From 0bfadc2011defe60cb1b7a9b17dbf18fbdd35abd Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Sat, 1 Aug 2026 17:08:47 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20Contract:=20K8-blocker=20Lines-edito?= =?UTF-8?q?r=20KHKK=20=E2=80=94=20gan=20hang=20muc=20SP-002=20cho=20dong?= =?UTF-8?q?=20(command=20hua-trong-comment=20chua=20tung=20land)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dry-run K8 bat TRUOC gio chay: auto-gen line moi CatalogEntryId=null + comment hen "UpdateContractSigningPlanLineCommand" (K4b) nhung K4b that ship ?group= => 0 impl/0 route/0 FE; submit-guard va-5 chan null => MOI phieu KHKK tao sau K2 ket Nhap vinh vien (dung cho cicd-K2 khai "chan-ly-rong") - BE: UpdateContractSigningPlanLineCommand(PlanId,LineId,CatalogEntryId) — EnsureDraftEditable + rao ContractId!=null 409 (gate F-6) + fail-fast entry song/IsActive/dung-nhom 409 + denorm TenHangMuc + changelog EntityType.Line EntityId=line.Id + ten NCC vao Summary (gate F-1) - API: PUT /contract-signing-plans/{id}/lines/{lineId} policy KeHoachKyKet.Update + UpdateSigningPlanLineBody - FE x2 app KhkkDetailPage (SHA-pair 0d6147d0): select gan tai bang (Nhap/TraLai, khoa dong da bac cau HD) + option-fallback giu hang-muc-da-luu khi vang options (gate F-2, 3 ca loading/error/ngung-hieu-luc) + dong loi query + amber nhom-0-hang-muc (F-3) + aria-label per-dong (F-8) - Test +5 Fact: duong gan->submit-QUA guard that · khac-nhom 409 · phase 409 · IsActive=false 409 · gan-DE doi ca Id lan snapshot (gate F-4) - Gate PASS-WITH-FLAGS-8/0-blocker — disposition tung dong trong sub-reviewer-gate-lineeditor.md; F-5/F-7 ghi no cung goi RowVersion/unique voi no K7 race-2-request - scripts/backup-sql.ps1 default D:\ -> C:\ (VPS khong co o D — do that B0) Co-Authored-By: Claude Fable 5 --- fe-admin/src/pages/khkk/KhkkDetailPage.tsx | 83 ++++++++++- fe-user/src/pages/khkk/KhkkDetailPage.tsx | 83 ++++++++++- scripts/backup-sql.ps1 | 4 +- .../ContractSigningPlansController.cs | 15 ++ .../ContractSigningPlanFeatures.cs | 78 ++++++++++ ...ContractSigningPlanGroupAndCatalogTests.cs | 140 ++++++++++++++++++ 6 files changed, 392 insertions(+), 11 deletions(-) diff --git a/fe-admin/src/pages/khkk/KhkkDetailPage.tsx b/fe-admin/src/pages/khkk/KhkkDetailPage.tsx index ca3dc80..e2c90e6 100644 --- a/fe-admin/src/pages/khkk/KhkkDetailPage.tsx +++ b/fe-admin/src/pages/khkk/KhkkDetailPage.tsx @@ -167,6 +167,31 @@ export function KhkkDetailPage() { onError: (e) => toast.error(getErrorMessage(e)), }) + // [S168 K8-blocker — Lines-editor] Gán hạng mục SP-002 cho dòng: options lọc ĐÚNG nhóm + // duyệt của phiếu (BE fail-fast tầng-gán + submit-guard chốt cuối). Chỉ tải khi phiếu + // còn sửa được (Nháp/Trả lại) — tránh 1 request thừa trên phiếu đã trình/duyệt. + const planDraftEditable = + plan.data?.phase === KhkkPhase.DangSoanThao || plan.data?.phase === KhkkPhase.TraLai + const catalogOptions = useQuery({ + queryKey: ['contract-catalog', plan.data?.approvalGroup], + queryFn: async () => + ( + await api.get<{ id: string; code: string; tenVi: string }[]>('/catalogs/contract-catalog', { + params: { approvalGroup: plan.data!.approvalGroup }, + }) + ).data, + enabled: planDraftEditable && plan.data?.approvalGroup != null, + }) + const assignLine = useMutation({ + mutationFn: async (p: { lineId: string; catalogEntryId: string }) => + api.put(`/contract-signing-plans/${id}/lines/${p.lineId}`, { catalogEntryId: p.catalogEntryId }), + onSuccess: () => { + toast.success('Đã gán hạng mục') + invalidate() + }, + onError: (e) => toast.error(getErrorMessage(e)), + }) + const upsertItem = useMutation({ mutationFn: async (body: UpsertKhkkDossierItemInput) => { if (!body.name.trim()) throw new Error('Vui lòng nhập tên căn cứ') @@ -503,9 +528,9 @@ export function KhkkDetailPage() { Nhà cung cấp - {/* [K2 S164] Hạng mục SP-002 của dòng — HIỂN THỊ-ONLY lượt này. - Đường GÁN (Lines-editor) = **K4b**; K2 chỉ đẻ cột + cho thấy dòng - nào còn trống (dòng trống KHÔNG trình duyệt được — submit-guard BE). */} + {/* [K2 S164 → S168] Hạng mục SP-002 của dòng — đường GÁN (Lines-editor) land + @S168 (mảnh hẹn-K4b bị rơi, dry-run K8 bắt): Nháp/Trả lại gán tại bảng, + dòng trống KHÔNG trình duyệt được (submit-guard BE). */} Hạng mục Giá tham chiếu (phiếu NCC) Giá đề xuất @@ -532,7 +557,55 @@ export function KhkkDetailPage() { )} - {l.tenHangMuc ? l.tenHangMuc : } + {/* Dòng đã bắc cầu HĐ thì khoá (đổi hạng mục sau khi có HĐ = đổi lịch sử). */} + {isDraft && !l.contractId ? ( + <> + + {catalogOptions.isError && ( +
+ Không tải được danh mục hạng mục —{' '} + +
+ )} + {catalogOptions.isSuccess && (catalogOptions.data?.length ?? 0) === 0 && ( +
+ Nhóm duyệt N{k.approvalGroup} chưa có hạng mục đang hiệu lực — liên hệ quản trị danh mục. +
+ )} + + ) : l.tenHangMuc ? ( + l.tenHangMuc + ) : ( + + )} {formatVnd(l.peReferenceAmount)} {formatVnd(l.proposedAmount)} @@ -545,7 +618,7 @@ export function KhkkDetailPage() { )}
Giá tham chiếu là SNAPSHOT tổng báo giá được chọn của NCC trên phiếu Duyệt NCC tại thời điểm lập kế hoạch. - {' '}Cột Hạng mục hiện chưa gán được từ màn này (ô gán thuộc bước Lines-editor). + {' '}Cột Hạng mục gán ngay tại bảng khi phiếu ở Nháp/Trả lại — mọi dòng phải gán xong mới trình duyệt được.
diff --git a/fe-user/src/pages/khkk/KhkkDetailPage.tsx b/fe-user/src/pages/khkk/KhkkDetailPage.tsx index ca3dc80..e2c90e6 100644 --- a/fe-user/src/pages/khkk/KhkkDetailPage.tsx +++ b/fe-user/src/pages/khkk/KhkkDetailPage.tsx @@ -167,6 +167,31 @@ export function KhkkDetailPage() { onError: (e) => toast.error(getErrorMessage(e)), }) + // [S168 K8-blocker — Lines-editor] Gán hạng mục SP-002 cho dòng: options lọc ĐÚNG nhóm + // duyệt của phiếu (BE fail-fast tầng-gán + submit-guard chốt cuối). Chỉ tải khi phiếu + // còn sửa được (Nháp/Trả lại) — tránh 1 request thừa trên phiếu đã trình/duyệt. + const planDraftEditable = + plan.data?.phase === KhkkPhase.DangSoanThao || plan.data?.phase === KhkkPhase.TraLai + const catalogOptions = useQuery({ + queryKey: ['contract-catalog', plan.data?.approvalGroup], + queryFn: async () => + ( + await api.get<{ id: string; code: string; tenVi: string }[]>('/catalogs/contract-catalog', { + params: { approvalGroup: plan.data!.approvalGroup }, + }) + ).data, + enabled: planDraftEditable && plan.data?.approvalGroup != null, + }) + const assignLine = useMutation({ + mutationFn: async (p: { lineId: string; catalogEntryId: string }) => + api.put(`/contract-signing-plans/${id}/lines/${p.lineId}`, { catalogEntryId: p.catalogEntryId }), + onSuccess: () => { + toast.success('Đã gán hạng mục') + invalidate() + }, + onError: (e) => toast.error(getErrorMessage(e)), + }) + const upsertItem = useMutation({ mutationFn: async (body: UpsertKhkkDossierItemInput) => { if (!body.name.trim()) throw new Error('Vui lòng nhập tên căn cứ') @@ -503,9 +528,9 @@ export function KhkkDetailPage() { Nhà cung cấp - {/* [K2 S164] Hạng mục SP-002 của dòng — HIỂN THỊ-ONLY lượt này. - Đường GÁN (Lines-editor) = **K4b**; K2 chỉ đẻ cột + cho thấy dòng - nào còn trống (dòng trống KHÔNG trình duyệt được — submit-guard BE). */} + {/* [K2 S164 → S168] Hạng mục SP-002 của dòng — đường GÁN (Lines-editor) land + @S168 (mảnh hẹn-K4b bị rơi, dry-run K8 bắt): Nháp/Trả lại gán tại bảng, + dòng trống KHÔNG trình duyệt được (submit-guard BE). */} Hạng mục Giá tham chiếu (phiếu NCC) Giá đề xuất @@ -532,7 +557,55 @@ export function KhkkDetailPage() { )} - {l.tenHangMuc ? l.tenHangMuc : } + {/* Dòng đã bắc cầu HĐ thì khoá (đổi hạng mục sau khi có HĐ = đổi lịch sử). */} + {isDraft && !l.contractId ? ( + <> + + {catalogOptions.isError && ( +
+ Không tải được danh mục hạng mục —{' '} + +
+ )} + {catalogOptions.isSuccess && (catalogOptions.data?.length ?? 0) === 0 && ( +
+ Nhóm duyệt N{k.approvalGroup} chưa có hạng mục đang hiệu lực — liên hệ quản trị danh mục. +
+ )} + + ) : l.tenHangMuc ? ( + l.tenHangMuc + ) : ( + + )} {formatVnd(l.peReferenceAmount)} {formatVnd(l.proposedAmount)} @@ -545,7 +618,7 @@ export function KhkkDetailPage() { )}
Giá tham chiếu là SNAPSHOT tổng báo giá được chọn của NCC trên phiếu Duyệt NCC tại thời điểm lập kế hoạch. - {' '}Cột Hạng mục hiện chưa gán được từ màn này (ô gán thuộc bước Lines-editor). + {' '}Cột Hạng mục gán ngay tại bảng khi phiếu ở Nháp/Trả lại — mọi dòng phải gán xong mới trình duyệt được.
diff --git a/scripts/backup-sql.ps1 b/scripts/backup-sql.ps1 index 12ea67b..464493b 100644 --- a/scripts/backup-sql.ps1 +++ b/scripts/backup-sql.ps1 @@ -9,7 +9,9 @@ param( [string]$Server = ".", [string]$Database = "SolutionErp", - [string]$BackupDir = "D:\Backups\SolutionErp", + # [S168 K8 B0] Default C:\ — VPS prod KHONG co o D (do that: mkdir D:\ fail + # "Cannot find drive"); D:\ cu la gia dinh chua tung chay tren may nay. + [string]$BackupDir = "C:\Backups\SolutionErp", [int]$RetentionDays = 30 ) diff --git a/src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs b/src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs index 18f1a92..0ac54b6 100644 --- a/src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs +++ b/src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs @@ -102,6 +102,18 @@ public class ContractSigningPlansController( return NoContent(); } + // [S168 K8-blocker] Gán hạng mục SP-002 cho DÒNG — mảnh Lines-editor (comment auto-gen hẹn + // K4b, chưa từng land; submit-guard vá-5 chặn null ⇒ thiếu route này phiếu mới kẹt Nháp). + // Ghi vào phiếu ⇒ policy Update (khuôn dossier-items dưới — "tạo" ở module này = tạo PHIẾU). + [HttpPut("{id:guid}/lines/{lineId:guid}")] + [Authorize(Policy = "KeHoachKyKet.Update")] + public async Task UpdateLine( + Guid id, Guid lineId, [FromBody] UpdateSigningPlanLineBody body, CancellationToken ct) + { + await mediator.Send(new UpdateContractSigningPlanLineCommand(id, lineId, body.CatalogEntryId), ct); + return NoContent(); + } + [HttpDelete("{id:guid}")] [Authorize(Policy = "KeHoachKyKet.Delete")] public async Task Delete( @@ -257,6 +269,9 @@ public class ContractSigningPlansController( string? Comment = null, bool? ApplyLevelFinalize = null); + // [S168 K8-blocker] Body gán hạng mục cho dòng — PlanId/LineId lấy từ route (1 nguồn sự thật). + public record UpdateSigningPlanLineBody(Guid CatalogEntryId); + // [K7 S167] Cầu → HĐ. `PlanId` lấy từ route (không nhận trong body — 1 nguồn sự thật). // `LineIds` nullable ở BODY để client gửi thiếu ra 400 của validator ("chọn ít nhất 1 dòng") // thay vì 500 NullReference ở tầng dưới. diff --git a/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs b/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs index fb3c651..255daf2 100644 --- a/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs +++ b/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs @@ -574,6 +574,84 @@ public class UpdateContractSigningPlanDraftCommandHandler( } } +// [S168 K8-blocker] Gán hạng mục SP-002 cho DÒNG kế hoạch — mảnh "Lines-editor" mà comment +// auto-gen (`:481-484`) HẸN nhưng chưa từng land (build-order K4b đổi việc sang `?group=`): +// submit-guard vá-5 đã CHẶN line CatalogEntryId=null ⇒ thiếu command này thì MỌI phiếu tạo +// sau K2 kẹt Nháp vĩnh viễn. Dry-run K8 bắt lỗ này trước giờ chạy. +public record UpdateContractSigningPlanLineCommand( + Guid PlanId, + Guid LineId, + Guid CatalogEntryId) : IRequest; + +public class UpdateContractSigningPlanLineCommandValidator + : AbstractValidator +{ + public UpdateContractSigningPlanLineCommandValidator() + { + RuleFor(x => x.PlanId).NotEmpty(); + RuleFor(x => x.LineId).NotEmpty(); + RuleFor(x => x.CatalogEntryId).NotEmpty(); + } +} + +public class UpdateContractSigningPlanLineCommandHandler( + IApplicationDbContext db, + ICurrentUser currentUser) : IRequestHandler +{ + public async Task Handle(UpdateContractSigningPlanLineCommand request, CancellationToken ct) + { + var plan = await db.ContractSigningPlans + .Include(x => x.Lines) + .FirstOrDefaultAsync(x => x.Id == request.PlanId, ct) + ?? throw new NotFoundException("ContractSigningPlan", request.PlanId); + + ContractSigningPlanScope.EnsureDraftEditable(plan, currentUser); + + var line = plan.Lines.FirstOrDefault(l => l.Id == request.LineId) + ?? throw new NotFoundException("ContractSigningPlanLine", request.LineId); + + // [gate-lineeditor F-6] Rào BE thật (FE khoá chỉ là hiển thị): dòng đã bắc cầu HĐ + // thì hạng mục bất biến — đổi sau khi có HĐ = đổi lịch sử pháp lý của HĐ đã sinh. + if (line.ContractId != null) + throw new ConflictException("Dòng đã được đưa vào hợp đồng — không đổi hạng mục được nữa."); + + // Fail-fast tầng-gán (submit-guard vẫn là chốt CUỐI chống data cũ/lách API): + // hạng mục phải CÒN SỐNG + đang hoạt động + ĐÚNG nhóm duyệt của phiếu. + var entry = await db.ContractCatalogEntries.AsNoTracking() + .FirstOrDefaultAsync(c => c.Id == request.CatalogEntryId, ct) + ?? throw new NotFoundException("ContractCatalogEntry", request.CatalogEntryId); + if (!entry.IsActive || entry.ApprovalGroup != plan.ApprovalGroup) + throw new ConflictException( + $"Hạng mục không hợp lệ — phải đang hoạt động và thuộc nhóm duyệt N{plan.ApprovalGroup} của phiếu."); + + line.CatalogEntryId = entry.Id; + // Denorm tên TẠI THỜI ĐIỂM GÁN (khuôn `SignedByFullName` Mig 26 — entity `:25-27`): + // danh mục đổi tên sau KHÔNG viết lại lịch sử phiếu. + line.TenHangMuc = entry.TenVi; + + // [gate-lineeditor F-1] Phiếu liên danh N dòng ⇒ nhật ký PHẢI trỏ được DÒNG NÀO: + // EntityId = line.Id (quy ước mọi changelog thực-thể-con của file) + tên NCC vào Summary. + var supplierName = await db.Suppliers.AsNoTracking() + .Where(x => x.Id == line.SupplierId) + .Select(x => x.Name) + .FirstOrDefaultAsync(ct); + + db.ContractSigningPlanChangelogs.Add(new ContractSigningPlanChangelog + { + ContractSigningPlanId = plan.Id, + EntityType = ContractSigningPlanEntityType.Line, + EntityId = line.Id, + Action = ChangelogAction.Update, + PhaseAtChange = plan.Phase, + UserId = currentUser.UserId, + UserName = currentUser.FullName ?? currentUser.Email, + Summary = $"Gán hạng mục '{entry.Code} — {entry.TenVi}' cho dòng NCC {supplierName ?? "(không rõ tên)"}", + }); + + await db.SaveChangesAsync(ct); + } +} + // ============================ Region 3: GET detail ================================== public record GetContractSigningPlanQuery(Guid Id) : IRequest; diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanGroupAndCatalogTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanGroupAndCatalogTests.cs index 4aac90d..0510d0b 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanGroupAndCatalogTests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanGroupAndCatalogTests.cs @@ -900,4 +900,144 @@ public class ContractSigningPlanGroupAndCatalogTests .Select(p => new PlanScope(p.MaKeHoach!, p.ApprovalGroup, p.ApprovalWorkflowId!.Value)) .ToListAsync(CancellationToken.None); } + + // =================================================================== + // [S168 K8-blocker] Lines-editor `UpdateContractSigningPlanLineCommand` — mảnh comment + // auto-gen HẸN cho K4b nhưng chưa từng land, dry-run K8 bắt: submit-guard vá-5 chặn + // line null mà 0 đường gán ⇒ phiếu mới kẹt Nháp vĩnh viễn. 3 phép đo: DƯƠNG đi trọn + // (gán → submit QUA — nối thẳng vào guard thật, không đo gán suông) · ÂM sai nhóm · + // ÂM sai phase. Handler THẬT, không mock (khuôn Act-helpers file này). + // =================================================================== + + private static async Task AssignLineAsync( + KhkkGroupFixture f, TestCurrentUser actor, Guid planId, Guid lineId, Guid catalogId) + { + await using var db = f.NewDb(actor); + var handler = new UpdateContractSigningPlanLineCommandHandler(db, actor); + await handler.Handle( + new UpdateContractSigningPlanLineCommand(planId, lineId, catalogId), CancellationToken.None); + } + + [Fact] + public async Task GanHangMuc_DungNhom_SnapshotTen_RoiTrinhQuaGuard() + { + using var f = new KhkkGroupFixture(); + var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch")); + var approver = await AddUserAsync(f, "NV duyệt Cấp 1"); + var wf = await SeedWorkflowAsync(f, "QT-KHKK-LINE-A", steps: [[[approver.Id]]]); + var catalog = await AddCatalogAsync(f, approvalGroup: 4, tenVi: "Xi măng"); + var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/101", + ContractSigningPlanPhase.DangSoanThao, approvalGroup: 4, + lines: [new LineSeed(100m, 100m, CatalogId: null)]); + + var lineId = (await ReadPlanAsync(f, planId)).Lines.Single().Id; + await AssignLineAsync(f, drafter, planId, lineId, catalog); + + var line = (await ReadPlanAsync(f, planId)).Lines.Single(); + line.CatalogEntryId.Should().Be(catalog); + line.TenHangMuc.Should().Be("Xi măng", + "tên hạng mục phải denorm TẠI THỜI ĐIỂM GÁN (khuôn SignedByFullName Mig 26)"); + + // Nối trọn vào submit-guard THẬT: gán xong thì trình phải QUA (ca dương của vá-5, + // chống thế giới nơi gán ăn nhưng submit vẫn đỏ vì lý do khác). + await ActAsync(f, drafter, planId, "submit"); + (await ReadPlanAsync(f, planId)).Phase.Should().Be(ContractSigningPlanPhase.ChoDuyet); + } + + [Fact] + public async Task GanHangMuc_KhacNhomDuyet_BiChan409_LineGiuNguyen() + { + using var f = new KhkkGroupFixture(); + var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch")); + // ApproverUserId cần ROW Users THẬT (FK Restrict — ghi chú fixture đầu file). + var approver = await AddUserAsync(f, "NV duyệt Cấp 1"); + var wf = await SeedWorkflowAsync(f, "QT-KHKK-LINE-B", steps: [[[approver.Id]]]); + var catalogN2 = await AddCatalogAsync(f, approvalGroup: 2, tenVi: "Nhân công cốp pha"); + var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/102", + ContractSigningPlanPhase.DangSoanThao, approvalGroup: 4, + lines: [new LineSeed(100m, 100m, CatalogId: null)]); + var lineId = (await ReadPlanAsync(f, planId)).Lines.Single().Id; + + var act = () => AssignLineAsync(f, drafter, planId, lineId, catalogN2); + await act.Should().ThrowAsync( + "hạng mục nhóm N2 không được gán vào phiếu nhóm N4 — fail-fast tầng-gán, " + + "submit-guard chỉ là chốt cuối"); + + (await ReadPlanAsync(f, planId)).Lines.Single().CatalogEntryId.Should().BeNull( + "gán fail thì line phải GIỮ NGUYÊN — không ghi dở"); + } + + [Fact] + public async Task GanHangMuc_EntryNgungHieuLuc_BiChan409() + { + using var f = new KhkkGroupFixture(); + var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch")); + var approver = await AddUserAsync(f, "NV duyệt Cấp 1"); + var wf = await SeedWorkflowAsync(f, "QT-KHKK-LINE-D", steps: [[[approver.Id]]]); + // Entry ĐÚNG nhóm nhưng IsActive=false — nhánh guard còn lại (gate-lineeditor F-4). + Guid inactive; + { + await using var db = f.NewDb(TestCurrentUser.System()); + var entry = new ContractCatalogEntry + { + Id = Guid.NewGuid(), Code = "K2-" + Guid.NewGuid().ToString("N")[..8], + TenVi = "Hạng mục đã ngừng", GroupCode = "B1", ApprovalGroup = 4, + SignerRole = "TP.PRO", IsActive = false, SortOrder = 1, + }; + db.ContractCatalogEntries.Add(entry); + await db.SaveChangesAsync(CancellationToken.None); + inactive = entry.Id; + } + var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/104", + ContractSigningPlanPhase.DangSoanThao, approvalGroup: 4, + lines: [new LineSeed(100m, 100m, CatalogId: null)]); + var lineId = (await ReadPlanAsync(f, planId)).Lines.Single().Id; + + var act = () => AssignLineAsync(f, drafter, planId, lineId, inactive); + await act.Should().ThrowAsync( + "entry IsActive=false phải bị chặn dù ĐÚNG nhóm — nhánh guard độc lập với nhánh khác-nhóm"); + } + + [Fact] + public async Task GanHangMuc_GanDe_DoiCaIdLanTenSnapshot() + { + using var f = new KhkkGroupFixture(); + var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch")); + var approver = await AddUserAsync(f, "NV duyệt Cấp 1"); + var wf = await SeedWorkflowAsync(f, "QT-KHKK-LINE-E", steps: [[[approver.Id]]]); + var catA = await AddCatalogAsync(f, approvalGroup: 4, tenVi: "Xi măng"); + var catB = await AddCatalogAsync(f, approvalGroup: 4, tenVi: "Thép hình"); + var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/105", + ContractSigningPlanPhase.DangSoanThao, approvalGroup: 4, + lines: [new LineSeed(100m, 100m, CatalogId: null)]); + var lineId = (await ReadPlanAsync(f, planId)).Lines.Single().Id; + + await AssignLineAsync(f, drafter, planId, lineId, catA); + // Gán ĐÈ là đường hợp lệ (người soạn chọn nhầm rồi chọn lại) — cả Id lẫn snapshot đổi theo. + await AssignLineAsync(f, drafter, planId, lineId, catB); + + var line = (await ReadPlanAsync(f, planId)).Lines.Single(); + line.CatalogEntryId.Should().Be(catB); + line.TenHangMuc.Should().Be("Thép hình", "snapshot phải đổi theo lần gán CUỐI"); + } + + [Fact] + public async Task GanHangMuc_PhieuDaTrinh_BiChanPhase() + { + using var f = new KhkkGroupFixture(); + var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch")); + // ApproverUserId cần ROW Users THẬT (FK Restrict — ghi chú fixture đầu file). + var approver = await AddUserAsync(f, "NV duyệt Cấp 1"); + var wf = await SeedWorkflowAsync(f, "QT-KHKK-LINE-C", steps: [[[approver.Id]]]); + var catalog = await AddCatalogAsync(f, approvalGroup: 4, tenVi: "Thép hình"); + var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/103", + ContractSigningPlanPhase.ChoDuyet, approvalGroup: 4, + lines: [new LineSeed(100m, 100m, CatalogId: null)], + stepIndex: 0, levelOrder: 1); + var lineId = (await ReadPlanAsync(f, planId)).Lines.Single().Id; + + var act = () => AssignLineAsync(f, drafter, planId, lineId, catalog); + await act.Should().ThrowAsync( + "phiếu đã trình thì dòng bất biến — chỉ Nháp/Trả lại sửa được (EnsureDraftEditable)"); + } }