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*"); + } }