diff --git a/docs/governance/so-yeu-cau-chu-du-an.md b/docs/governance/so-yeu-cau-chu-du-an.md index cf44484..cd55ae9 100644 --- a/docs/governance/so-yeu-cau-chu-du-an.md +++ b/docs/governance/so-yeu-cau-chu-du-an.md @@ -43,8 +43,10 @@ Dữ-liệu cũ (WAL · HANDOFF · auto-memory · migration-todos) **KHÔNG** đ | `YC-010` | "OK làm hết rồi khép trọn đi" | 2026-08-07 · S181 | đóng | **2** | `WAL` chain W5b/W3b/W6 | Khép trọn gói: W5b + W3b + phần còn lại W6 | `YC-008` | | `YC-011` | "1,2 -> làm đi, OK sau đó /session-end" | 2026-08-07 · S181 | mở | 1 | `WAL` `next:` | (1) push 42 commit · (2) cổng đồng-bộ + báo cáo cuối · rồi đóng phiên | | | `YC-012` | "nâng cap" *(🔸 trích từ `spec-…-07-08-2026.md:64`, cửa-sổ **S180**, KHÔNG bắt trực tiếp ở cửa-sổ này — khai đúng nấc)* | 2026-08-07 · S180 | đóng | 1 | `memory-budget.json` `_cap_raised_S181` | Lệnh dứt khoát nâng `hmw_width.cap` 20→30. 🔴 @S179 lead **tự thu-hẹp** chữ "OK" thành "chưa phải lệnh đổi số" — lần này không tự thu-hẹp | | -| `YC-013` | "Hiện tạo muốn cac quy trình này giống như duyệt NCC. Có thể thêm sửa xóa edit stick tương tự như duyệt NCC. Cho 2 /fable-real invest cách làm BE và Database, 1 /fable-real invest cách cấu hình giao diện front-end đúng nhất về UI lẫn giao diện, ko lệch. -> Ra các wave xử lý chi tiết nhất. cho 2 /fable-real review và tổng hợp các wave xử lý chi tiết. Cuối cùng fan-out cho Opus 5 MAX xử lý nhé." *(kèm 4 ảnh trang `approval-workflows-v2/DuyetNcc` + dialog Sửa-tại-chỗ)* | 2026-08-08 · S182 | **mở** | 1 | `runs/2026-08-08-S182-khkk-awf-mirror-ncc/` | 8 workflow "Duyệt KHKK — Nhóm 1..8" phải có ĐỦ chức năng như trang "Duyệt NCC": xem steps/cấp/NV + per-NV quyền duyệt + Sửa-tại-chỗ + Tạo-phiên-bản-mới + Ghim/Bỏ-ghim + Xoá-version + lịch-sử-versions. UI **không lệch** khuôn DuyetNcc. Pipeline anh chỉ định: 3 `/fable-real investigator-codebase` (BE · DB · FE-UI) → wave-plan chi tiết → 2 `/fable-real reviewer` → hmw fan-out **Opus 5 MAX** execute | | +| `YC-013` | "Hiện tạo muốn cac quy trình này giống như duyệt NCC. Có thể thêm sửa xóa edit stick tương tự như duyệt NCC. Cho 2 /fable-real invest cách làm BE và Database, 1 /fable-real invest cách cấu hình giao diện front-end đúng nhất về UI lẫn giao diện, ko lệch. -> Ra các wave xử lý chi tiết nhất. cho 2 /fable-real review và tổng hợp các wave xử lý chi tiết. Cuối cùng fan-out cho Opus 5 MAX xử lý nhé." *(kèm 4 ảnh trang `approval-workflows-v2/DuyetNcc` + dialog Sửa-tại-chỗ)* | 2026-08-08 · S182 | **mở** *(code+deploy XONG — còn acceptance-mắt-anh + bookend @close rồi lật đóng; TASK → WAL)* | **2** | `runs/2026-08-08-S182-khkk-awf-mirror-ncc/` | 8 workflow "Duyệt KHKK — Nhóm 1..8" phải có ĐỦ chức năng như trang "Duyệt NCC": xem steps/cấp/NV + per-NV quyền duyệt + Sửa-tại-chỗ + Tạo-phiên-bản-mới + Ghim/Bỏ-ghim + Xoá-version + lịch-sử-versions. UI **không lệch** khuôn DuyetNcc. Pipeline anh chỉ định: 3 `/fable-real investigator-codebase` (BE · DB · FE-UI) → wave-plan chi tiết → 2 `/fable-real reviewer` → hmw fan-out **Opus 5 MAX** execute | | | `YC-014` | "C — Mờ ô chưa nghe (Recommended)" *(AskUser D3 @S182 — sau khi invest phát hiện 6/9 checkbox KHKK-runtime bỏ qua)* | 2026-08-08 · S182 | đóng | 1 | `spec-khkk-awf-mirror-ncc-DRAFT.md` MỤC 3 D3 | Dialog KHKK giữ layout y khuôn; ô máy-chưa-nghe **mờ + tooltip "chưa áp dụng cho KHKK"** (tiền lệ ô Duyệt-thẳng-Cấp-cuối @K3). KHÔNG port semantics đợt này (W3 hủy); port sau thì gỡ mờ dần | | +| `YC-015` | "làm nốt cho ra giao diện luôn nhé" *(qua `/tiep` @S183)* | 2026-08-08 · S183 | đóng | — | WAL chain `[x]` cicd-4-chân | Verify deploy `706134da` cho giao diện 8 panel SỐNG trên prod — 4/4 chân PASS (bundle ×2 rotate + marker + DLL 3-hit + 8-row IsActive=1 + pin-lệch 0) | **`YC-013`** | +| `YC-016` | "1 -> (c) THEO TAY CẦM PHIẾU -> OK nhé" | 2026-08-08 · S183 | **mở** | 1 | WAL chain (TASK → WAL) | **Phán quyết slot (67)** treo từ S172: upload file-khi-duyệt KHKK theo TAY CẦM PHIẾU — phiếu Nháp/Trả-lại (tay người soạn) ⇒ NGƯỜI SOẠN up được; phiếu Chờ-duyệt ⇒ CHỈ NGƯỜI DUYỆT (thuộc workflow phiếu); guard ở API-layer (#82 2-tầng), kèm test. Slot (67) ĐÓNG | | > 🔴 **MỘT yêu-cầu, nhắc 4 lần.** Đây là dữ-liệu **TC-01** sinh ra để bắt, và nó tố-giác chính lead: hiểu sai ở `YC-002` nên anh phải nói lại 3 lần. **Ghi lại chứ không làm đẹp.** > diff --git a/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs b/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs index c1082de..8467b76 100644 --- a/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs +++ b/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs @@ -1597,6 +1597,12 @@ public class UploadContractSigningPlanAttachmentCommandHandler( .FirstOrDefaultAsync(p => p.Id == request.ContractSigningPlanId, ct) ?? throw new NotFoundException("ContractSigningPlan", request.ContractSigningPlanId); + // [YC-016 @S183 — "(c) THEO TAY CẦM PHIẾU"] CHỈ rẽ nhánh file-khi-duyệt; đường + // căn-cứ dossier (purpose khác, người soạn upload lúc soạn) GIỮ NGUYÊN hành vi cũ. + if (request.Purpose == ContractSigningPlanAttachmentPurpose.ApprovalAttachment) + await ContractSigningPlanWorkflowGuards.EnsureApprovalAttachmentUploaderAsync( + db, plan, currentUser.UserId, ct); + // Căn cứ gắn kèm (nếu có) phải thuộc ĐÚNG phiếu này — `DossierItemId` chỉ có index, // KHÔNG FK vật lý (`ContractSigningPlanAttachment.cs:14-19` — tránh multiple cascade // path) ⇒ DB không kiểm hộ, handler phải tự kiểm. diff --git a/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanWorkflowGuards.cs b/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanWorkflowGuards.cs index 3f4638f..4019960 100644 --- a/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanWorkflowGuards.cs +++ b/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanWorkflowGuards.cs @@ -2,6 +2,7 @@ using System.Text.RegularExpressions; using Microsoft.EntityFrameworkCore; using SolutionErp.Application.Common.Exceptions; using SolutionErp.Application.Common.Interfaces; +using SolutionErp.Domain.ContractSigningPlans; namespace SolutionErp.Application.ContractSigningPlans; @@ -75,4 +76,52 @@ public static class ContractSigningPlanWorkflowGuards $"Phiếu nhóm N{approvalGroup} đang gắn quy trình N{k} — " + "vào phiếu (Nháp/Trả lại) đổi lại quy trình đúng nhóm trước khi trình."); } + + /// [YC-016 @S183 — anh chốt "(c) THEO TAY CẦM PHIẾU"] File-khi-duyệt (`Purpose = + /// ApprovalAttachment`) đi theo TAY đang cầm phiếu, không theo policy module: + /// • Nháp / Trả lại (tay người soạn) ⇒ CHỈ người soạn (`CreatedBy`); + /// • Chờ duyệt (tay người duyệt) ⇒ CHỈ thành viên quy trình đã pin + /// (mọi `Level.ApproverUserId` của workflow — membership, KHÔNG đòi đúng-lượt: + /// chữ anh @S171 "file do người duyệt tải lên trong quá trình duyệt" không thu + /// hẹp về acting-level; siết thêm sau = 1 mệnh-đề); + /// • Đã duyệt / Từ chối (terminal — không ai cầm) ⇒ CHẶN cả hai (hồ sơ đã chốt). + /// + /// 🔴 Vì sao cần rào này dù endpoint đã có `[Authorize(Policy="KeHoachKyKet.Update")]`: + /// policy là quyền MODULE (người soạn đương nhiên có để soạn phiếu) ⇒ đo prod @S172: + /// `test.drafter@` up được file duyệt vào phiếu KHÔNG thuộc tay mình. Display-layer + /// (nút chỉ nằm trong dialog duyệt) không phải rào — #82 hai tầng độc lập. + public static async Task EnsureApprovalAttachmentUploaderAsync( + IApplicationDbContext db, ContractSigningPlan plan, Guid? userId, CancellationToken ct) + { + switch (plan.Phase) + { + case ContractSigningPlanPhase.DangSoanThao: + case ContractSigningPlanPhase.TraLai: + if (userId is null || plan.CreatedBy != userId) + throw new ConflictException( + "Phiếu đang ở tay người soạn (Nháp/Trả lại) — chỉ người soạn được đính kèm file duyệt."); + return; + + case ContractSigningPlanPhase.ChoDuyet: + { + // Submit đã ép pin (`SubmitAsync` guard `ApprovalWorkflowId is not Guid ⇒ 409`) + // nên nhánh null ở đây là fail-safe, không phải đường đi thường. + if (plan.ApprovalWorkflowId is not Guid awId) + throw new ConflictException( + "Phiếu chưa gắn quy trình duyệt — không xác định được người duyệt."); + + var isMember = userId is Guid uid && await db.ApprovalWorkflowLevels + .AsNoTracking() + .AnyAsync(l => l.Step!.ApprovalWorkflowId == awId && l.ApproverUserId == uid, ct); + if (!isMember) + throw new ConflictException( + "Phiếu đang chờ duyệt — chỉ người duyệt trong quy trình được đính kèm file khi duyệt."); + return; + } + + default: // DaDuyet / TuChoi — terminal, hồ sơ đã chốt + throw new ConflictException( + "Phiếu đã kết thúc — hồ sơ đã chốt, không đính kèm thêm file duyệt."); + } + } } diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanApprovalTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanApprovalTests.cs index 85d3a5f..0f99f7d 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanApprovalTests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanApprovalTests.cs @@ -1448,4 +1448,186 @@ public class ContractSigningPlanApprovalTests + "là khoá cứng mọi phiếu đang pin bản đã nghỉ, đúng nhóm phiếu cần sửa nhất"); plan.ApprovalWorkflowId.Should().Be(banMoi, "và pin không được tự đổi trong lượt sửa GhiChu"); } + + // =================================================================== + // [YC-016 @S183 — anh chốt "(c) THEO TAY CẦM PHIẾU"] File-khi-duyệt (Purpose=ApprovalAttachment) + // đi theo TAY cầm phiếu — 8 ca, mỗi ca 1 DbContext MỚI (khuôn (2) đầu file). + // Guard: `ContractSigningPlanWorkflowGuards.EnsureApprovalAttachmentUploaderAsync`. + // Control (ca 8): đường căn-cứ (DossierScan) KHÔNG bị rào mới đụng — hành vi cũ giữ nguyên. + // =================================================================== + + private sealed class FakeStorage : SolutionErp.Application.Common.Interfaces.IFileStorage + { + public Task SaveAsync(string relativePath, Stream content, CancellationToken ct = default) + => Task.FromResult(relativePath); + public Task OpenReadAsync(string relativePath, CancellationToken ct = default) + => Task.FromResult(new MemoryStream()); + public Task DeleteAsync(string relativePath, CancellationToken ct = default) => Task.CompletedTask; + public bool Exists(string relativePath) => true; + } + + private static async Task UploadAsync( + KhkkWorkflowFixture f, TestCurrentUser actor, Guid planId, + ContractSigningPlanAttachmentPurpose purpose = ContractSigningPlanAttachmentPurpose.ApprovalAttachment) + { + await using var db = f.NewDb(actor); + var handler = new UploadContractSigningPlanAttachmentCommandHandler(db, new FakeStorage(), actor); + await using var content = new MemoryStream(new byte[] { 1, 2, 3, 4 }); + return await handler.Handle(new UploadContractSigningPlanAttachmentCommand( + planId, DossierItemId: null, "bien-ban.pdf", "application/pdf", 4, content, + purpose, Note: "test"), CancellationToken.None); + } + + private static async Task CountAttachmentsAsync(KhkkWorkflowFixture f, Guid planId) + { + await using var db = f.NewDb(TestCurrentUser.System()); + return await db.ContractSigningPlanAttachments + .CountAsync(a => a.ContractSigningPlanId == planId, CancellationToken.None); + } + + [Fact] + public async Task UploadDuyet_Nhap_NguoiSoan_DuocUp() + { + using var f = new KhkkWorkflowFixture(); + var drafter = As(await AddUserAsync(f, "Người soạn"), "Drafter"); + var approver = await AddUserAsync(f, "NV duyệt"); + var wf = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver.Id]]]); + var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/701", + ContractSigningPlanPhase.DangSoanThao, null, null, [new LineSeed(100m, 100m)]); + + var dto = await UploadAsync(f, drafter, planId); + + dto.Purpose.Should().Be(ContractSigningPlanAttachmentPurpose.ApprovalAttachment); + (await CountAttachmentsAsync(f, planId)).Should().Be(1, + "Nháp = tay người soạn ⇒ người soạn up file duyệt được (YC-016 nhánh 1)"); + } + + [Fact] + public async Task UploadDuyet_TraLai_NguoiSoan_DuocUp() + { + using var f = new KhkkWorkflowFixture(); + var drafter = As(await AddUserAsync(f, "Người soạn"), "Drafter"); + var approver = await AddUserAsync(f, "NV duyệt"); + var wf = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver.Id]]]); + var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/702", + ContractSigningPlanPhase.TraLai, null, null, [new LineSeed(100m, 100m)]); + + await UploadAsync(f, drafter, planId); + + (await CountAttachmentsAsync(f, planId)).Should().Be(1, + "Trả lại = phiếu về tay người soạn ⇒ vẫn là tay người soạn"); + } + + [Fact] + public async Task UploadDuyet_ChoDuyet_NguoiSoan_409_DiaBatDong() + { + using var f = new KhkkWorkflowFixture(); + var drafter = As(await AddUserAsync(f, "Người soạn"), "Drafter"); + var approver = await AddUserAsync(f, "NV duyệt"); + var wf = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver.Id]]]); + var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/703", + ContractSigningPlanPhase.ChoDuyet, 0, 1, [new LineSeed(100m, 100m)]); + + var act = async () => await UploadAsync(f, drafter, planId); + + (await act.Should().ThrowAsync( + "phiếu đã rời tay người soạn — up tiếp là nhét tài liệu vào hồ sơ đang duyệt")) + .Which.Message.Should().Contain("chờ duyệt").And.Contain("người duyệt"); + (await CountAttachmentsAsync(f, planId)).Should().Be(0, "409 thì đĩa phải bất động"); + } + + [Fact] + public async Task UploadDuyet_ChoDuyet_ThanhVienQuyTrinh_DuocUp() + { + using var f = new KhkkWorkflowFixture(); + var drafter = As(await AddUserAsync(f, "Người soạn"), "Drafter"); + var approver1 = await AddUserAsync(f, "NV duyệt Bước 1"); + var approver2 = await AddUserAsync(f, "NV duyệt Bước 2"); + var wf = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver1.Id]], [[approver2.Id]]]); + var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/704", + ContractSigningPlanPhase.ChoDuyet, 0, 1, [new LineSeed(100m, 100m)]); + + // approver2 thuộc BƯỚC SAU (chưa tới lượt) — membership là đủ, KHÔNG đòi đúng-lượt + // (chữ anh @S171 không thu hẹp về acting-level; guard doc-comment khai rõ). + await UploadAsync(f, As(approver2, "DeptManager"), planId); + + (await CountAttachmentsAsync(f, planId)).Should().Be(1, + "thành viên quy trình (kể cả bước sau) up được khi phiếu đang chờ duyệt"); + } + + [Fact] + public async Task UploadDuyet_Nhap_NguoiDuyet_409() + { + using var f = new KhkkWorkflowFixture(); + var drafter = As(await AddUserAsync(f, "Người soạn"), "Drafter"); + var approver = await AddUserAsync(f, "NV duyệt"); + var wf = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver.Id]]]); + var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/705", + ContractSigningPlanPhase.DangSoanThao, null, null, [new LineSeed(100m, 100m)]); + + var act = async () => await UploadAsync(f, As(approver, "DeptManager"), planId); + + (await act.Should().ThrowAsync( + "Nháp = phiếu chưa trình — người duyệt chưa có việc gì với nó")) + .Which.Message.Should().Contain("tay người soạn"); + } + + [Fact] + public async Task UploadDuyet_ChoDuyet_NgoaiCuoc_409() + { + using var f = new KhkkWorkflowFixture(); + var drafter = As(await AddUserAsync(f, "Người soạn"), "Drafter"); + var approver = await AddUserAsync(f, "NV duyệt"); + var outsider = await AddUserAsync(f, "Người ngoài quy trình"); + var wf = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver.Id]]]); + var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/706", + ContractSigningPlanPhase.ChoDuyet, 0, 1, [new LineSeed(100m, 100m)]); + + // outsider đã QUA policy module từ controller (ca đo prod @S172) — guard là rào CUỐI. + var act = async () => await UploadAsync(f, As(outsider, "DeptManager"), planId); + + (await act.Should().ThrowAsync( + "đây chính là ca đo prod @S172: qua policy nhưng KHÔNG thuộc tay nào")) + .Which.Message.Should().Contain("người duyệt trong quy trình"); + } + + [Fact] + public async Task UploadDuyet_Terminal_ChanCaHai() + { + using var f = new KhkkWorkflowFixture(); + var drafter = As(await AddUserAsync(f, "Người soạn"), "Drafter"); + var approver = await AddUserAsync(f, "NV duyệt"); + var wf = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver.Id]]]); + var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/707", + ContractSigningPlanPhase.DaDuyet, null, null, [new LineSeed(100m, 100m)]); + + var actDrafter = async () => await UploadAsync(f, drafter, planId); + var actApprover = async () => await UploadAsync(f, As(approver, "DeptManager"), planId); + + (await actDrafter.Should().ThrowAsync()) + .Which.Message.Should().Contain("đã kết thúc"); + (await actApprover.Should().ThrowAsync( + "terminal = không ai cầm phiếu — hồ sơ đã chốt, cả hai đều bị chặn")) + .Which.Message.Should().Contain("đã kết thúc"); + (await CountAttachmentsAsync(f, planId)).Should().Be(0); + } + + [Fact] + public async Task UploadCanCu_DuongSoan_KhongBiRaoMoiDung() + { + using var f = new KhkkWorkflowFixture(); + var drafter = As(await AddUserAsync(f, "Người soạn"), "Drafter"); + var approver = await AddUserAsync(f, "NV duyệt"); + var wf = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver.Id]]]); + var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/708", + ContractSigningPlanPhase.ChoDuyet, 0, 1, [new LineSeed(100m, 100m)]); + + // CONTROL: purpose = DossierScan (đường căn-cứ, KHÔNG phải file-khi-duyệt) — guard mới + // CHỈ rẽ nhánh ApprovalAttachment ⇒ hành vi cũ (cho qua) GIỮ NGUYÊN. Ca này chết nếu + // ai đó nới guard sang mọi purpose "cho chắc" — đó là đổi hành vi NGOÀI phán quyết YC-016. + await UploadAsync(f, drafter, planId, ContractSigningPlanAttachmentPurpose.DossierScan); + + (await CountAttachmentsAsync(f, planId)).Should().Be(1, + "đường căn-cứ giữ nguyên hành vi cũ — YC-016 chỉ phán về file-khi-duyệt"); + } }