16 KiB
sub-implementer-backend-0 — W2 KHKK BE (CRUD nháp + căn cứ b.8-9)
Run:
2026-07-29-S161-khkk-w2-crud· vai: implementer-backend · lượt 0 Luật: ghi TỪNG MỤC ngay sau khi làm xong mục đó (W1: 5/7 lượt chết #53 — file này là thứ duy nhất sống).
§0 — AUDIT TRƯỚC KHI VIẾT (đọc đĩa, không đoán)
Mọi số dưới đây đo từ đĩa lúc bắt đầu lượt (HEAD 77c723b, W1 đã land ở a8027c0).
0.1 — 5 anchor spec §③-A: TỰ MỞ LẠI (không tin gate của lead)
| # | Anchor | Đo được | Ghi chú |
|---|---|---|---|
| A1 | Application/Office/ProposalFeatures.cs:427-439 |
✔ SỐNG — allLevels = wf.Steps.OrderBy(s=>s.Order).SelectMany(s=>s.Levels...) rồi ElementAtOrDefault(CurrentApprovalLevelOrder-1) |
🔴 BẪY: flatten toàn workflow ⇒ mọi Level thành 1 chuỗi tuần tự = OR-of-N biến thành AND. ĐỌC ĐỂ NÉ. W2 KHÔNG copy (phần duyệt = W3) |
| A2 | PurchaseEvaluations/CreateContractFromEvaluationFeatures.cs:53-62 |
✔ guard pe.Phase != DaDuyet → ConflictException (:53-54) + winners = pe.Suppliers.Where(s => s.IsWinner) (:56) + winners.Count == 0 → Conflict (:57-58) |
phép tính tiền ở :88-90 |
| A3 | ProposalFeatures.cs:258-267 |
✔ guard workflow-type TRỌN 2 nhánh: wfType is null → NotFoundException (:264-265) + wfType != ProposalGeneral → ConflictException (:266-267) |
copy CẢ nhánh NotFound |
| A4 | MenuKeys.cs:42 const + :180 vào All |
✔ KeHoachKyKet = "KeHoachKyKet" (:42), có mặt trong All (:180) |
⇒ 4 policy KeHoachKyKet.{Read,Create,Update,Delete} sinh runtime (Program.cs nhân All × Actions) |
| A5 | ApprovalWorkflow.cs enum |
✔ ContractSigningPlan = 10 (append-only, đúng slot) |
dùng cho guard type |
0.2 — Nền W1 đang có trên đĩa (7 entity + 7 config + DbSet + codegen)
- Entity:
Domain/ContractSigningPlans/{ContractSigningPlan, ContractSigningPlanLine, ...DossierItem, ...Attachment, ...LevelOpinion, ...Changelog, ...Approval}.cs+ContractSigningPlanPhase.cs. - Phase enum:
DangSoanThao=1 · ChoDuyet=2 · DaDuyet=3 · TraLai=98 · TuChoi=99(ContractSigningPlanPhase.cs:8-15). - DbSet ×7:
IApplicationDbContext.cs:149-155. - Codegen:
Application/ContractSigningPlans/Services/IContractSigningPlanCodeGenerator.cs—Task<string> GenerateAsync(CancellationToken); DI đã đăng kýInfrastructure/DependencyInjection.cs:42.
0.3 — MaxLength THẬT (EF = source of truth, KHÔNG trust spec — bài S35)
Đo từ Configurations (không đọc spec):
| Field | EF config | file:line |
|---|---|---|
Plan.MaKeHoach |
50 | ContractSigningPlanConfiguration.cs:19 |
Plan.HoSoLink |
1000 | :21 |
Plan.GhiChu |
2000 | :22 |
Line.Note |
1000 | ContractSigningPlanLineConfiguration.cs:20 |
DossierItem.Name |
500 required | ContractSigningPlanDossierItemConfiguration.cs:18 |
DossierItem.TvgsName |
200 | :19 |
DossierItem.Note |
1000 | :20 |
Attachment.FileName |
500 | ContractSigningPlanAttachmentConfiguration.cs:24 |
Attachment.StoragePath |
1000 | :25 |
Attachment.ContentType |
200 | :26 |
Attachment.Note |
1000 | :28 |
0.4 — HasQueryFilter: KHKK CÓ (khác HRM), trừ 2 bảng audit
grep -c HasQueryFilter trên 7 config KHKK: có ở ContractSigningPlan/Line/DossierItem/Attachment (:42/:33/:30/:38).
Changelog + Approval = BaseEntity (không IsDeleted) ⇒ không có filter. LevelOpinion = AuditableEntity nhưng CỐ Ý KHÔNG filter (ContractSigningPlanLevelOpinion.cs:18-20 — site dọn chữ ký mồ côi phải thấy row soft-deleted).
⇒ Hệ quả cho W2: list/detail KHÔNG cần .Where(!IsDeleted) thủ công (filter tự chạy); ngược lại màn "đã xóa" BẮT BUỘC IgnoreQueryFilters().Where(x => x.IsDeleted) — luật ContractFeatures.cs:356-360 (Ignore GỠ filter chứ không ĐẢO).
0.5 — LỆCH SPEC vs COMMENT W1 (lead đã phân xử: SPEC THẮNG) + 2 site cùng-lớp lead CHƯA nêu
Lead giao sửa 1 chỗ. Grep cùng-lớp ra 3 chỗ khai cùng một điều sai ("gen lúc Submit"):
| # | Site | Nguyên văn | Lead giao? |
|---|---|---|---|
| 1 | Domain/ContractSigningPlans/ContractSigningPlan.cs:17 |
// "KHKK/2026/001" — gen atomic lúc Submit (W2) |
✔ CÓ |
| 2 | Application/ContractSigningPlans/Services/IContractSigningPlanCodeGenerator.cs:9 |
// Caller: W2 Submit (gen lúc trình duyệt, KHÔNG lúc tạo nháp — mirror quyết spec cũ) |
✗ KHÔNG |
| 3 | Infrastructure/.../ContractSigningPlanConfiguration.cs:24 |
// Mã phiếu UNIQUE khi đã gen (gen lúc Submit ⇒ nháp còn null) |
✗ KHÔNG |
Xử: sửa cả 3 (comment-only, 0 thay đổi hành vi) — theo bài feedback_root_cause_over_symptom "được chỉ 1 chỗ ⇒ PHẢI grep cùng-lớp". Index filtered [MaKeHoach] IS NOT NULL GIỮ NGUYÊN (vẫn đúng: gen-lúc-create thì cột luôn NOT NULL, filter chỉ rộng hơn cần thiết chứ không sai).
ĐÃ LÀM — 3 Edit comment-only:
ContractSigningPlan.cs:17-22(5 dòng comment mới + prop giữ nguyên chữ kýpublic string? MaKeHoach)IContractSigningPlanCodeGenerator.cs:9-11ContractSigningPlanConfiguration.cs:24-27
§1 — CREATE (CreateContractSigningPlanCommand)
File: src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs (Region 1).
Chữ ký: CreateContractSigningPlanCommand(Guid PeId, Guid ApprovalWorkflowId, string? GhiChu = null) : IRequest<CreateContractSigningPlanResult>
→ CreateContractSigningPlanResult(Guid Id, string MaKeHoach, int LineCount).
🔴 Tên PeId (KHÔNG PurchaseEvaluationId theo convention repo) là CỐ Ý: spec §②-2 viết đúng chữ đó, lane FE dựng body JSON từ CÙNG bản spec ⇒ đổi tên = gãy tích hợp 2 lane (camelCase body sẽ là peId). Đã ghi lý do vào <remarks> ngay trên record.
4 rào theo đúng thứ tự (fail sớm, rào rẻ trước):
- Phase PE
!= DaDuyet→ConflictException— mirrorCreateContractFromEvaluationFeatures.cs:53-54. winners = pe.Suppliers.Where(s => s.IsWinner), rỗng → Conflict — mirror:56-58.- Đã có kế hoạch SỐNG (
AnyAsync(p.PurchaseEvaluationId == pe.Id && p.Phase != TuChoi)) → Conflict 409.TuChoikhông tính ⇒ lập lại được; phiếu xoá mềm cũng không tính (global filter tự loại) ⇒ acceptance "DELETE → tạo lại → 201" đi qua rào này. - Workflow type — copy TRỌN 2 nhánh Proposal
:264-267vào helperContractSigningPlanScope.EnsureWorkflowTypeAsync(dùng lại ở Update). NotFound giữ nguyên làNotFoundException, sai loại làConflictException.
Codegen: await codeGen.GenerateAsync(ct) gọi TRƯỚC db.ContractSigningPlans.Add(plan).
Lý do đo được: ContractSigningPlanCodeGenerator.cs:23-43 mở transaction SERIALIZABLE rồi gọi db.SaveChangesAsync nội bộ ⇒ nếu phiếu đã nằm trong change-tracker, lượt lưu đó flush phiếu DỞ (chưa có mã) trong transaction của codegen (bài S88 self-committing codegen). Add + SaveChanges đúng 1 lần ở cuối.
Lines auto-sinh: 1 dòng / winner, PeReferenceAmount = SUM(Quote.ThanhTien WHERE IsSelected && q.PurchaseEvaluationSupplierId == w.Id) — copy :88-90.
🔴 So với w.Id (Id của ROW PurchaseEvaluationSuppliers), KHÔNG phải w.SupplierId (Id master) — nhầm 2 cái này là cộng nhầm tiền của NCC khác. Snapshot 1 lần, không đọc live (cùng triết lý freeze NS Mig 67).
Denorm: ProjectId/DepartmentId ← PE. DrafterUserId ← currentUser (KHÔNG phải pe.DrafterUserId) — xem §6 DECISION-2.
Changelog: 1 entry Header/Insert/PhaseAtChange=DangSoanThao + ContextNote = "Nguồn: phiếu Duyệt NCC {MaPhieu} ({Id})".
§2 — UPDATE nháp (UpdateContractSigningPlanDraftCommand)
(Guid Id, string? GhiChu = null, string? HoSoLink = null, Guid? ApprovalWorkflowId = null).
- Guard qua
ContractSigningPlanScope.EnsureDraftEditable(plan, currentUser): (a) owner ∥ Admin →ForbiddenException; (b) phase ∈ {DangSoanThao, TraLai} →ConflictException. - Null-safe 3/3 field (#73/S42):
null= client không gửi ⇒ GIỮ;""= chủ động xoá ⇒ set null (khuônHoSoLinkPurchaseEvaluationFeatures.cs:285-286). Không field nào absolute-set. - Re-pin workflow: guard type LẠI (admin có thể đổi loại quy trình giữa chừng) — cùng helper §1 rào 4.
§3 — GET detail / LIST / INBOX / ĐÃ XOÁ
GetContractSigningPlanQuery — Include Lines + DossierItems + Attachments + LevelOpinions (khuôn Proposal :159-162) + tra thêm: PE (MaPhieu/TenGoiThau), Project (Name/Code), Department, Drafter, tên NCC 1 lượt (Suppliers dictionary dùng chung Lines + DossierItems), workflow tree (Steps→Levels + tên người duyệt) và metadata Cấp cho LevelOpinions (StepOrder/StepName/LevelOrder/ApproverUserId) — mirror ProposalFeatures.cs:182-217 nhưng KHÔNG dùng SelectMany flatten (chỉ dựng Dictionary<levelId, meta>).
ListContractSigningPlansQuery(Phase?, ProjectId?, PurchaseEvaluationId?, PendingMe) : PagedRequest
- Không cần
.Where(!IsDeleted)thủ công — 4 bảng KHKK CÓ global filter (§0.4). PendingMe=true→ giao vớiResolveV2InboxIdsAsync(non-admin) hoặcAdminInboxPhases(admin).- IDOR scope non-admin — xem §6 DECISION-3.
GetMyContractSigningPlanInboxQuery — admin: phase-list TƯỜNG MINH AdminInboxPhases = [ChoDuyet] (mirror PE :731-742, nháp KHÔNG vào inbox theo S89); non-admin: ResolveV2InboxIdsAsync mirror PurchaseEvaluationFeatures.cs:817-852 — in-memory join vì CurrentWorkflowStepIndex là INDEX 0-based vào list Steps đã sort, không dịch thẳng SQL được; so Cấp bằng GIÁ TRỊ Level.Order + Any(...) ⇒ giữ OR-of-N. W2 luôn trả rỗng (chưa có phiếu ChoDuyet vì transition = W3) — wire sẵn, W3 không phải sửa list/inbox.
ListDeletedContractSigningPlansQuery — IgnoreQueryFilters().Where(x => x.IsDeleted) theo LUẬT ContractFeatures.cs:356-360 (Ignore GỠ filter chứ không ĐẢO) + cùng vị ngữ IDOR như list sống.
Projection dùng chung 3 query qua ListContractSigningPlansQueryHandler.ProjectPagedAsync (1 hình dạng DTO, không lệch).
§4 — Dossier items (căn cứ b.8-9) + Attachments
UpsertContractSigningPlanDossierItemCommand(PlanId, Id?, Kind, SupplierId?, Name, Status, TvgsName?, TvgsResultAt?, Note?)→Guid.Id == null⇒ Insert, có ⇒ Update (verify item thuộc ĐÚNG phiếu). Guard =EnsureDraftEditable⇒ chỉ {DangSoanThao, TraLai}.DeleteContractSigningPlanDossierItemCommand—Remove()= XOÁ MỀM (AuditingInterceptor.cs:54-63đổi Deleted→Modified + set IsDeleted/DeletedAt/DeletedBy) + changelog.- Attachments MỞ MỌI PHASE (triết lý PE S147): người duyệt phải bổ sung được scan kết quả TVGS ngay lúc đang duyệt ⇒ 3 handler upload/download/delete KHÔNG gọi
EnsureDraftEditable. Chỉ CĂN CỨ mới khoá theo phase. - Upload:
IFileStorage+ pathcontract-signing-plans/{planId}/{attId}_{safeName}+ sanitize (mirror PE:115-123).DossierItemIdphải thuộc đúng phiếu — handler tự kiểm vì cột đó CỐ Ý không có FK vật lý (tránh multiple cascade path,ContractSigningPlanAttachment.cs:14-19) ⇒ DB không kiểm hộ. - Validator MaxLength lấy từ EF KHKK (FileName 500 / ContentType 200 / Note 1000) — KHÔNG copy số của PE (255/100/500); PE hẹp hơn vì là bản cũ.
§5 — DELETE phiếu + PICKER
DeleteContractSigningPlanCommand(Id, Reason?) — owner ∥ Admin; allow-list {DangSoanThao, TuChoi} (M6: thiếu đường này thì lỡ tạo nhầm 1 kế hoạch là PE khoá 409 vĩnh viễn).
🔴 Thứ tự db.ContractSigningPlans.Remove(plan) TRƯỚC Add(changelog) — bug T26 S155: EF chạy cascade client-side NGAY tại Remove() (CascadeDeleteTiming.Immediate), changelog đang ở state Added sẽ bị DETACH ⇒ SaveChanges ghi 0 row, IM LẶNG. (Ở dossier-item/attachment thì changelog trỏ PLAN chứ không trỏ thứ bị Remove nên không dính, vẫn giữ cùng thứ tự cho nhất quán.)
ListApprovedPeAwaitingPlanQuery — mirror CreateContractFromEvaluationFeatures.cs:162-174, đổi vị ngữ: bỏ e.ContractId == null (không liên quan GĐ2), thêm có ≥1 IsWinner + chưa có plan sống ⇒ picker khớp ĐÚNG 2 rào của Create, không mời user bấm vào thứ chắc chắn 409.
⚠️ WinnerQuoteTotal viết lại sau lần build đầu: bỏ Any(...) lồng trong Where của subquery (sâu 2 tầng, vỡ dịch SQL chỉ nổ LÚC CHẠY chứ không nổ lúc build) → dùng hình dạng subquery-có-join đã chạy được ở :185-189.
§6 — 4 QUYẾT ĐỊNH lệch/ngoài chữ của lệnh (khai để lead phân xử)
| # | Chỗ | Lệnh/spec nói | Tôi làm | Vì sao |
|---|---|---|---|---|
| D1 | tên tham số Create | spec + lệnh viết PeId |
PeId (trái convention repo PurchaseEvaluationId) |
spec = hợp đồng chung 2 lane; FE đọc CÙNG spec ⇒ đổi tên là gãy body JSON |
| D2 | DrafterUserId |
lệnh gộp "DepartmentId/DrafterUserId/ProjectId denorm từ PE" | = currentUser.UserId (chỉ Project+Dept mới denorm) |
(a) entity khai "BCH soạn kế hoạch" ContractSigningPlan.cs:24 — người soạn PE (QS) ≠ người lập kế hoạch (BCH); (b) khuôn Proposal :276 + PE :147 đều lấy currentUser; (c) pe.DrafterUserId là Guid? còn cột này non-null ⇒ copy sang cho phiếu PE cũ thiếu drafter sẽ ra Guid.Empty = hỏng cả quyền sửa lẫn scope list |
| D3 | IDOR scope list | lệnh không nói | non-admin thấy: phiếu MÌNH soạn ∪ phiếu đã rời nháp thuộc quy trình mình có chân duyệt | bài 8b S155 "handler MỚI mặc định KHÔNG có rào nào"; khuôn Proposal (0 rào) là khuôn HỞ. Bỏ vế eligible-phase-theo-role vì module này KHÔNG có bản đồ role→phase. Nới cho cả phòng = thêm 1 vế DepartmentId ở đúng 1 chỗ (comment đã chỉ chỗ), áp cả list sống lẫn màn xoá |
| D4 | ProposedAmount lúc tạo |
không nói | mồi = PeReferenceAmount |
W2 KHÔNG có command sửa dòng giá (spec §②-2 không liệt kê) ⇒ để 0 là cả phiếu 0 và không ai sửa được. Mồi = giá đã duyệt ở PE = trường hợp thường gặp |
Cố ý KHÔNG làm (ngoài spec, để W3): command sửa Line (ProposedAmount/Note) · mọi transition (submit/approve/reject/return) · SlaDeadline để null (cột là display-only, không job — ContractSigningPlan.cs:34-37) · xoá phiếu đang duyệt (cần rào đúng-lượt như PeSoftDeleteFeatures).
§7 — Endpoint + authz (14 action, per-action policy 14/14)
Route gốc api/contract-signing-plans, class [Authorize(Policy = "KeHoachKyKet.Read")].
| Verb | Path | Policy |
|---|---|---|
| GET | / |
KeHoachKyKet.Read |
| GET | /inbox |
Read |
| GET | /deleted |
Read |
| GET | /approved-pe-awaiting-plan |
Read |
| GET | /{id} |
Read |
| POST | / |
Create |
| PUT | /{id} |
Update |
| DELETE | /{id} |
Delete |
| POST | /{id}/dossier-items |
Update |
| PUT | /{id}/dossier-items/{itemId} |
Update |
| DELETE | /{id}/dossier-items/{itemId} |
Update |
| POST | /{id}/attachments |
Update |
| GET | /{id}/attachments/{attId}/download |
Read |
| GET | /{id}/attachments/{attId}/view |
Read |
| DELETE | /{id}/attachments/{attId} |
Update |
(15 dòng bảng = 15 action; xem số đo grep ở §8.)
Ghi Read tường minh cả trên action chỉ-đọc: [Authorize] trần / để trống = lỗ hổng (gotcha #82 — ẩn menu KHÔNG đóng API). Dossier-item + attachment dùng Update chứ không Create: "tạo" ở module này = tạo PHIẾU.
Endpoint dossier-item verify id == cmd.ContractSigningPlanId → BadRequest (Pattern 12-ter).