[CLAUDE] App: YC-016 guard file-khi-duyệt KHKK theo TAY CẦM PHIẾU (slot 67 đóng)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m17s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m17s
Anh chốt "(c) THEO TAY CẦM PHIẾU": Nháp/Trả-lại (tay người soạn) ⇒ chỉ người soạn up file duyệt; Chờ-duyệt ⇒ chỉ thành viên quy trình đã pin (membership, không đòi đúng-lượt — chữ anh @S171 không thu hẹp); terminal DaDuyet/TuChoi ⇒ chặn cả hai (hồ sơ đã chốt). Guard mới EnsureApprovalAttachmentUploaderAsync (WorkflowGuards) wire vào UploadContractSigningPlanAttachmentCommandHandler — CHỈ rẽ nhánh Purpose=ApprovalAttachment; đường căn-cứ dossier giữ nguyên hành vi cũ (có control-test chặn nới guard ngoài phán quyết). Đóng lỗ đo prod @S172: drafter qua policy KeHoachKyKet.Update up được file vào phiếu không thuộc tay mình (#82 hai tầng — policy là quyền module, không phải quyền theo tay). FE 0-đổi (nút vốn chỉ trong dialog duyệt — display-layer; guard này là API-layer thật). Test 660 → 668 (+8: 3 nhánh tay × 2 chủ thể + ngoài-cuộc + terminal-2-vế + control đường-soạn). YC-016 vào sổ; YC-013 nhắc=2 (YC-015 verify 4-chân). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@ -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-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-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-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-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.**
|
> 🔴 **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.**
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1597,6 +1597,12 @@ public class UploadContractSigningPlanAttachmentCommandHandler(
|
|||||||
.FirstOrDefaultAsync(p => p.Id == request.ContractSigningPlanId, ct)
|
.FirstOrDefaultAsync(p => p.Id == request.ContractSigningPlanId, ct)
|
||||||
?? throw new NotFoundException("ContractSigningPlan", request.ContractSigningPlanId);
|
?? 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,
|
// 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
|
// 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.
|
// path) ⇒ DB không kiểm hộ, handler phải tự kiểm.
|
||||||
|
|||||||
@ -2,6 +2,7 @@ using System.Text.RegularExpressions;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using SolutionErp.Application.Common.Exceptions;
|
using SolutionErp.Application.Common.Exceptions;
|
||||||
using SolutionErp.Application.Common.Interfaces;
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
using SolutionErp.Domain.ContractSigningPlans;
|
||||||
|
|
||||||
namespace SolutionErp.Application.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} — " +
|
$"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.");
|
"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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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");
|
+ "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");
|
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<string> SaveAsync(string relativePath, Stream content, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult(relativePath);
|
||||||
|
public Task<Stream> OpenReadAsync(string relativePath, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult<Stream>(new MemoryStream());
|
||||||
|
public Task DeleteAsync(string relativePath, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
|
public bool Exists(string relativePath) => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<ContractSigningPlanAttachmentDto> 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<int> 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<ConflictException>(
|
||||||
|
"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<ConflictException>(
|
||||||
|
"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<ConflictException>(
|
||||||
|
"đâ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<ConflictException>())
|
||||||
|
.Which.Message.Should().Contain("đã kết thúc");
|
||||||
|
(await actApprover.Should().ThrowAsync<ConflictException>(
|
||||||
|
"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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user