diff --git a/fe-admin/src/components/khkk/KhkkDetailContent.tsx b/fe-admin/src/components/khkk/KhkkDetailContent.tsx index d8d936c..cb68a42 100644 --- a/fe-admin/src/components/khkk/KhkkDetailContent.tsx +++ b/fe-admin/src/components/khkk/KhkkDetailContent.tsx @@ -494,10 +494,14 @@ export function KhkkDetailContent({ đúng ca "vắng-mặt trông giống ổn" (màn rỗng nhìn vẫn gọn nên không ai kêu). */}
Hạng mục (dự án) - {plan.peWorkItemCode && ( - {plan.peWorkItemCode} - )} - {plan.peWorkItemName ?? '—'} + {/* [S177 — anh: "Tô đậm" (mã) + "Bỏ đi" (tên)] Header giờ CHỈ còn MÃ, in đậm. + 🔴 Fallback về TÊN khi thiếu mã: `peWorkItemCode` null khi PE không gắn hạng mục + (`WorkItemId` là loose-Guid, Mig 49 — được phép rỗng). Bỏ tên VÔ ĐIỀU KIỆN thì + đúng ca đó header còn trơ mỗi chữ "Hạng mục (dự án)" — một nhãn không có giá trị + đi kèm, trông như lỗi render chứ không như "phiếu này chưa gắn hạng mục". */} + + {plan.peWorkItemCode ?? plan.peWorkItemName ?? '—'} +
{plan.lines.length === 0 ? ( @@ -605,47 +609,49 @@ export function KhkkDetailContent({ ) : moneyOrDash(l.proposedAmount)} - {isDraft && !l.contractId && l.peReferenceAmount === 0 && ( - - )} - - - ))} - {/* [S177 — anh: "+ thêm hạng mục ở đây"] Nút thêm DỜI TỪ DƯỚI BẢNG vào - thành dòng cuối của `tbody` — ngay sau dòng dữ liệu cuối, TRÊN dòng TỔNG. - 🔴 `colSpan={4}` (không phải 3): bảng đã lên 4 cột từ đầu lượt S177. - 🔸 Panel nhập (`addFor`) VẪN Ở NGOÀI bảng — nó là form nhiều ô, nhét vào - trong `` thì vỡ lưới `table-fixed` đang giữ 3 cột số thẳng hàng. */} - {isDraft && ( - - - )} + ))} + {/* 🧊 [S177 — anh: "bỏ đi"] Khối "Thêm hạng mục cho: [NCC…]" ĐÃ GỠ HẲN. + Vòng đời đủ 3 nấc trong CÙNG lượt S177, giữ vết để khỏi ai dựng lại: + (1) S175 đặt DƯỚI bảng → (2) anh: "+ thêm hạng mục ở đây" ⇒ dời vào + `tbody` → (3) anh nhìn thật rồi chốt "bỏ đi", thay bằng nút **+** ở cột + hành-động của TỪNG DÒNG (xem ` {/* [S175 — anh: "chọn hạng mục tiền → thành tổng"] TỔNG = Σ giá các dòng. 🔴 KHÔNG có cột "giá tham chiếu" / "giá duyệt" nữa — anh chốt "ko cần so diff --git a/fe-admin/src/pages/khkk/KhkkCreatePage.tsx b/fe-admin/src/pages/khkk/KhkkCreatePage.tsx index 065c5fd..7647df0 100644 --- a/fe-admin/src/pages/khkk/KhkkCreatePage.tsx +++ b/fe-admin/src/pages/khkk/KhkkCreatePage.tsx @@ -29,7 +29,7 @@ import { useState, type FormEvent, type ReactNode } from 'react' import { useMutation, useQuery } from '@tanstack/react-query' import { useSearchParams } from 'react-router-dom' -import { FileCheck, Pencil, X } from 'lucide-react' +import { FileCheck } from 'lucide-react' import { toast } from 'sonner' import { EmptyState } from '@/components/EmptyState' import { Button } from '@/components/ui/Button' @@ -218,29 +218,17 @@ export function KhkkCreatePage() { const readOnly = !editPlan || !canEdit return (
- {/* 🧊 [S175 — anh: "bỏ mấy dòng này đi"] Dải banner "Đang xem kế hoạch…" ĐÃ GỠ. - Nút của nó đi đâu: **Sửa** → bút chì ở danh sách (đường duy nhất, đúng như - banner cũ vẫn dặn) · **Xóa phiếu / Gửi duyệt** → THANH NÚT ĐÁY của - `KhkkDetailContent`. Chỉ giữ đúng 1 dòng nhỏ để quay về form tạo — bỏ hẳn - thì người dùng mở 1 phiếu xong là kẹt, không có đường lùi. */} -
- {readOnly && canEdit && ( - - )} - -
+ {/* 🧊 [S177 — anh: "bỏ nút sửa với về lại form"] Dải 2 nút góc phải ĐÃ GỠ TRỌN + (**Sửa** + **✕ Về form tạo**). Cả hai đều là đường PHỤ, không phải đường duy nhất: + · **vào chế-độ sửa** → bút chì ở DANH SÁCH trái (`onEditPlan` `:202`) — GIỮ. + 🔴 Bút chì đó là đường DUY NHẤT vào chế-độ sửa; gỡ nó là mất hẳn khả năng + sửa phiếu, nên nó KHÔNG nằm trong phạm vi yêu cầu này. + · **rời chế-độ xem** → bấm phiếu Duyệt NCC bất kỳ ở cây trái: `pickPe` `:163-166` + tự `setViewPlanId(null)`. Cây trái luôn hiện ⇒ không kẹt. + 🔸 Đính chính chú-thích S175 từng đứng đây (*"bỏ hẳn thì người dùng kẹt, không + có đường lùi"*): SAI ngay lúc viết — `pickPe` mang nhãn **[S174]**, tức đã tồn + tại TRƯỚC câu cảnh-báo đó. Thứ duy nhất mất đi là đường quay lại màn rỗng + "Chọn phiếu Duyệt NCC…" (`:256`), mà đó chỉ là chỗ hướng-dẫn, không phải đích. */} {viewPlan.isLoading &&
Đang tải…
} {!viewPlan.isLoading && !viewPlan.data && (
Không tìm thấy phiếu.
diff --git a/fe-user/src/components/khkk/KhkkDetailContent.tsx b/fe-user/src/components/khkk/KhkkDetailContent.tsx index d8d936c..cb68a42 100644 --- a/fe-user/src/components/khkk/KhkkDetailContent.tsx +++ b/fe-user/src/components/khkk/KhkkDetailContent.tsx @@ -494,10 +494,14 @@ export function KhkkDetailContent({ đúng ca "vắng-mặt trông giống ổn" (màn rỗng nhìn vẫn gọn nên không ai kêu). */}
Hạng mục (dự án) - {plan.peWorkItemCode && ( - {plan.peWorkItemCode} - )} - {plan.peWorkItemName ?? '—'} + {/* [S177 — anh: "Tô đậm" (mã) + "Bỏ đi" (tên)] Header giờ CHỈ còn MÃ, in đậm. + 🔴 Fallback về TÊN khi thiếu mã: `peWorkItemCode` null khi PE không gắn hạng mục + (`WorkItemId` là loose-Guid, Mig 49 — được phép rỗng). Bỏ tên VÔ ĐIỀU KIỆN thì + đúng ca đó header còn trơ mỗi chữ "Hạng mục (dự án)" — một nhãn không có giá trị + đi kèm, trông như lỗi render chứ không như "phiếu này chưa gắn hạng mục". */} + + {plan.peWorkItemCode ?? plan.peWorkItemName ?? '—'} +
{plan.lines.length === 0 ? ( @@ -605,47 +609,49 @@ export function KhkkDetailContent({ ) : moneyOrDash(l.proposedAmount)}
- - ))} - {/* [S177 — anh: "+ thêm hạng mục ở đây"] Nút thêm DỜI TỪ DƯỚI BẢNG vào - thành dòng cuối của `tbody` — ngay sau dòng dữ liệu cuối, TRÊN dòng TỔNG. - 🔴 `colSpan={4}` (không phải 3): bảng đã lên 4 cột từ đầu lượt S177. - 🔸 Panel nhập (`addFor`) VẪN Ở NGOÀI bảng — nó là form nhiều ô, nhét vào - trong `
-
- Thêm hạng mục cho: - {[...new Map(plan.lines.map(l => [l.supplierId, l])).values()].map((l) => ( + {/* [S177 — anh: "Thêm dấu + thêm ở đây"] Nút THÊM về đúng cột hành-động + của TỪNG DÒNG, thay khối "Thêm hạng mục cho: [NCC…]" (anh: "bỏ đi"). + 🔴 Vì sao gọn hơn hẳn: nút ở dòng nào thì BIẾT SẴN NCC của dòng đó + (`l.supplierId`), nên không phải bày danh sách NCC cho người dùng chọn + lại thứ mà vị trí bấm đã nói rồi. Khối cũ tồn tại chỉ vì nó đứng NGOÀI + mọi dòng nên buộc phải hỏi "cho NCC nào". */} +
+ {isDraft && ( - ))} + )} + {isDraft && !l.contractId && l.peReferenceAmount === 0 && ( + + )}
` hành-động phía trên). + 🔴 Chức năng KHÔNG mất: nút + trên dòng gọi đúng `setAddFor` cũ, và còn + biết sẵn NCC nên bỏ được bước chọn NCC. Panel nhập `addFor` giữ nguyên. */}
- {isDraft && !l.contractId && l.peReferenceAmount === 0 && ( - - )} -
` thì vỡ lưới `table-fixed` đang giữ 3 cột số thẳng hàng. */} - {isDraft && ( - - - )} + ))} + {/* 🧊 [S177 — anh: "bỏ đi"] Khối "Thêm hạng mục cho: [NCC…]" ĐÃ GỠ HẲN. + Vòng đời đủ 3 nấc trong CÙNG lượt S177, giữ vết để khỏi ai dựng lại: + (1) S175 đặt DƯỚI bảng → (2) anh: "+ thêm hạng mục ở đây" ⇒ dời vào + `tbody` → (3) anh nhìn thật rồi chốt "bỏ đi", thay bằng nút **+** ở cột + hành-động của TỪNG DÒNG (xem ` {/* [S175 — anh: "chọn hạng mục tiền → thành tổng"] TỔNG = Σ giá các dòng. 🔴 KHÔNG có cột "giá tham chiếu" / "giá duyệt" nữa — anh chốt "ko cần so diff --git a/fe-user/src/pages/khkk/KhkkCreatePage.tsx b/fe-user/src/pages/khkk/KhkkCreatePage.tsx index 065c5fd..7647df0 100644 --- a/fe-user/src/pages/khkk/KhkkCreatePage.tsx +++ b/fe-user/src/pages/khkk/KhkkCreatePage.tsx @@ -29,7 +29,7 @@ import { useState, type FormEvent, type ReactNode } from 'react' import { useMutation, useQuery } from '@tanstack/react-query' import { useSearchParams } from 'react-router-dom' -import { FileCheck, Pencil, X } from 'lucide-react' +import { FileCheck } from 'lucide-react' import { toast } from 'sonner' import { EmptyState } from '@/components/EmptyState' import { Button } from '@/components/ui/Button' @@ -218,29 +218,17 @@ export function KhkkCreatePage() { const readOnly = !editPlan || !canEdit return (
- {/* 🧊 [S175 — anh: "bỏ mấy dòng này đi"] Dải banner "Đang xem kế hoạch…" ĐÃ GỠ. - Nút của nó đi đâu: **Sửa** → bút chì ở danh sách (đường duy nhất, đúng như - banner cũ vẫn dặn) · **Xóa phiếu / Gửi duyệt** → THANH NÚT ĐÁY của - `KhkkDetailContent`. Chỉ giữ đúng 1 dòng nhỏ để quay về form tạo — bỏ hẳn - thì người dùng mở 1 phiếu xong là kẹt, không có đường lùi. */} -
- {readOnly && canEdit && ( - - )} - -
+ {/* 🧊 [S177 — anh: "bỏ nút sửa với về lại form"] Dải 2 nút góc phải ĐÃ GỠ TRỌN + (**Sửa** + **✕ Về form tạo**). Cả hai đều là đường PHỤ, không phải đường duy nhất: + · **vào chế-độ sửa** → bút chì ở DANH SÁCH trái (`onEditPlan` `:202`) — GIỮ. + 🔴 Bút chì đó là đường DUY NHẤT vào chế-độ sửa; gỡ nó là mất hẳn khả năng + sửa phiếu, nên nó KHÔNG nằm trong phạm vi yêu cầu này. + · **rời chế-độ xem** → bấm phiếu Duyệt NCC bất kỳ ở cây trái: `pickPe` `:163-166` + tự `setViewPlanId(null)`. Cây trái luôn hiện ⇒ không kẹt. + 🔸 Đính chính chú-thích S175 từng đứng đây (*"bỏ hẳn thì người dùng kẹt, không + có đường lùi"*): SAI ngay lúc viết — `pickPe` mang nhãn **[S174]**, tức đã tồn + tại TRƯỚC câu cảnh-báo đó. Thứ duy nhất mất đi là đường quay lại màn rỗng + "Chọn phiếu Duyệt NCC…" (`:256`), mà đó chỉ là chỗ hướng-dẫn, không phải đích. */} {viewPlan.isLoading &&
Đang tải…
} {!viewPlan.isLoading && !viewPlan.data && (
Không tìm thấy phiếu.
diff --git a/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs b/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs index 0a541be..1ade403 100644 --- a/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs +++ b/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs @@ -1720,12 +1720,21 @@ public class DeleteContractSigningPlanCommandHandler( if (!isOwner && !isAdmin) throw new ForbiddenException("Chỉ người soạn kế hoạch hoặc Admin được xoá phiếu này."); - // Allow-list {DangSoanThao, TuChoi} — M6 spec cũ: THIẾU đường này thì lỡ tạo nhầm 1 - // kế hoạch là phiếu PE bị khoá 409 vĩnh viễn (rào "1 PE = 1 kế hoạch sống" ở Create - // không có cửa thoát). Xoá phiếu đang duyệt = W3 (cần rào đúng-lượt như PE). - if (plan.Phase is not (ContractSigningPlanPhase.DangSoanThao or ContractSigningPlanPhase.TuChoi)) + // Allow-list {DangSoanThao, TuChoi, TraLai} — M6 spec cũ: THIẾU đường này thì lỡ tạo + // nhầm 1 kế hoạch là phiếu PE bị khoá 409 vĩnh viễn (rào "1 PE = 1 kế hoạch sống" ở + // Create không có cửa thoát). Xoá phiếu ĐANG duyệt vẫn cấm (cần rào đúng-lượt như PE). + // [S177 — anh: "Trạng thái trả lại cho xóa đc luôn nhé"] +`TraLai`. + // 🔴 ĐÂY LÀ VÁ LỆCH 2 TẦNG, không phải nới quyền mới: FE ĐÃ hiện nút Xoá cho phiếu + // TraLai từ trước (`KhkkCreatePage` tính `canEdit` gồm cả `TraLai` ⇒ `readOnly=false` + // ⇒ thanh nút đáy hiện) trong khi BE trả 409 — bấm được mà không xoá được. Rào hiển-thị + // và rào API là HAI tầng độc lập; sửa một tầng là để lại đúng cái bẫy này (gotcha #82). + // 🔸 TraLai an toàn về nghiệp-vụ: phiếu đã bị trả về tay người soạn, KHÔNG đang chiếm + // lượt của ai — cùng vị thế với DangSoanThao, khác hẳn phiếu đang chờ duyệt. + if (plan.Phase is not (ContractSigningPlanPhase.DangSoanThao + or ContractSigningPlanPhase.TuChoi + or ContractSigningPlanPhase.TraLai)) throw new ConflictException( - "Chỉ xoá được kế hoạch ở trạng thái Nháp hoặc Từ chối. " + + "Chỉ xoá được kế hoạch ở trạng thái Nháp, Trả lại hoặc Từ chối. " + "Kế hoạch đang duyệt phải Trả lại/Từ chối trước."); var reason = string.IsNullOrWhiteSpace(request.Reason) ? null : request.Reason.Trim(); diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanCrudTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanCrudTests.cs index bde7de9..9a2a5f7 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanCrudTests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanCrudTests.cs @@ -576,6 +576,45 @@ public class ContractSigningPlanCrudTests .Should().Be(admin.UserId, "người xoá thật được ghi lại là Admin, không phải chủ phiếu"); } + // =================================================================== + // (8-bis) [S177 — anh: "Trạng thái trả lại cho xóa đc luôn nhé"] `TraLai` vào allow-list xoá. + // + // Đây là ĐỔI SPEC (allow-list {Nháp, Từ chối} → {Nháp, Từ chối, Trả lại}) nên test đi + // CÙNG commit, không để test-after. + // + // 🔴 Vì sao test cũ (8) KHÔNG bắt được thiếu sót này, dù nó có cả vế thuận lẫn vế + // nghịch: vế nghịch của nó dùng `ChoDuyet` — vẫn bị cấm ĐÚNG cả trước lẫn sau bản + // vá; vế thuận dùng `TuChoi` — được phép ĐÚNG cả trước lẫn sau. `TraLai` là phase + // DUY NHẤT đổi verdict, và nó không có mặt trong test nào ⇒ allow-list thiếu một + // phase mà toàn bộ hàng test vẫn xanh. Trục nào KHÔNG có ca thì trục đó không được đo. + // + // 🔸 Nền của bản vá là LỆCH 2 TẦNG chứ không phải nới quyền: FE đã hiện nút Xoá cho + // phiếu `TraLai` từ trước (`canEdit` gồm `TraLai`), BE thì 409 ⇒ bấm được mà không + // xoá được. Test này neo tầng BE — tầng có răng thật. + [Fact] + public async Task DeleteContractSigningPlan_Allows_TraLai() + { + using var f = new KhkkFixture(); + var seeded = await SeedPeAsync(f, supplierCount: 2, winnerIndexes: [0], quotes: [[100m, 7m]]); + var created = await CreateAsync(f, seeded.PeId, seeded.WorkflowId); + + await using (var db = f.NewDb()) + { + var plan = db.ContractSigningPlans.Single(p => p.Id == created.Id); + plan.Phase = ContractSigningPlanPhase.TraLai; + db.SaveChanges(); + } + + await DeleteAsync(f, created.Id, "Phiếu bị trả lại, soạn lại từ đầu", f.Actor); + + await using var final = f.NewDb(); + final.ContractSigningPlans.Count(p => p.Id == created.Id) + .Should().Be(0, "phiếu Trả lại đã nằm trong tay người soạn, không chiếm lượt duyệt của ai"); + final.ContractSigningPlans.IgnoreQueryFilters().AsNoTracking() + .Single(p => p.Id == created.Id).IsDeleted + .Should().BeTrue("xoá MỀM — vết phải còn để truy, không phải bốc hơi khỏi DB"); + } + // =================================================================== // (9) [gate-K4b MAJOR-A @S166] Màn "Đã xoá" phải LỌC NHÓM ở SERVER // (`ListDeletedContractSigningPlansQueryHandler` :896-897, vá @K4b).
-
- Thêm hạng mục cho: - {[...new Map(plan.lines.map(l => [l.supplierId, l])).values()].map((l) => ( + {/* [S177 — anh: "Thêm dấu + thêm ở đây"] Nút THÊM về đúng cột hành-động + của TỪNG DÒNG, thay khối "Thêm hạng mục cho: [NCC…]" (anh: "bỏ đi"). + 🔴 Vì sao gọn hơn hẳn: nút ở dòng nào thì BIẾT SẴN NCC của dòng đó + (`l.supplierId`), nên không phải bày danh sách NCC cho người dùng chọn + lại thứ mà vị trí bấm đã nói rồi. Khối cũ tồn tại chỉ vì nó đứng NGOÀI + mọi dòng nên buộc phải hỏi "cho NCC nào". */} +
+ {isDraft && ( - ))} + )} + {isDraft && !l.contractId && l.peReferenceAmount === 0 && ( + + )}
` hành-động phía trên). + 🔴 Chức năng KHÔNG mất: nút + trên dòng gọi đúng `setAddFor` cũ, và còn + biết sẵn NCC nên bỏ được bước chọn NCC. Panel nhập `addFor` giữ nguyên. */}