[CLAUDE] PurchaseEvaluation: Mig 55 ô "Ghi chú từ CCM" ngân sách gói thầu — CCM nhập số + ghi lý do giống PRO
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m54s

- Entity PeWorkItemBudget +CcmNote (mirror ProNote, nvarchar 1000) + Mig 55 additive-nullable
- UpdatePeBudgetCcmCommand +CcmNote absolute-set, role-gate CostControl/Admin fail-closed
- DTO PeBudgetSummaryDto +CcmNote + controller BudgetCcmBody + GET mapping
- FE 2 app SHA-mirror: dòng "Ghi chú từ CCM" gate canEditCcm (sau V0/hiệu chỉnh), absolute-set đủ 3 field
- Test +5 (set CCM/Admin, null-clear, non-priv Forbidden, all-3-persist) -> 339 pass

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-18 19:05:10 +07:00
parent e7e99d10f2
commit 8655ebf1ba
14 changed files with 6487 additions and 15 deletions

View File

@ -1079,7 +1079,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
}) })
// PUT /budget/ccm — chỉ khi canEditCcm. initialAmount + adjustmentAmount. // PUT /budget/ccm — chỉ khi canEditCcm. initialAmount + adjustmentAmount.
const ccmMut = useMutation({ const ccmMut = useMutation({
mutationFn: async (body: { initialAmount: number | null; adjustmentAmount: number | null }) => mutationFn: async (body: { initialAmount: number | null; adjustmentAmount: number | null; ccmNote: string | null }) =>
api.put(`/purchase-evaluations/${ev.id}/budget/ccm`, body), api.put(`/purchase-evaluations/${ev.id}/budget/ccm`, body),
onSuccess: () => { toast.success('Đã lưu ngân sách ban hành'); invalidate() }, onSuccess: () => { toast.success('Đã lưu ngân sách ban hành'); invalidate() },
onError: e => toast.error(getErrorMessage(e)), onError: e => toast.error(getErrorMessage(e)),
@ -1097,6 +1097,9 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
// proNote inline-edit state (Textarea — không dùng VndInlineEdit) // proNote inline-edit state (Textarea — không dùng VndInlineEdit)
const [proNoteText, setProNoteText] = useState(bs?.proNote ?? '') const [proNoteText, setProNoteText] = useState(bs?.proNote ?? '')
useEffect(() => { setProNoteText(bs?.proNote ?? '') }, [bs?.proNote]) useEffect(() => { setProNoteText(bs?.proNote ?? '') }, [bs?.proNote])
// ccmNote inline-edit state (mirror proNoteText) — [Mig anh Kiệt FDC]
const [ccmNoteText, setCcmNoteText] = useState(bs?.ccmNote ?? '')
useEffect(() => { setCcmNoteText(bs?.ccmNote ?? '') }, [bs?.ccmNote])
// Phiếu cũ chưa gắn Hạng mục công việc → budgetSummary null. // Phiếu cũ chưa gắn Hạng mục công việc → budgetSummary null.
if (!bs) { if (!bs) {
@ -1172,7 +1175,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
initial={bs.initialAmount} initial={bs.initialAmount}
saving={ccmMut.isPending} saving={ccmMut.isPending}
label="Ngân sách ban hành lần đầu" label="Ngân sách ban hành lần đầu"
onSave={v => ccmMut.mutate({ initialAmount: v, adjustmentAmount: bs.adjustmentAmount })} onSave={v => ccmMut.mutate({ initialAmount: v, adjustmentAmount: bs.adjustmentAmount, ccmNote: bs.ccmNote })}
/> />
) : bs.initialAmount != null ? fmtVnd(bs.initialAmount) : <span className="text-slate-400"></span> ) : bs.initialAmount != null ? fmtVnd(bs.initialAmount) : <span className="text-slate-400"></span>
} }
@ -1188,7 +1191,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
allowNegative allowNegative
saving={ccmMut.isPending} saving={ccmMut.isPending}
label="Ngân sách hiệu chỉnh tăng giảm" label="Ngân sách hiệu chỉnh tăng giảm"
onSave={v => ccmMut.mutate({ initialAmount: bs.initialAmount, adjustmentAmount: v })} onSave={v => ccmMut.mutate({ initialAmount: bs.initialAmount, adjustmentAmount: v, ccmNote: bs.ccmNote })}
/> />
) : bs.adjustmentAmount != null ? ( ) : bs.adjustmentAmount != null ? (
<span className={cn(bs.adjustmentAmount < 0 && 'text-red-600')}>{fmtVndSigned(bs.adjustmentAmount)}</span> <span className={cn(bs.adjustmentAmount < 0 && 'text-red-600')}>{fmtVndSigned(bs.adjustmentAmount)}</span>
@ -1196,6 +1199,37 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
} }
/> />
{/* Ghi chú từ CCM (CCM editable — Textarea, mirror Ghi chú từ PRO) — [Mig anh Kiệt FDC] */}
<div className="flex items-start gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]">
<div className="min-w-0 flex-1 text-slate-700">Ghi chú từ CCM</div>
<div className="w-72 shrink-0">
{bs.canEditCcm ? (
<div className="space-y-1">
<textarea
value={ccmNoteText}
onChange={e => setCcmNoteText(e.target.value)}
placeholder="Ghi chú ngân sách CCM…"
rows={2}
className="w-full rounded border border-slate-300 px-2 py-1 text-[12px]"
/>
<div className="flex justify-end">
<Button
onClick={() => ccmMut.mutate({ initialAmount: bs.initialAmount, adjustmentAmount: bs.adjustmentAmount, ccmNote: ccmNoteText || null })}
disabled={ccmNoteText === (bs.ccmNote ?? '') || ccmMut.isPending}
className="h-6 px-2 text-[11px]"
>
{ccmMut.isPending ? '…' : 'Lưu ghi chú'}
</Button>
</div>
</div>
) : (
<div className="whitespace-pre-wrap text-right text-[12px] text-slate-600">
{bs.ccmNote || <span className="text-slate-400"></span>}
</div>
)}
</div>
</div>
{/* Dòng 4 — Dự trù PRO (PRO editable) */} {/* Dòng 4 — Dự trù PRO (PRO editable) */}
<BudgetRow <BudgetRow
label="Ngân sách PRO" label="Ngân sách PRO"

