[CLAUDE] Contract: K8-blocker Lines-editor KHKK — gan hang muc SP-002 cho dong (command hua-trong-comment chua tung land)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m27s

- 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 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-01 17:08:47 +07:00
parent 7d0867011c
commit 0bfadc2011
6 changed files with 392 additions and 11 deletions

View File

@ -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() {
<thead className="border-b border-slate-200 bg-slate-50/70">
<tr>
<th className="label-eyebrow px-3 py-2 text-left">Nhà cung cấp</th>
{/* [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). */}
<th className="label-eyebrow px-3 py-2 text-left">Hạng mục</th>
<th className="label-eyebrow px-3 py-2 text-right">Giá tham chiếu (phiếu NCC)</th>
<th className="label-eyebrow px-3 py-2 text-right">Giá đ xuất</th>
@ -532,7 +557,55 @@ export function KhkkDetailPage() {
)}
</td>
<td className="px-3 py-2 text-xs text-slate-700">
{l.tenHangMuc ? l.tenHangMuc : <span className="text-slate-300"></span>}
{/* 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 ? (
<>
<select
value={l.catalogEntryId ?? ''}
onChange={(e) =>
e.target.value &&
assignLine.mutate({ lineId: l.id, catalogEntryId: e.target.value })
}
disabled={assignLine.isPending}
aria-label={`Gán hạng mục SP-002 cho dòng ${l.supplierName ?? l.id}`}
className="h-8 w-full min-w-[12rem] rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-700 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
>
<option value=""> Chọn hạng mục </option>
{/* [gate-lineeditor F-2] Hạng mục ĐÃ LƯU mà không có trong options
(đang tải / query lỗi / entry hết hiệu lực) ⇒ chèn option đại diện
từ dữ liệu đã lưu — không thì select rơi về placeholder và dòng
đã gán TRÔNG như chưa gán (vắng-mặt trông giống ổn). */}
{l.catalogEntryId &&
!(catalogOptions.data ?? []).some((c) => c.id === l.catalogEntryId) && (
<option value={l.catalogEntryId}>
{l.tenHangMuc ?? '(hạng mục đã gán)'}
</option>
)}
{(catalogOptions.data ?? []).map((c) => (
<option key={c.id} value={c.id}>
{c.code} {c.tenVi}
</option>
))}
</select>
{catalogOptions.isError && (
<div className="mt-1 text-[11px] text-red-600">
Không tải đưc danh mục hạng mục {' '}
<button type="button" className="underline" onClick={() => catalogOptions.refetch()}>
thử lại
</button>
</div>
)}
{catalogOptions.isSuccess && (catalogOptions.data?.length ?? 0) === 0 && (
<div className="mt-1 text-[11px] text-amber-700">
Nhóm duyệt N{k.approvalGroup} chưa hạng mục đang hiệu lực liên hệ quản trị danh mục.
</div>
)}
</>
) : l.tenHangMuc ? (
l.tenHangMuc
) : (
<span className="text-slate-300"></span>
)}
</td>
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{formatVnd(l.peReferenceAmount)}</td>
<td className="px-3 py-2 text-right tabular-nums text-brand-800">{formatVnd(l.proposedAmount)}</td>
@ -545,7 +618,7 @@ export function KhkkDetailPage() {
)}
<div className="mt-3 text-xs text-muted-foreground">
Giá tham chiếu 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.
</div>
</SectionCard>

View File

@ -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() {
<thead className="border-b border-slate-200 bg-slate-50/70">
<tr>
<th className="label-eyebrow px-3 py-2 text-left">Nhà cung cấp</th>
{/* [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). */}
<th className="label-eyebrow px-3 py-2 text-left">Hạng mục</th>
<th className="label-eyebrow px-3 py-2 text-right">Giá tham chiếu (phiếu NCC)</th>
<th className="label-eyebrow px-3 py-2 text-right">Giá đ xuất</th>
@ -532,7 +557,55 @@ export function KhkkDetailPage() {
)}
</td>
<td className="px-3 py-2 text-xs text-slate-700">
{l.tenHangMuc ? l.tenHangMuc : <span className="text-slate-300"></span>}
{/* 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 ? (
<>
<select
value={l.catalogEntryId ?? ''}
onChange={(e) =>
e.target.value &&
assignLine.mutate({ lineId: l.id, catalogEntryId: e.target.value })
}
disabled={assignLine.isPending}
aria-label={`Gán hạng mục SP-002 cho dòng ${l.supplierName ?? l.id}`}
className="h-8 w-full min-w-[12rem] rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-700 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
>
<option value=""> Chọn hạng mục </option>
{/* [gate-lineeditor F-2] Hạng mục ĐÃ LƯU mà không có trong options
(đang tải / query lỗi / entry hết hiệu lực) ⇒ chèn option đại diện
từ dữ liệu đã lưu — không thì select rơi về placeholder và dòng
đã gán TRÔNG như chưa gán (vắng-mặt trông giống ổn). */}
{l.catalogEntryId &&
!(catalogOptions.data ?? []).some((c) => c.id === l.catalogEntryId) && (
<option value={l.catalogEntryId}>
{l.tenHangMuc ?? '(hạng mục đã gán)'}
</option>
)}
{(catalogOptions.data ?? []).map((c) => (
<option key={c.id} value={c.id}>
{c.code} {c.tenVi}
</option>
))}
</select>
{catalogOptions.isError && (
<div className="mt-1 text-[11px] text-red-600">
Không tải đưc danh mục hạng mục {' '}
<button type="button" className="underline" onClick={() => catalogOptions.refetch()}>
thử lại
</button>
</div>
)}
{catalogOptions.isSuccess && (catalogOptions.data?.length ?? 0) === 0 && (
<div className="mt-1 text-[11px] text-amber-700">
Nhóm duyệt N{k.approvalGroup} chưa hạng mục đang hiệu lực liên hệ quản trị danh mục.
</div>
)}
</>
) : l.tenHangMuc ? (
l.tenHangMuc
) : (
<span className="text-slate-300"></span>
)}
</td>
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{formatVnd(l.peReferenceAmount)}</td>
<td className="px-3 py-2 text-right tabular-nums text-brand-800">{formatVnd(l.proposedAmount)}</td>
@ -545,7 +618,7 @@ export function KhkkDetailPage() {
)}
<div className="mt-3 text-xs text-muted-foreground">
Giá tham chiếu 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.
</div>
</SectionCard>

View File

@ -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
)

View File

@ -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<IActionResult> 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<IActionResult> 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.

View File

@ -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<UpdateContractSigningPlanLineCommand>
{
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<UpdateContractSigningPlanLineCommand>
{
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 tên)"}",
});
await db.SaveChangesAsync(ct);
}
}
// ============================ Region 3: GET detail ==================================
public record GetContractSigningPlanQuery(Guid Id) : IRequest<ContractSigningPlanDetailDto>;

View File

@ -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<ConflictException>(
"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<ConflictException>(
"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<ConflictException>(
"phiếu đã trình thì dòng bất biến — chỉ Nháp/Trả lại sửa được (EnsureDraftEditable)");
}
}