[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
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:
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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=
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user