[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
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:
@ -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 có 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 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.
|
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.
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
|
|||||||
@ -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 có 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 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.
|
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.
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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 rõ 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>;
|
||||||
|
|||||||
@ -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)");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user