Files
solution-erp/.claude/workflows/runs/2026-07-29-S161-khkk-w2-crud/sub-implementer-backend-0.md
2026-07-29 20:46:44 +07:00

16 KiB
Raw Blame History

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.csTask<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-11
  • ContractSigningPlanConfiguration.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):

  1. Phase PE != DaDuyetConflictException — mirror CreateContractFromEvaluationFeatures.cs:53-54.
  2. winners = pe.Suppliers.Where(s => s.IsWinner), rỗng → Conflict — mirror :56-58.
  3. Đã có kế hoạch SỐNG (AnyAsync(p.PurchaseEvaluationId == pe.Id && p.Phase != TuChoi)) → Conflict 409. TuChoi khô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.
  4. Workflow type — copy TRỌN 2 nhánh Proposal :264-267 vào helper ContractSigningPlanScope.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. DrafterUserIdcurrentUser (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ôn HoSoLink PurchaseEvaluationFeatures.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ới ResolveV2InboxIdsAsync (non-admin) hoặc AdminInboxPhases (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.

ListDeletedContractSigningPlansQueryIgnoreQueryFilters().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 = EnsureDraftEditablechỉ {DangSoanThao, TraLai}.
  • DeleteContractSigningPlanDossierItemCommandRemove() = 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 + path contract-signing-plans/{planId}/{attId}_{safeName} + sanitize (mirror PE :115-123). DossierItemId phả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.DrafterUserIdGuid? 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.ContractSigningPlanIdBadRequest (Pattern 12-ter).