[CLAUDE] PurchaseEvaluation: khóa sửa giá đề xuất PRO/CCM sau khi phiếu đã gửi duyệt (anh Kiệt FDC "duyệt là fix")
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m25s

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 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-01 16:07:25 +07:00
parent 36cbf8b1cc
commit 0c2f9f630f
4 changed files with 118 additions and 20 deletions

View File

@ -1722,13 +1722,14 @@ function SuggestedPriceRows({ ev }: { ev: PeDetailBundle }) {
onError: e => toast.error(getErrorMessage(e)), onError: e => toast.error(getErrorMessage(e)),
}) })
// [S89 anh Kiệt FDC bug] Giá đề xuất PRO/CCM editable theo ROLE-capability đơn thuần // [S89 anh Kiệt FDC] Giá đề xuất PRO/CCM editable theo ROLE-capability (canEditPro/CcmSuggestedPrice)
// (mirror ô ngân sách Block A/B dùng bs.canEditPro/canEditCcm — KHÔNG gắn !readOnly). // — 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
// BE setter fail-closed theo role (Forbidden) + "không ràng Phase, chỉnh như tài liệu // 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
// sống" → CCM/PRO nhập được MỌI view (kể cả preview + Duyệt chưa-tới-lượt readOnly=true), // khi cấp duyệt đã xem. Đồng bộ BE guard (UpdatePeSuggestedPricePro/Ccm phase-gate). Admin sửa-sai
// không còn kẹt read-only khi xem phiếu ngoài đúng lượt duyệt của mình. // qua API (BE cho admin override; FE ẩn cho gọn — hiếm khi cần).
const canEditPro = ev.canEditProSuggestedPrice const editablePhase = isEditablePhase(ev.phase) // DangSoanThao || TraLai
const canEditCcm = ev.canEditCcmSuggestedPrice 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á. // Ghi chú PRO/CCM inline-edit state (Textarea). Echo cùng body absolute-set khi lưu giá.
const [proNoteText, setProNoteText] = useState(ev.proSuggestedPriceNote ?? '') const [proNoteText, setProNoteText] = useState(ev.proSuggestedPriceNote ?? '')

View File

@ -1722,13 +1722,14 @@ function SuggestedPriceRows({ ev }: { ev: PeDetailBundle }) {
onError: e => toast.error(getErrorMessage(e)), onError: e => toast.error(getErrorMessage(e)),
}) })
// [S89 anh Kiệt FDC bug] Giá đề xuất PRO/CCM editable theo ROLE-capability đơn thuần // [S89 anh Kiệt FDC] Giá đề xuất PRO/CCM editable theo ROLE-capability (canEditPro/CcmSuggestedPrice)
// (mirror ô ngân sách Block A/B dùng bs.canEditPro/canEditCcm — KHÔNG gắn !readOnly). // — 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
// BE setter fail-closed theo role (Forbidden) + "không ràng Phase, chỉnh như tài liệu // 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
// sống" → CCM/PRO nhập được MỌI view (kể cả preview + Duyệt chưa-tới-lượt readOnly=true), // khi cấp duyệt đã xem. Đồng bộ BE guard (UpdatePeSuggestedPricePro/Ccm phase-gate). Admin sửa-sai
// không còn kẹt read-only khi xem phiếu ngoài đúng lượt duyệt của mình. // qua API (BE cho admin override; FE ẩn cho gọn — hiếm khi cần).
const canEditPro = ev.canEditProSuggestedPrice const editablePhase = isEditablePhase(ev.phase) // DangSoanThao || TraLai
const canEditCcm = ev.canEditCcmSuggestedPrice 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á. // Ghi chú PRO/CCM inline-edit state (Textarea). Echo cùng body absolute-set khi lưu giá.
const [proNoteText, setProNoteText] = useState(ev.proSuggestedPriceNote ?? '') const [proNoteText, setProNoteText] = useState(ev.proSuggestedPriceNote ?? '')

