From 0c2f9f630fabad028d4f7a18121999d5f098a4dc Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Wed, 1 Jul 2026 16:07:25 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20PurchaseEvaluation:=20kh=C3=B3a=20s?= =?UTF-8?q?=E1=BB=ADa=20gi=C3=A1=20=C4=91=E1=BB=81=20xu=E1=BA=A5t=20PRO/CC?= =?UTF-8?q?M=20sau=20khi=20phi=E1=BA=BFu=20=C4=91=C3=A3=20g=E1=BB=ADi=20du?= =?UTF-8?q?y=E1=BB=87t=20(anh=20Ki=E1=BB=87t=20FDC=20"duy=E1=BB=87t=20l?= =?UTF-8?q?=C3=A0=20fix")?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug (anh Kiệt/team, screenshot): drafter=PRO vẫn điền được giá đề xuất Min/Max SAU khi cấp trên (chị Trà) đã duyệt — "duyệt là fix". Root cause: UpdatePeSuggestedPricePro/CcmCommandHandler cố ý KHÔNG ràng Phase (Mig 54 "chỉnh như tài liệu sống") → sửa được mọi phase kể cả DaDuyet. Fix: - BE: +phase-guard — non-admin chỉ sửa giá đề xuất PRO/CCM khi phiếu ở Nháp (DangSoanThao) / Trả lại (TraLai); đã gửi duyệt (ChoDuyet) / đã duyệt (DaDuyet) / từ chối → ConflictException. Admin override (sửa-sai). Role-gate vẫn chạy trước. - FE (2 app): SuggestedPriceRows gate canEditPro/Ccm thêm && isEditablePhase(ev.phase) → ẩn ô sửa (read-only) khi phiếu đã gửi duyệt. Đồng bộ BE. - Test: +4 (PRO ChoDuyet->Conflict no-mutate, PRO DaDuyet->Conflict, Admin DaDuyet->override-set, CCM DaDuyet->Conflict); sửa SeedPeAsync default->DangSoanThao (spec change). 436->440 PASS. Delete phiếu sau duyệt: ĐÃ chặn sẵn (FE nút Xóa chỉ hiện DangSoanThao + BE delete-guard block non-draft) — không cần sửa. Co-Authored-By: Claude Opus 4.8 --- fe-admin/src/components/pe/PeDetailTabs.tsx | 15 ++-- fe-user/src/components/pe/PeDetailTabs.tsx | 15 ++-- .../PeSuggestedPriceFeatures.cs | 30 ++++++- .../PeSuggestedPriceSetterAuthzTests.cs | 78 ++++++++++++++++++- 4 files changed, 118 insertions(+), 20 deletions(-) diff --git a/fe-admin/src/components/pe/PeDetailTabs.tsx b/fe-admin/src/components/pe/PeDetailTabs.tsx index 0d7cd0a..b06512a 100644 --- a/fe-admin/src/components/pe/PeDetailTabs.tsx +++ b/fe-admin/src/components/pe/PeDetailTabs.tsx @@ -1722,13 +1722,14 @@ function SuggestedPriceRows({ ev }: { ev: PeDetailBundle }) { onError: e => toast.error(getErrorMessage(e)), }) - // [S89 anh Kiệt FDC bug] Giá đề xuất PRO/CCM editable theo ROLE-capability đơn thuần - // (mirror ô ngân sách Block A/B dùng bs.canEditPro/canEditCcm — KHÔNG gắn !readOnly). - // BE setter fail-closed theo role (Forbidden) + "không ràng Phase, chỉnh như tài liệu - // sống" → CCM/PRO nhập được MỌI view (kể cả preview + Duyệt chưa-tới-lượt readOnly=true), - // không còn kẹt read-only khi xem phiếu ngoài đúng lượt duyệt của mình. - const canEditPro = ev.canEditProSuggestedPrice - const canEditCcm = ev.canEditCcmSuggestedPrice + // [S89 anh Kiệt FDC] Giá đề xuất PRO/CCM editable theo ROLE-capability (canEditPro/CcmSuggestedPrice) + // — nhập ở mọi view khi phiếu CÒN soạn/trả-lại. [S96 2026-07-01 — anh Kiệt "duyệt là fix"] THÊM + // gắn phase: phiếu ĐÃ GỬI DUYỆT / ĐÃ DUYỆT / TỪ CHỐI → KHÓA (read-only) — không sửa giá ngầm sau + // khi cấp duyệt đã xem. Đồng bộ BE guard (UpdatePeSuggestedPricePro/Ccm phase-gate). Admin sửa-sai + // qua API (BE cho admin override; FE ẩn cho gọn — hiếm khi cần). + const editablePhase = isEditablePhase(ev.phase) // DangSoanThao || TraLai + const canEditPro = ev.canEditProSuggestedPrice && editablePhase + const canEditCcm = ev.canEditCcmSuggestedPrice && editablePhase // Ghi chú PRO/CCM inline-edit state (Textarea). Echo cùng body absolute-set khi lưu giá. const [proNoteText, setProNoteText] = useState(ev.proSuggestedPriceNote ?? '') diff --git a/fe-user/src/components/pe/PeDetailTabs.tsx b/fe-user/src/components/pe/PeDetailTabs.tsx index 0d7cd0a..b06512a 100644 --- a/fe-user/src/components/pe/PeDetailTabs.tsx +++ b/fe-user/src/components/pe/PeDetailTabs.tsx @@ -1722,13 +1722,14 @@ function SuggestedPriceRows({ ev }: { ev: PeDetailBundle }) { onError: e => toast.error(getErrorMessage(e)), }) - // [S89 anh Kiệt FDC bug] Giá đề xuất PRO/CCM editable theo ROLE-capability đơn thuần - // (mirror ô ngân sách Block A/B dùng bs.canEditPro/canEditCcm — KHÔNG gắn !readOnly). - // BE setter fail-closed theo role (Forbidden) + "không ràng Phase, chỉnh như tài liệu - // sống" → CCM/PRO nhập được MỌI view (kể cả preview + Duyệt chưa-tới-lượt readOnly=true), - // không còn kẹt read-only khi xem phiếu ngoài đúng lượt duyệt của mình. - const canEditPro = ev.canEditProSuggestedPrice - const canEditCcm = ev.canEditCcmSuggestedPrice + // [S89 anh Kiệt FDC] Giá đề xuất PRO/CCM editable theo ROLE-capability (canEditPro/CcmSuggestedPrice) + // — nhập ở mọi view khi phiếu CÒN soạn/trả-lại. [S96 2026-07-01 — anh Kiệt "duyệt là fix"] THÊM + // gắn phase: phiếu ĐÃ GỬI DUYỆT / ĐÃ DUYỆT / TỪ CHỐI → KHÓA (read-only) — không sửa giá ngầm sau + // khi cấp duyệt đã xem. Đồng bộ BE guard (UpdatePeSuggestedPricePro/Ccm phase-gate). Admin sửa-sai + // qua API (BE cho admin override; FE ẩn cho gọn — hiếm khi cần). + const editablePhase = isEditablePhase(ev.phase) // DangSoanThao || TraLai + const canEditPro = ev.canEditProSuggestedPrice && editablePhase + const canEditCcm = ev.canEditCcmSuggestedPrice && editablePhase // Ghi chú PRO/CCM inline-edit state (Textarea). Echo cùng body absolute-set khi lưu giá. const [proNoteText, setProNoteText] = useState(ev.proSuggestedPriceNote ?? '') diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeSuggestedPriceFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeSuggestedPriceFeatures.cs index ec8aaf4..8ddb21d 100644 --- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeSuggestedPriceFeatures.cs +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeSuggestedPriceFeatures.cs @@ -52,13 +52,25 @@ public class UpdatePeSuggestedPriceProCommandHandler( ?? throw new NotFoundException("PurchaseEvaluation", request.PeId); // Fail-closed TRƯỚC mọi side-effect. - if (!currentUser.Roles.Contains(AppRoles.Admin) - && !currentUser.Roles.Contains(AppRoles.Procurement)) + var isAdmin = currentUser.Roles.Contains(AppRoles.Admin); + if (!isAdmin && !currentUser.Roles.Contains(AppRoles.Procurement)) { throw new ForbiddenException( "Chỉ Phòng Cung ứng (PRO) hoặc Admin được nhập giá đề xuất PRO (Min/Max)."); } + // [S96 2026-07-01 — anh Kiệt FDC "duyệt là fix"] Chỉ sửa giá đề xuất khi phiếu CÒN + // soạn/trả-lại. Phiếu ĐÃ GỬI DUYỆT (ChoDuyet/sub-phase) / ĐÃ DUYỆT (DaDuyet) / TỪ CHỐI + // → KHÓA: giá đã trình cho cấp duyệt xem, KHÔNG đổi ngầm dưới chân họ. Đảo comment cũ + // "KHÔNG ràng Phase" (Mig 54). Admin override (sửa-sai). Non-admin fail-closed. + if (!isAdmin + && pe.Phase != PurchaseEvaluationPhase.DangSoanThao + && pe.Phase != PurchaseEvaluationPhase.TraLai) + { + throw new ConflictException( + "Phiếu đã gửi duyệt / đã duyệt — không sửa được giá đề xuất PRO. Chỉ sửa khi phiếu ở Nháp hoặc Trả lại."); + } + var oldMin = pe.ProSuggestedMinPrice; var oldMax = pe.ProSuggestedMaxPrice; var oldNote = pe.ProSuggestedPriceNote; @@ -114,13 +126,23 @@ public class UpdatePeSuggestedPriceCcmCommandHandler( var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PeId, ct) ?? throw new NotFoundException("PurchaseEvaluation", request.PeId); - if (!currentUser.Roles.Contains(AppRoles.Admin) - && !currentUser.Roles.Contains(AppRoles.CostControl)) + var isAdmin = currentUser.Roles.Contains(AppRoles.Admin); + if (!isAdmin && !currentUser.Roles.Contains(AppRoles.CostControl)) { throw new ForbiddenException( "Chỉ Phòng Kiểm soát Chi phí (CCM) hoặc Admin được nhập giá đề xuất CCM."); } + // [S96 2026-07-01 — anh Kiệt FDC "duyệt là fix"] Chỉ sửa giá CCM khi phiếu CÒN soạn/ + // trả-lại — đã gửi duyệt / đã duyệt / từ chối → KHÓA. Admin override. (mirror PRO handler) + if (!isAdmin + && pe.Phase != PurchaseEvaluationPhase.DangSoanThao + && pe.Phase != PurchaseEvaluationPhase.TraLai) + { + throw new ConflictException( + "Phiếu đã gửi duyệt / đã duyệt — không sửa được giá đề xuất CCM. Chỉ sửa khi phiếu ở Nháp hoặc Trả lại."); + } + var oldCcm = pe.CcmSuggestedPrice; var oldNote = pe.CcmSuggestedPriceNote; pe.CcmSuggestedPrice = request.CcmPrice; // absolute-set (null = clear) diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/PeSuggestedPriceSetterAuthzTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/PeSuggestedPriceSetterAuthzTests.cs index 09ab773..7c5d2a5 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Application/PeSuggestedPriceSetterAuthzTests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Application/PeSuggestedPriceSetterAuthzTests.cs @@ -36,13 +36,16 @@ public class PeSuggestedPriceSetterAuthzTests } private static async Task SeedPeAsync( - TestApplicationDbContext db, string code = "PE-SP-001") + TestApplicationDbContext db, string code = "PE-SP-001", + // [S96 2026-07-01] Default DangSoanThao (Nháp) = phase editable cho setter giá đề xuất + // sau khi thêm phase-guard "duyệt là fix". Guard tests truyền ChoDuyet/DaDuyet để chặn. + PurchaseEvaluationPhase phase = PurchaseEvaluationPhase.DangSoanThao) { var pe = new PurchaseEvaluation { Id = Guid.NewGuid(), Type = PurchaseEvaluationType.DuyetNcc, - Phase = PurchaseEvaluationPhase.ChoDuyet, + Phase = phase, MaPhieu = code, TenGoiThau = "Gói thầu test giá đề xuất", ProjectId = Guid.NewGuid(), @@ -433,4 +436,75 @@ public class PeSuggestedPriceSetterAuthzTests ccmValidator.Validate(new UpdatePeSuggestedPriceCcmCommand(pe.Id, null, Note: new string('x', 1001))) .IsValid.Should().BeFalse("Note > 1000 ký tự vi phạm MaximumLength(1000)"); } + + // ==================================================================== + // ===== NEW [S96 2026-07-01 — anh Kiệt FDC "duyệt là fix"] PHASE-GUARD ===== + // Giá đề xuất PRO/CCM CHỈ sửa khi phiếu Nháp (DangSoanThao) / Trả lại (TraLai). + // Phiếu ĐÃ GỬI DUYỆT (ChoDuyet) / ĐÃ DUYỆT (DaDuyet) / TỪ CHỐI → ConflictException. + // Admin override (sửa-sai). Đảo Mig 54 comment "KHÔNG ràng Phase". Role-gate chạy + // TRƯỚC phase-guard (wrong-role → Forbidden; right-role + non-draft → Conflict). + // ==================================================================== + + // 16. PRO Procurement + phiếu ĐÃ GỬI DUYỆT (ChoDuyet) → Conflict + no mutate. Đây ĐÚNG + // bug anh Kiệt: drafter=PRO điền Min/Max SAU khi cấp trên (chị Trà) đã duyệt. + [Fact] + public async Task ProSetter_SubmittedPhase_ThrowsConflict_NoMutate() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var pe = await SeedPeAsync(db, "PE-SP-LOCK-1", PurchaseEvaluationPhase.ChoDuyet); + var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.Procurement)); + + var act = async () => await handler.Handle( + new UpdatePeSuggestedPriceProCommand(pe.Id, MinPrice: 111m, MaxPrice: 111m), + CancellationToken.None); + + await act.Should().ThrowAsync().WithMessage("*đã gửi duyệt*"); + var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + reloaded.ProSuggestedMinPrice.Should().BeNull("phiếu đã gửi duyệt → KHÔNG set giá (no mutate)"); + } + + // 17. PRO Procurement + phiếu ĐÃ DUYỆT (DaDuyet) → Conflict. + [Fact] + public async Task ProSetter_ApprovedPhase_ThrowsConflict() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var pe = await SeedPeAsync(db, "PE-SP-LOCK-2", PurchaseEvaluationPhase.DaDuyet); + var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.Procurement)); + + await FluentActions.Awaiting(() => handler.Handle( + new UpdatePeSuggestedPriceProCommand(pe.Id, 1m, 2m), CancellationToken.None)) + .Should().ThrowAsync(); + } + + // 18. Admin OVERRIDE — sửa giá được kể cả phiếu ĐÃ DUYỆT (sửa-sai). Guard chỉ chặn non-admin. + [Fact] + public async Task ProSetter_Admin_ApprovedPhase_StillSets() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var pe = await SeedPeAsync(db, "PE-SP-LOCK-3", PurchaseEvaluationPhase.DaDuyet); + var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.Admin)); + + await handler.Handle( + new UpdatePeSuggestedPriceProCommand(pe.Id, MinPrice: 42m, MaxPrice: null), CancellationToken.None); + + (await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id)) + .ProSuggestedMinPrice.Should().Be(42m, "Admin override phase-guard (sửa-sai sau duyệt)"); + } + + // 19. CCM CostControl + phiếu ĐÃ DUYỆT → Conflict (mirror PRO). + [Fact] + public async Task CcmSetter_ApprovedPhase_ThrowsConflict() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var pe = await SeedPeAsync(db, "PE-SP-LOCK-4", PurchaseEvaluationPhase.DaDuyet); + var handler = new UpdatePeSuggestedPriceCcmCommandHandler(db, new FakeCurrentUser(AppRoles.CostControl)); + + await FluentActions.Awaiting(() => handler.Handle( + new UpdatePeSuggestedPriceCcmCommand(pe.Id, 5m), CancellationToken.None)) + .Should().ThrowAsync().WithMessage("*đã gửi duyệt*"); + } }