[CLAUDE] PurchaseEvaluation: Plan AC — fix Lịch sử duyệt panel show Trả lại + Duyệt vượt cấp
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m27s

Bro UAT 2026-05-19 screenshot: panel "Lịch sử duyệt" KHÔNG show Return mode
events (Bro Trả lại từ Phan Văn Chương → Trà missing) + KHÔNG distinct
event Duyệt vượt cấp (skipToFinal F2).

Root cause:
- PurchaseEvaluationApprovals.Add() chỉ ở Approve branch (line 472 V2 + 660 V1)
- Reject branch line 75-103 NEVER adds Approval row — chỉ log Changelog
- skipToFinal advance branch line 532-572 dùng existing line 472 row nhưng
  comment KHÔNG distinct "vượt cấp" semantic vs approve thường

Fix Plan AC:

1. BE Service.cs Reject branch (line 75-103): capture pre-call Step/Level
   trước ApplyReturnModeAsync mutate pointer, add Approval row sau khi mutate:
   Decision=Reject + FromPhase + ToPhase=evaluation.Phase + Comment carry
   from-position + mode summary. Cover cả Trả lại (TraLai+pointer-mode) +
   Từ chối (TuChoi terminal).

2. BE Service.cs line 472 Approve branch: enrich Comment với prefix
   "[Duyệt vượt cấp tới Cấp cuối]" khi skipToFinal=true để Lịch sử duyệt
   distinguish vượt cấp với approve thường.

3. FE PeDetailTabs.tsx × 2 app ApprovalsTab: add Decision badge phân biệt
   Approve (emerald) / Trả lại (amber) / Từ chối (rose). Vì 3/4 mode Trả
   lại (OneLevel/OneStep/Assignee) giữ Phase=ChoDuyet → fromPhase→toPhase
   badge giống Approve. Decision badge bù visual phân biệt.

Verify:
- dotnet build clean 0 err 2 warn (pre-existing DocxRenderer)
- dotnet test 111/111 PASS
- npm build × fe-user + fe-admin PASS 0 TS err

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-19 10:45:25 +07:00
parent 8c05947176
commit a734bf2b8b
3 changed files with 105 additions and 38 deletions

View File