View File

@ -52,13 +52,25 @@ public class UpdatePeSuggestedPriceProCommandHandler(
?? throw new NotFoundException("PurchaseEvaluation", request.PeId); ?? throw new NotFoundException("PurchaseEvaluation", request.PeId);
// Fail-closed TRƯỚC mọi side-effect. // Fail-closed TRƯỚC mọi side-effect.
if (!currentUser.Roles.Contains(AppRoles.Admin) var isAdmin = currentUser.Roles.Contains(AppRoles.Admin);
&& !currentUser.Roles.Contains(AppRoles.Procurement)) if (!isAdmin && !currentUser.Roles.Contains(AppRoles.Procurement))
{ {
throw new ForbiddenException( throw new ForbiddenException(
"Chỉ Phòng Cung ứng (PRO) hoặc Admin được nhập giá đề xuất PRO (Min/Max)."); "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 oldMin = pe.ProSuggestedMinPrice;
var oldMax = pe.ProSuggestedMaxPrice; var oldMax = pe.ProSuggestedMaxPrice;
var oldNote = pe.ProSuggestedPriceNote; var oldNote = pe.ProSuggestedPriceNote;
@ -114,13 +126,23 @@ public class UpdatePeSuggestedPriceCcmCommandHandler(
var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PeId, ct) var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PeId, ct)
?? throw new NotFoundException("PurchaseEvaluation", request.PeId); ?? throw new NotFoundException("PurchaseEvaluation", request.PeId);
if (!currentUser.Roles.Contains(AppRoles.Admin) var isAdmin = currentUser.Roles.Contains(AppRoles.Admin);
&& !currentUser.Roles.Contains(AppRoles.CostControl)) if (!isAdmin && !currentUser.Roles.Contains(AppRoles.CostControl))
{ {
throw new ForbiddenException( throw new ForbiddenException(
"Chỉ Phòng Kiểm soát Chi phí (CCM) hoặc Admin được nhập giá đề xuất CCM."); "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 oldCcm = pe.CcmSuggestedPrice;
var oldNote = pe.CcmSuggestedPriceNote; var oldNote = pe.CcmSuggestedPriceNote;
pe.CcmSuggestedPrice = request.CcmPrice; // absolute-set (null = clear) pe.CcmSuggestedPrice = request.CcmPrice; // absolute-set (null = clear)

View File

@ -36,13 +36,16 @@ public class PeSuggestedPriceSetterAuthzTests
} }
private static async Task<PurchaseEvaluation> SeedPeAsync( private static async Task<PurchaseEvaluation> 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 var pe = new PurchaseEvaluation
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Type = PurchaseEvaluationType.DuyetNcc, Type = PurchaseEvaluationType.DuyetNcc,
Phase = PurchaseEvaluationPhase.ChoDuyet, Phase = phase,
MaPhieu = code, MaPhieu = code,
TenGoiThau = "Gói thầu test giá đề xuất", TenGoiThau = "Gói thầu test giá đề xuất",
ProjectId = Guid.NewGuid(), ProjectId = Guid.NewGuid(),
@ -433,4 +436,75 @@ public class PeSuggestedPriceSetterAuthzTests
ccmValidator.Validate(new UpdatePeSuggestedPriceCcmCommand(pe.Id, null, Note: new string('x', 1001))) ccmValidator.Validate(new UpdatePeSuggestedPriceCcmCommand(pe.Id, null, Note: new string('x', 1001)))
.IsValid.Should().BeFalse("Note > 1000 ký tự vi phạm MaximumLength(1000)"); .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<TestApplicationDbContext>();
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<ConflictException>().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<TestApplicationDbContext>();
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<ConflictException>();
}
// 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<TestApplicationDbContext>();
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<TestApplicationDbContext>();
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<ConflictException>().WithMessage("*đã gửi duyệt*");
}
} }