[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)), 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({ const upsertItem = useMutation({
mutationFn: async (body: UpsertKhkkDossierItemInput) => { mutationFn: async (body: UpsertKhkkDossierItemInput) => {
if (!body.name.trim()) throw new Error('Vui lòng nhập tên căn cứ') 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"> <thead className="border-b border-slate-200 bg-slate-50/70">
<tr> <tr>
<th className="label-eyebrow px-3 py-2 text-left">Nhà cung cấp</th> <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. {/* [K2 S164 → S168] Hạng mục SP-002 của dòng — đường GÁN (Lines-editor) land
Đường GÁN (Lines-editor) = **K4b**; K2 chỉ đẻ cột + cho thấy dòng @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,
nào còn trống (dòng trống KHÔNG trình duyệt được submit-guard BE). */} 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-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á tham chiếu (phiếu NCC)</th>
<th className="label-eyebrow px-3 py-2 text-right">Giá đ xuất</th> <th className="label-eyebrow px-3 py-2 text-right">Giá đ xuất</th>
@ -532,7 +557,55 @@ export function KhkkDetailPage() {
)} )}
</td> </td>
<td className="px-3 py-2 text-xs text-slate-700"> <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>
<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-slate-600">{formatVnd(l.peReferenceAmount)}</td>
<td className="px-3 py-2 text-right tabular-nums text-brand-800">{formatVnd(l.proposedAmount)}</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"> <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. 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> </div>
</SectionCard> </SectionCard>

View File

@ -167,6 +167,31 @@ export function KhkkDetailPage() {
onError: (e) => toast.error(getErrorMessage(e)), 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({ const upsertItem = useMutation({
mutationFn: async (body: UpsertKhkkDossierItemInput) => { mutationFn: async (body: UpsertKhkkDossierItemInput) => {
if (!body.name.trim()) throw new Error('Vui lòng nhập tên căn cứ') 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"> <thead className="border-b border-slate-200 bg-slate-50/70">
<tr> <tr>
<th className="label-eyebrow px-3 py-2 text-left">Nhà cung cấp</th> <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. {/* [K2 S164 → S168] Hạng mục SP-002 của dòng — đường GÁN (Lines-editor) land
Đường GÁN (Lines-editor) = **K4b**; K2 chỉ đẻ cột + cho thấy dòng @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,
nào còn trống (dòng trống KHÔNG trình duyệt được submit-guard BE). */} 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-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á tham chiếu (phiếu NCC)</th>
<th className="label-eyebrow px-3 py-2 text-right">Giá đ xuất</th> <th className="label-eyebrow px-3 py-2 text-right">Giá đ xuất</th>
@ -532,7 +557,55 @@ export function KhkkDetailPage() {
)} )}
</td> </td>
<td className="px-3 py-2 text-xs text-slate-700"> <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>
<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-slate-600">{formatVnd(l.peReferenceAmount)}</td>
<td className="px-3 py-2 text-right tabular-nums text-brand-800">{formatVnd(l.proposedAmount)}</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"> <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. 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> </div>
</SectionCard> </SectionCard>

View File

@ -9,7 +9,9 @@
param( param(
[string]$Server = ".", [string]$Server = ".",
[string]$Database = "SolutionErp", [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 [int]$RetentionDays = 30
) )

View File

@ -102,6 +102,18 @@ public class ContractSigningPlansController(
return NoContent(); 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}")] [HttpDelete("{id:guid}")]
[Authorize(Policy = "KeHoachKyKet.Delete")] [Authorize(Policy = "KeHoachKyKet.Delete")]
public async Task<IActionResult> Delete( public async Task<IActionResult> Delete(
@ -257,6 +269,9 @@ public class ContractSigningPlansController(
string? Comment = null, string? Comment = null,
bool? ApplyLevelFinalize = 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). // [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") // `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. // 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 ================================== // ============================ Region 3: GET detail ==================================
public record GetContractSigningPlanQuery(Guid Id) : IRequest<ContractSigningPlanDetailDto>; 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)) .Select(p => new PlanScope(p.MaKeHoach!, p.ApprovalGroup, p.ApprovalWorkflowId!.Value))
.ToListAsync(CancellationToken.None); .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)");
}
} }