View File

@ -293,6 +293,7 @@ export type PeBudgetSummary = {
proNote: string | null proNote: string | null
initialAmount: number | null initialAmount: number | null
adjustmentAmount: number | null // CCM "NS V0/hiệu chỉnh" — cho phép ÂM adjustmentAmount: number | null // CCM "NS V0/hiệu chỉnh" — cho phép ÂM
ccmNote: string | null // [Mig — anh Kiệt FDC] Ghi chú từ CCM (mirror proNote)
fullAmount: number fullAmount: number
fullIsEstimate: boolean fullIsEstimate: boolean
canEditPro: boolean canEditPro: boolean

View File

@ -1079,7 +1079,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
}) })
// PUT /budget/ccm — chỉ khi canEditCcm. initialAmount + adjustmentAmount. // PUT /budget/ccm — chỉ khi canEditCcm. initialAmount + adjustmentAmount.
const ccmMut = useMutation({ const ccmMut = useMutation({
mutationFn: async (body: { initialAmount: number | null; adjustmentAmount: number | null }) => mutationFn: async (body: { initialAmount: number | null; adjustmentAmount: number | null; ccmNote: string | null }) =>
api.put(`/purchase-evaluations/${ev.id}/budget/ccm`, body), api.put(`/purchase-evaluations/${ev.id}/budget/ccm`, body),
onSuccess: () => { toast.success('Đã lưu ngân sách ban hành'); invalidate() }, onSuccess: () => { toast.success('Đã lưu ngân sách ban hành'); invalidate() },
onError: e => toast.error(getErrorMessage(e)), onError: e => toast.error(getErrorMessage(e)),
@ -1097,6 +1097,9 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
// proNote inline-edit state (Textarea — không dùng VndInlineEdit) // proNote inline-edit state (Textarea — không dùng VndInlineEdit)
const [proNoteText, setProNoteText] = useState(bs?.proNote ?? '') const [proNoteText, setProNoteText] = useState(bs?.proNote ?? '')
useEffect(() => { setProNoteText(bs?.proNote ?? '') }, [bs?.proNote]) useEffect(() => { setProNoteText(bs?.proNote ?? '') }, [bs?.proNote])
// ccmNote inline-edit state (mirror proNoteText) — [Mig anh Kiệt FDC]
const [ccmNoteText, setCcmNoteText] = useState(bs?.ccmNote ?? '')
useEffect(() => { setCcmNoteText(bs?.ccmNote ?? '') }, [bs?.ccmNote])
// Phiếu cũ chưa gắn Hạng mục công việc → budgetSummary null. // Phiếu cũ chưa gắn Hạng mục công việc → budgetSummary null.
if (!bs) { if (!bs) {
@ -1172,7 +1175,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
initial={bs.initialAmount} initial={bs.initialAmount}
saving={ccmMut.isPending} saving={ccmMut.isPending}
label="Ngân sách ban hành lần đầu" label="Ngân sách ban hành lần đầu"
onSave={v => ccmMut.mutate({ initialAmount: v, adjustmentAmount: bs.adjustmentAmount })} onSave={v => ccmMut.mutate({ initialAmount: v, adjustmentAmount: bs.adjustmentAmount, ccmNote: bs.ccmNote })}
/> />
) : bs.initialAmount != null ? fmtVnd(bs.initialAmount) : <span className="text-slate-400"></span> ) : bs.initialAmount != null ? fmtVnd(bs.initialAmount) : <span className="text-slate-400"></span>
} }
@ -1188,7 +1191,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
allowNegative allowNegative
saving={ccmMut.isPending} saving={ccmMut.isPending}
label="Ngân sách hiệu chỉnh tăng giảm" label="Ngân sách hiệu chỉnh tăng giảm"
onSave={v => ccmMut.mutate({ initialAmount: bs.initialAmount, adjustmentAmount: v })} onSave={v => ccmMut.mutate({ initialAmount: bs.initialAmount, adjustmentAmount: v, ccmNote: bs.ccmNote })}
/> />
) : bs.adjustmentAmount != null ? ( ) : bs.adjustmentAmount != null ? (
<span className={cn(bs.adjustmentAmount < 0 && 'text-red-600')}>{fmtVndSigned(bs.adjustmentAmount)}</span> <span className={cn(bs.adjustmentAmount < 0 && 'text-red-600')}>{fmtVndSigned(bs.adjustmentAmount)}</span>
@ -1196,6 +1199,37 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
} }
/> />
{/* Ghi chú từ CCM (CCM editable — Textarea, mirror Ghi chú từ PRO) — [Mig anh Kiệt FDC] */}
<div className="flex items-start gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]">
<div className="min-w-0 flex-1 text-slate-700">Ghi chú từ CCM</div>
<div className="w-72 shrink-0">
{bs.canEditCcm ? (
<div className="space-y-1">
<textarea
value={ccmNoteText}
onChange={e => setCcmNoteText(e.target.value)}
placeholder="Ghi chú ngân sách CCM…"
rows={2}
className="w-full rounded border border-slate-300 px-2 py-1 text-[12px]"
/>
<div className="flex justify-end">
<Button
onClick={() => ccmMut.mutate({ initialAmount: bs.initialAmount, adjustmentAmount: bs.adjustmentAmount, ccmNote: ccmNoteText || null })}
disabled={ccmNoteText === (bs.ccmNote ?? '') || ccmMut.isPending}
className="h-6 px-2 text-[11px]"
>
{ccmMut.isPending ? '…' : 'Lưu ghi chú'}
</Button>
</div>
</div>
) : (
<div className="whitespace-pre-wrap text-right text-[12px] text-slate-600">
{bs.ccmNote || <span className="text-slate-400"></span>}
</div>
)}
</div>
</div>
{/* Dòng 4 — Dự trù PRO (PRO editable) */} {/* Dòng 4 — Dự trù PRO (PRO editable) */}
<BudgetRow <BudgetRow
label="Ngân sách PRO" label="Ngân sách PRO"