@ -1999,29 +1999,48 @@ function QuoteDialog({
}
// ===== Tab: Duyệt =====
// Plan AC S25 Bug 3 — Decision badge phân biệt Approve / Trả lại / Từ chối.
// 3/4 mode Trả lại (OneLevel/OneStep/Assignee) giữ Phase=ChoDuyet → fromPhase
// + toPhase badge giống hệt Approve. Decision badge bù visual phân biệt.
const PE_DECISION_REJECT = 2
function decisionBadge(decision: number, toPhase: number): { label: string; cls: string } {
if (decision === PE_DECISION_REJECT) {
// Reject phân biệt: TuChoi(99) = "Từ chối" / TraLai(98) hoặc ChoDuyet(10) = "Trả lại"
if (toPhase === 99) return { label: 'Từ chối', cls: 'bg-rose-100 text-rose-700 border border-rose-200' }
return { label: 'Trả lại', cls: 'bg-amber-100 text-amber-700 border border-amber-200' }
}
return { label: 'Duyệt', cls: 'bg-emerald-100 text-emerald-700 border border-emerald-200' }
}
function ApprovalsTab({ ev }: { ev: PeDetailBundle }) {
if (ev.approvals.length === 0) return <p className="text-sm text-slate-500">Chưa bước duyệt nào.</p>
return (
<ol className="space-y-2">
{ev.approvals.map(a => (
<li key={a.id} className="rounded border border-slate-200 bg-white p-3 text-sm">
<div className="flex items-center justify-between">
<div>
<span className={cn('rounded px-1.5 py-0.5 text-[11px]', PurchaseEvaluationPhaseColor[a.fromPhase])}>
{PurchaseEvaluationPhaseLabel[a.fromPhase]}
</span>
<span className="mx-2"></span>
<span className={cn('rounded px-1.5 py-0.5 text-[11px]', PurchaseEvaluationPhaseColor[a.toPhase])}>
{PurchaseEvaluationPhaseLabel[a.toPhase]}
</span>
{ev.approvals.map(a => {
const dec = decisionBadge(a.decision, a.toPhase)
return (
<li key={a.id} className="rounded border border-slate-200 bg-white p-3 text-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={cn('rounded px-1.5 py-0.5 text-[11px] font-medium', dec.cls)}>
{dec.label}
</span>
<span className={cn('rounded px-1.5 py-0.5 text-[11px]', PurchaseEvaluationPhaseColor[a.fromPhase])}>
{PurchaseEvaluationPhaseLabel[a.fromPhase]}
</span>
<span className="text-slate-400"></span>
<span className={cn('rounded px-1.5 py-0.5 text-[11px]', PurchaseEvaluationPhaseColor[a.toPhase])}>
{PurchaseEvaluationPhaseLabel[a.toPhase]}
</span>
</div>
<span className="text-xs text-slate-500">{new Date(a.approvedAt).toLocaleString('vi-VN')}</span>
</div>
<span className="text-xs text-slate-500">{new Date(a.approvedAt).toLocaleString('vi-VN')}</span>
</div>
<div className="mt-1 text-xs text-slate-500">
{a.approverName ?? 'Hệ thống'}{a.comment && ` · ${a.comment}`}
</div>
</li>
))}
<div className="mt-1 text-xs text-slate-500">
{a.approverName ?? 'Hệ thống'}{a.comment && ` · ${a.comment}`}
</div>
</li>
)
})}
</ol>
)
}

View File

@ -1993,29 +1993,48 @@ function QuoteDialog({
}
// ===== Tab: Duyệt =====
// Plan AC S25 Bug 3 — Decision badge phân biệt Approve / Trả lại / Từ chối.
// 3/4 mode Trả lại (OneLevel/OneStep/Assignee) giữ Phase=ChoDuyet → fromPhase
// + toPhase badge giống hệt Approve. Decision badge bù visual phân biệt.
const PE_DECISION_REJECT = 2
function decisionBadge(decision: number, toPhase: number): { label: string; cls: string } {
if (decision === PE_DECISION_REJECT) {
// Reject phân biệt: TuChoi(99) = "Từ chối" / TraLai(98) hoặc ChoDuyet(10) = "Trả lại"
if (toPhase === 99) return { label: 'Từ chối', cls: 'bg-rose-100 text-rose-700 border border-rose-200' }
return { label: 'Trả lại', cls: 'bg-amber-100 text-amber-700 border border-amber-200' }
}
return { label: 'Duyệt', cls: 'bg-emerald-100 text-emerald-700 border border-emerald-200' }
}
function ApprovalsTab({ ev }: { ev: PeDetailBundle }) {
if (ev.approvals.length === 0) return <p className="text-sm text-slate-500">Chưa bước duyệt nào.</p>
return (
<ol className="space-y-2">
{ev.approvals.map(a => (
<li key={a.id} className="rounded border border-slate-200 bg-white p-3 text-sm">
<div className="flex items-center justify-between">
<div>
<span className={cn('rounded px-1.5 py-0.5 text-[11px]', PurchaseEvaluationPhaseColor[a.fromPhase])}>
{PurchaseEvaluationPhaseLabel[a.fromPhase]}
</span>
<span className="mx-2"></span>
<span className={cn('rounded px-1.5 py-0.5 text-[11px]', PurchaseEvaluationPhaseColor[a.toPhase])}>
{PurchaseEvaluationPhaseLabel[a.toPhase]}
</span>
{ev.approvals.map(a => {
const dec = decisionBadge(a.decision, a.toPhase)
return (
<li key={a.id} className="rounded border border-slate-200 bg-white p-3 text-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={cn('rounded px-1.5 py-0.5 text-[11px] font-medium', dec.cls)}>
{dec.label}
</span>
<span className={cn('rounded px-1.5 py-0.5 text-[11px]', PurchaseEvaluationPhaseColor[a.fromPhase])}>
{PurchaseEvaluationPhaseLabel[a.fromPhase]}
</span>
<span className="text-slate-400"></span>
<span className={cn('rounded px-1.5 py-0.5 text-[11px]', PurchaseEvaluationPhaseColor[a.toPhase])}>
{PurchaseEvaluationPhaseLabel[a.toPhase]}
</span>
</div>
<span className="text-xs text-slate-500">{new Date(a.approvedAt).toLocaleString('vi-VN')}</span>
</div>
<span className="text-xs text-slate-500">{new Date(a.approvedAt).toLocaleString('vi-VN')}</span>
</div>
<div className="mt-1 text-xs text-slate-500">
{a.approverName ?? 'Hệ thống'}{a.comment && ` · ${a.comment}`}
</div>
</li>
))}
<div className="mt-1 text-xs text-slate-500">
{a.approverName ?? 'Hệ thống'}{a.comment && ` · ${a.comment}`}
</div>
</li>
)
})}
</ol>
)
}

View File

@ -80,6 +80,13 @@ public class PurchaseEvaluationWorkflowService(
// guard tránh request forge non-approver gọi PATCH direct.
await EnsureCanRejectV2Async(evaluation, actorUserId, isAdmin, ct);
// Plan AC S25 Bug 3a — capture pre-call Step/Level để log Approval row
// chính xác (ApplyReturnModeAsync mutate pointer cho 3 mode OneLevel/
// OneStep/Assignee). FE ApprovalsTab render `ev.approvals` — trước
// đây Reject KHÔNG add row → Lịch sử duyệt panel mất event Trả lại.
var fromStepIdx = evaluation.CurrentWorkflowStepIndex;
var fromLevelOrder = evaluation.CurrentApprovalLevelOrder;
if (targetPhase == PurchaseEvaluationPhase.TuChoi)
{
// Từ chối hoàn toàn — phiếu khoá vĩnh viễn (lock edit Mig 16).
@ -97,6 +104,25 @@ public class PurchaseEvaluationWorkflowService(
? returnSummary
: $"{comment} [{returnSummary}]";
}
// Plan AC S25 Bug 3a — add Approval row cho Lịch sử duyệt panel
// (FE ApprovalsTab). Decision=Reject + Comment carry mode + summary.
// From-position dùng pre-call Step/Level để show actor đã trả lại
// từ Bước/Cấp nào.
var fromPos = fromStepIdx.HasValue && fromLevelOrder.HasValue
? $"[Bước {fromStepIdx.Value + 1} — Cấp {fromLevelOrder.Value}] "
: "";
db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval
{
PurchaseEvaluationId = evaluation.Id,
FromPhase = fromPhase,
ToPhase = evaluation.Phase,
ApproverUserId = actorUserId,
Decision = ApprovalDecision.Reject,
Comment = $"{fromPos}{comment ?? ""}".Trim(),
ApprovedAt = dateTime.UtcNow,
});
await LogTransitionAsync(evaluation, fromPhase, evaluation.Phase, actorUserId, decision, comment, ct);
await db.SaveChangesAsync(ct);
return;
@ -468,7 +494,10 @@ public class PurchaseEvaluationWorkflowService(
}
}
// Log approval
// Log approval. Plan AC S25 Bug 3b — enrich comment với prefix
// "[Duyệt vượt cấp]" khi skipToFinal=true để FE Lịch sử duyệt panel
// phân biệt rõ event vượt cấp với approve thường.
var skipPrefix = skipToFinal ? "[Duyệt vượt cấp tới Cấp cuối] " : "";
db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval
{
PurchaseEvaluationId = evaluation.Id,
@ -476,7 +505,7 @@ public class PurchaseEvaluationWorkflowService(
ToPhase = evaluation.Phase,
ApproverUserId = actorUserId,
Decision = ApprovalDecision.Approve,
Comment = $"[Bước {currentIdx + 1} — Cấp {currentLevelOrder}] {comment ?? ""}",
Comment = $"{skipPrefix}[Bước {currentIdx + 1} — Cấp {currentLevelOrder}] {comment ?? ""}".Trim(),
ApprovedAt = dateTime.UtcNow,
});