View File

@ -295,6 +295,7 @@ export type PeBudgetSummary = {
proNote: string | null proNote: string | null
initialAmount: number | null initialAmount: number | null
adjustmentAmount: number | null // CCM "NS V0/hiệu chỉnh" — cho phép ÂM adjustmentAmount: number | null // CCM "NS V0/hiệu chỉnh" — cho phép ÂM
ccmNote: string | null // [Mig — anh Kiệt FDC] Ghi chú từ CCM (mirror proNote)
fullAmount: number fullAmount: number
fullIsEstimate: boolean fullIsEstimate: boolean
canEditPro: boolean canEditPro: boolean

View File

@ -80,10 +80,10 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
[HttpPut("{id:guid}/budget/ccm")] [HttpPut("{id:guid}/budget/ccm")]
public async Task<IActionResult> UpdateBudgetCcm(Guid id, [FromBody] BudgetCcmBody body, CancellationToken ct) public async Task<IActionResult> UpdateBudgetCcm(Guid id, [FromBody] BudgetCcmBody body, CancellationToken ct)
{ {
await mediator.Send(new UpdatePeBudgetCcmCommand(id, body.InitialAmount, body.AdjustmentAmount), ct); await mediator.Send(new UpdatePeBudgetCcmCommand(id, body.InitialAmount, body.AdjustmentAmount, body.CcmNote), ct);
return NoContent(); return NoContent();
} }
public record BudgetCcmBody(decimal? InitialAmount, decimal? AdjustmentAmount); public record BudgetCcmBody(decimal? InitialAmount, decimal? AdjustmentAmount, string? CcmNote);
// [S69 2026-06-17] Cờ gấp (urgent) — anh Kiệt FDC. Class [Authorize] any-auth; // [S69 2026-06-17] Cờ gấp (urgent) — anh Kiệt FDC. Class [Authorize] any-auth;
// handler fine-grained Forbidden theo role (PRO=Procurement set cờ đỏ, CCM= // handler fine-grained Forbidden theo role (PRO=Procurement set cờ đỏ, CCM=

View File

@ -297,6 +297,7 @@ public record PeBudgetSummaryDto(
string? ProNote, string? ProNote,
decimal? InitialAmount, decimal? InitialAmount,
decimal? AdjustmentAmount, decimal? AdjustmentAmount,
string? CcmNote,
decimal FullAmount, decimal FullAmount,
bool FullIsEstimate, bool FullIsEstimate,
bool CanEditPro, bool CanEditPro,

View File

@ -12,7 +12,7 @@ namespace SolutionErp.Application.PurchaseEvaluations;
// [S61 Mig 50] 2 handler nhập ngân sách gói thầu theo ROLE (anh Kiệt chốt): // [S61 Mig 50] 2 handler nhập ngân sách gói thầu theo ROLE (anh Kiệt chốt):
// - PRO (Procurement | Admin): ProEstimateAmount (dự trù lần đầu) + ProNote. // - PRO (Procurement | Admin): ProEstimateAmount (dự trù lần đầu) + ProNote.
// - CCM (CostControl | Admin): InitialAmount ("Ban hành lần đầu") + // - CCM (CostControl | Admin): InitialAmount ("Ban hành lần đầu") +
// AdjustmentAmount ("V0/hiệu chỉnh tăng giảm" — cho phép ÂM). // AdjustmentAmount ("V0/hiệu chỉnh tăng giảm" — cho phép ÂM) + CcmNote (Mig 55).
// Authz pattern AssignItTicketHandler S54: controller [Authorize] any-auth, // Authz pattern AssignItTicketHandler S54: controller [Authorize] any-auth,
// handler fine-grained ForbiddenException fail-closed (Forbidden TRƯỚC mọi // handler fine-grained ForbiddenException fail-closed (Forbidden TRƯỚC mọi
// side-effect — S56 #5). KHÔNG ràng Phase (CCM "nhập trong khi duyệt" theo lời // side-effect — S56 #5). KHÔNG ràng Phase (CCM "nhập trong khi duyệt" theo lời
@ -126,7 +126,8 @@ public class UpdatePeBudgetProCommandHandler(
public record UpdatePeBudgetCcmCommand( public record UpdatePeBudgetCcmCommand(
Guid PeId, Guid PeId,
decimal? InitialAmount, decimal? InitialAmount,
decimal? AdjustmentAmount) : IRequest; decimal? AdjustmentAmount,
string? CcmNote) : IRequest;
public class UpdatePeBudgetCcmCommandValidator : AbstractValidator<UpdatePeBudgetCcmCommand> public class UpdatePeBudgetCcmCommandValidator : AbstractValidator<UpdatePeBudgetCcmCommand>
{ {
@ -135,6 +136,7 @@ public class UpdatePeBudgetCcmCommandValidator : AbstractValidator<UpdatePeBudge
RuleFor(x => x.InitialAmount).GreaterThanOrEqualTo(0) RuleFor(x => x.InitialAmount).GreaterThanOrEqualTo(0)
.When(x => x.InitialAmount.HasValue); .When(x => x.InitialAmount.HasValue);
// AdjustmentAmount KHÔNG ràng dấu — "hiệu chỉnh tăng giảm" cho phép ÂM. // AdjustmentAmount KHÔNG ràng dấu — "hiệu chỉnh tăng giảm" cho phép ÂM.
RuleFor(x => x.CcmNote).MaximumLength(1000);
} }
} }
@ -161,14 +163,18 @@ public class UpdatePeBudgetCcmCommandHandler(
var oldInitial = rec.InitialAmount; var oldInitial = rec.InitialAmount;
var oldAdjustment = rec.AdjustmentAmount; var oldAdjustment = rec.AdjustmentAmount;
var oldCcmNote = rec.CcmNote;
rec.InitialAmount = request.InitialAmount; // absolute-set (null = clear) rec.InitialAmount = request.InitialAmount; // absolute-set (null = clear)
rec.AdjustmentAmount = request.AdjustmentAmount; rec.AdjustmentAmount = request.AdjustmentAmount;
rec.CcmNote = request.CcmNote;
var parts = new List<string>(); var parts = new List<string>();
if (oldInitial != request.InitialAmount) if (oldInitial != request.InitialAmount)
parts.Add($"ban hành lần đầu {oldInitial?.ToString("N0") ?? "(trống)"}đ → {request.InitialAmount?.ToString("N0") ?? "(trống)"}đ"); parts.Add($"ban hành lần đầu {oldInitial?.ToString("N0") ?? "(trống)"}đ → {request.InitialAmount?.ToString("N0") ?? "(trống)"}đ");
if (oldAdjustment != request.AdjustmentAmount) if (oldAdjustment != request.AdjustmentAmount)
parts.Add($"V0/hiệu chỉnh {oldAdjustment?.ToString("N0") ?? "(trống)"}đ → {request.AdjustmentAmount?.ToString("N0") ?? "(trống)"}đ"); parts.Add($"V0/hiệu chỉnh {oldAdjustment?.ToString("N0") ?? "(trống)"}đ → {request.AdjustmentAmount?.ToString("N0") ?? "(trống)"}đ");
if (oldCcmNote != request.CcmNote)
parts.Add("ghi chú CCM cập nhật");
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{ {

View File

@ -851,7 +851,7 @@ public class GetPurchaseEvaluationQueryHandler(
peBudgetSummary = new PeBudgetSummaryDto( peBudgetSummary = new PeBudgetSummaryDto(
pairRec?.Id, pairRec?.ProEstimateAmount, pairRec?.ProNote, pairRec?.Id, pairRec?.ProEstimateAmount, pairRec?.ProNote,
pairRec?.InitialAmount, pairRec?.AdjustmentAmount, pairRec?.InitialAmount, pairRec?.AdjustmentAmount, pairRec?.CcmNote,
fullAmount, !hasCcm, fullAmount, !hasCcm,
canEditPro, canEditCcm, canEditPro, canEditCcm,
prevSubmittedTotal, prevSubmittedCount, prevSubmittedTotal, prevSubmittedCount,

View File

@ -13,7 +13,7 @@ namespace SolutionErp.Domain.PurchaseEvaluations;
// Quyền nhập theo ROLE (anh Kiệt chốt S61): // Quyền nhập theo ROLE (anh Kiệt chốt S61):
// - PRO (Procurement): ProEstimateAmount (dự trù lần đầu) + ProNote. // - PRO (Procurement): ProEstimateAmount (dự trù lần đầu) + ProNote.
// - CCM (CostControl): InitialAmount (Ban hành lần đầu) + AdjustmentAmount // - CCM (CostControl): InitialAmount (Ban hành lần đầu) + AdjustmentAmount
// (NS V0 hiệu chỉnh tăng/giảm — cho phép ÂM). // (NS V0 hiệu chỉnh tăng/giảm — cho phép ÂM) + CcmNote (ghi chú CCM, Mig 55).
// //
// "Ngân sách full gói thầu" KHÔNG lưu cột — BE compute: // "Ngân sách full gói thầu" KHÔNG lưu cột — BE compute:
// full = (InitialAmount ?? 0) + (AdjustmentAmount ?? 0); // full = (InitialAmount ?? 0) + (AdjustmentAmount ?? 0);
@ -28,4 +28,5 @@ public class PeWorkItemBudget : AuditableEntity
public string? ProNote { get; set; } // "Ghi chú từ PRO" public string? ProNote { get; set; } // "Ghi chú từ PRO"
public decimal? InitialAmount { get; set; } // CCM "Ngân sách Ban hành lần đầu" (đ) public decimal? InitialAmount { get; set; } // CCM "Ngân sách Ban hành lần đầu" (đ)
public decimal? AdjustmentAmount { get; set; } // CCM "NS V0/hiệu chỉnh tăng giảm" (đ, cho phép ÂM) public decimal? AdjustmentAmount { get; set; } // CCM "NS V0/hiệu chỉnh tăng giảm" (đ, cho phép ÂM)
public string? CcmNote { get; set; } // [Mig 55] "Ghi chú từ CCM" — CCM ghi lý do/nguồn số (mirror ProNote)
} }

View File

@ -20,6 +20,7 @@ public class PeWorkItemBudgetConfiguration : IEntityTypeConfiguration<PeWorkItem
b.Property(x => x.InitialAmount).HasPrecision(18, 2); b.Property(x => x.InitialAmount).HasPrecision(18, 2);
b.Property(x => x.AdjustmentAmount).HasPrecision(18, 2); b.Property(x => x.AdjustmentAmount).HasPrecision(18, 2);
b.Property(x => x.ProNote).HasMaxLength(1000); b.Property(x => x.ProNote).HasMaxLength(1000);
b.Property(x => x.CcmNote).HasMaxLength(1000);
b.HasIndex(x => new { x.ProjectId, x.WorkItemId }) b.HasIndex(x => new { x.ProjectId, x.WorkItemId })
.IsUnique() .IsUnique()

View File

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddCcmNoteToPeWorkItemBudget : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "CcmNote",
table: "PeWorkItemBudgets",
type: "nvarchar(1000)",
maxLength: 1000,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CcmNote",
table: "PeWorkItemBudgets");
}
}
}

View File

@ -4523,6 +4523,10 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.HasPrecision(18, 2) .HasPrecision(18, 2)
.HasColumnType("decimal(18,2)"); .HasColumnType("decimal(18,2)");
b.Property<string>("CcmNote")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2"); .HasColumnType("datetime2");

View File

@ -385,7 +385,7 @@ public class PeWorkItemBudgetTests
var handler = new UpdatePeBudgetCcmCommandHandler(db, AsRoles(AppRoles.CostControl)); var handler = new UpdatePeBudgetCcmCommandHandler(db, AsRoles(AppRoles.CostControl));
// Adjustment ÂM 5tr — "hiệu chỉnh tăng giảm" cho phép ÂM (validator KHÔNG ràng dấu). // Adjustment ÂM 5tr — "hiệu chỉnh tăng giảm" cho phép ÂM (validator KHÔNG ràng dấu).
await handler.Handle(new UpdatePeBudgetCcmCommand(pe.Id, 80_000_000m, -5_000_000m), await handler.Handle(new UpdatePeBudgetCcmCommand(pe.Id, 80_000_000m, -5_000_000m, null),
CancellationToken.None); CancellationToken.None);
var rec = await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id); var rec = await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
@ -404,7 +404,7 @@ public class PeWorkItemBudgetTests
var handler = new UpdatePeBudgetCcmCommandHandler(db, AsRoles(AppRoles.Procurement)); var handler = new UpdatePeBudgetCcmCommandHandler(db, AsRoles(AppRoles.Procurement));
await FluentActions.Awaiting(() => handler.Handle( await FluentActions.Awaiting(() => handler.Handle(
new UpdatePeBudgetCcmCommand(pe.Id, 10m, 0m), CancellationToken.None)) new UpdatePeBudgetCcmCommand(pe.Id, 10m, 0m, null), CancellationToken.None))
.Should().ThrowAsync<ForbiddenException>("chỉ CostControl | Admin được nhập ban hành/hiệu chỉnh"); .Should().ThrowAsync<ForbiddenException>("chỉ CostControl | Admin được nhập ban hành/hiệu chỉnh");
} }
@ -418,13 +418,130 @@ public class PeWorkItemBudgetTests
var pe = await SeedPeAsync(db, project.Id, wi.Id); var pe = await SeedPeAsync(db, project.Id, wi.Id);
var handler = new UpdatePeBudgetCcmCommandHandler(db, AsRoles(AppRoles.Admin)); var handler = new UpdatePeBudgetCcmCommandHandler(db, AsRoles(AppRoles.Admin));
await handler.Handle(new UpdatePeBudgetCcmCommand(pe.Id, 1m, 2m), CancellationToken.None); await handler.Handle(new UpdatePeBudgetCcmCommand(pe.Id, 1m, 2m, null), CancellationToken.None);
var rec = await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id); var rec = await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
rec.InitialAmount.Should().Be(1m); rec.InitialAmount.Should().Be(1m);
rec.AdjustmentAmount.Should().Be(2m); rec.AdjustmentAmount.Should().Be(2m);
} }
// =====================================================================
// 4b. CcmNote [Mig 55] — mirror ProNote: absolute-set null=clear,
// role-gate CostControl|Admin fail-closed TRƯỚC side-effect.
// =====================================================================
[Fact]
public async Task UpdateCcm_CostControlRole_SetsCcmNote()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-CCMN1");
var pe = await SeedPeAsync(db, project.Id, wi.Id);
var handler = new UpdatePeBudgetCcmCommandHandler(db, AsRoles(AppRoles.CostControl));
await handler.Handle(
new UpdatePeBudgetCcmCommand(pe.Id, 80_000_000m, -5_000_000m, "Theo NS ban hành Q2 — nguồn dự toán CCM"),
CancellationToken.None);
var rec = await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
rec.CcmNote.Should().Be("Theo NS ban hành Q2 — nguồn dự toán CCM", "CostControl được ghi chú CCM");
}
[Fact]
public async Task UpdateCcm_AdminRole_SetsCcmNote()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-CCMN2");
var pe = await SeedPeAsync(db, project.Id, wi.Id);
var handler = new UpdatePeBudgetCcmCommandHandler(db, AsRoles(AppRoles.Admin));
await handler.Handle(new UpdatePeBudgetCcmCommand(pe.Id, 1m, 2m, "admin ghi chú"), CancellationToken.None);
(await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id))
.CcmNote.Should().Be("admin ghi chú", "Admin được ghi chú CCM");
}
[Fact]
public async Task UpdateCcm_NullCcmNote_ClearsField_AbsoluteSet()
{
// Absolute-set (mirror ProNote): gửi CcmNote=null → CLEAR về null, KHÔNG partial-keep.
// Bắt đầu từ giá trị có sẵn để chứng minh bị xoá (không phải vốn-dĩ-null).
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-CCMN3");
var pe = await SeedPeAsync(db, project.Id, wi.Id);
db.PeWorkItemBudgets.Add(new PeWorkItemBudget
{
Id = Guid.NewGuid(), ProjectId = project.Id, WorkItemId = wi.Id,
InitialAmount = 500m, AdjustmentAmount = 10m, CcmNote = "ghi chú cũ cần xoá",
});
await db.SaveChangesAsync(CancellationToken.None);
var handler = new UpdatePeBudgetCcmCommandHandler(db, AsRoles(AppRoles.CostControl));
await handler.Handle(new UpdatePeBudgetCcmCommand(pe.Id, 500m, 10m, null), CancellationToken.None);
var rec = await db.PeWorkItemBudgets.AsNoTracking()
.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
rec.CcmNote.Should().BeNull("absolute-set: CcmNote=null CLEAR field, KHÔNG giữ giá trị cũ");
}
[Fact]
public async Task UpdateCcm_NonPrivilegedRole_WithCcmNote_ThrowsForbidden_AndDoesNotMutateRecord()
{
// Procurement (KHÔNG CostControl/Admin) set CcmNote → ForbiddenException +
// record giữ nguyên (fail-closed: role-gate TRƯỚC EnsureTrackedAsync + side-effect).
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-CCMN4");
var pe = await SeedPeAsync(db, project.Id, wi.Id);
// Pre-seed record để chứng minh KHÔNG bị mutate khi Forbidden.
db.PeWorkItemBudgets.Add(new PeWorkItemBudget
{
Id = Guid.NewGuid(), ProjectId = project.Id, WorkItemId = wi.Id,
InitialAmount = 700m, AdjustmentAmount = -3m, CcmNote = "giữ nguyên",
});
await db.SaveChangesAsync(CancellationToken.None);
var handler = new UpdatePeBudgetCcmCommandHandler(db, AsRoles(AppRoles.Procurement));
await FluentActions.Awaiting(() => handler.Handle(
new UpdatePeBudgetCcmCommand(pe.Id, 9_999m, 1m, "không được ghi"), CancellationToken.None))
.Should().ThrowAsync<ForbiddenException>("chỉ CostControl | Admin được nhập ban hành/hiệu chỉnh + ghi chú CCM");
var rec = await db.PeWorkItemBudgets.AsNoTracking()
.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
rec.CcmNote.Should().Be("giữ nguyên", "Forbidden TRƯỚC side-effect → CcmNote giữ nguyên");
rec.InitialAmount.Should().Be(700m, "không field nào bị mutate khi Forbidden");
rec.AdjustmentAmount.Should().Be(-3m);
}
[Fact]
public async Task UpdateCcm_InitialAdjustmentAndCcmNote_AllPersistTogether()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-CCMN5");
var pe = await SeedPeAsync(db, project.Id, wi.Id);
var handler = new UpdatePeBudgetCcmCommandHandler(db, AsRoles(AppRoles.CostControl));
await handler.Handle(
new UpdatePeBudgetCcmCommand(pe.Id, 120_000_000m, -8_000_000m, "ban hành + hiệu chỉnh + lý do"),
CancellationToken.None);
var rec = await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
rec.InitialAmount.Should().Be(120_000_000m);
rec.AdjustmentAmount.Should().Be(-8_000_000m);
rec.CcmNote.Should().Be("ban hành + hiệu chỉnh + lý do", "cả 3 field persist trong 1 lệnh");
}
// ===================================================================== // =====================================================================
// 5. budgetSummary aggregates (GetPurchaseEvaluationQueryHandler) // 5. budgetSummary aggregates (GetPurchaseEvaluationQueryHandler)
// ===================================================================== // =====================================================================