[CLAUDE] PurchaseEvaluation: Plan AB Chunk A — fix Changelog visibility Bug 1 Budget Adjust + Bug 2 Return Mode
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Failing after 1m6s

- BE ApplyReturnModeAsync 4 mode add Changelog.Add() common path (refactor Drafter early return)
- FE PeDetailTabs.tsx HistoryTab filter extend cover Header+ngân sách (B1) + Workflow+Trả lại (B2)
- FE empty placeholder + comment update reflect new filter scope
- Mirror 2 app §3.9

Bug 1: Budget Adjust handler đã log (Header+Update) nhưng FE filter strict TraLai-only
Bug 2: Return mode Service không log Changelog — chỉ approval phase transition

Verify:
- Build clean 0 err
- npm build × 2 app pass 0 TS err
- 111 test baseline preserve (UAT skip test-after defer)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-19 10:07:44 +07:00
parent e23f51c42e
commit cdfd54212c
3 changed files with 146 additions and 95 deletions

View File

@ -2033,24 +2033,31 @@ function HistoryTab({ ev }: { ev: PeDetailBundle }) {
queryFn: async () => (await api.get<PeChangelog[]>(`/purchase-evaluations/${ev.id}/changelogs`)).data, queryFn: async () => (await api.get<PeChangelog[]>(`/purchase-evaluations/${ev.id}/changelogs`)).data,
}) })
if (logs.isLoading) return <p className="text-sm text-slate-500">Đang tải</p> if (logs.isLoading) return <p className="text-sm text-slate-500">Đang tải</p>
// User UAT 2026-05-08: chỉ track events liên quan Trả lại + Gửi duyệt lại. // User UAT 2026-05-08: chỉ track events Trả lại + Gửi duyệt lại.
// Bỏ trạng thái duyệt (Cấp 1 → Cấp 2 → DaDuyet) + bỏ thay đổi trước Trả lại. // User UAT 2026-05-19: + track Budget Adjust (Bug 1) + 4 mode Trả lại (Bug 2).
// Filter giữ: // Filter giữ:
// - Workflow transition về TraLai (phaseAtChange = TraLai = 98) // - Workflow transition về TraLai (phaseAtChange = TraLai = 98)
// - Workflow transition từ TraLai → khác (Drafter gửi lại — summary chứa "TraLai →") // - Workflow transition từ TraLai → khác (Drafter gửi lại — summary "TraLai →")
// - Mọi thay đổi nội dung khi phaseAtChange = TraLai (sửa trong giai đoạn chờ gửi lại) // - Workflow Trả lại 4 mode (summary chứa "Trả lại" — Plan AB S25 fix Bug 2)
// - Header Budget Adjust (summary chứa "ngân sách" — Plan AB S25 fix Bug 1)
// - Mọi thay đổi nội dung khi phaseAtChange = TraLai (Drafter sửa trước gửi lại)
// BE giữ data đầy đủ (audit trail) — chỉ filter ở UI, reversible. // BE giữ data đầy đủ (audit trail) — chỉ filter ở UI, reversible.
const PE_PHASE_TRALAI = 98 const PE_PHASE_TRALAI = 98
const PE_ENTITY_WORKFLOW = 5 const PE_ENTITY_WORKFLOW = 5
const PE_ENTITY_HEADER = 1
const filtered = (logs.data ?? []).filter(l => { const filtered = (logs.data ?? []).filter(l => {
if (l.entityType === PE_ENTITY_WORKFLOW) { if (l.entityType === PE_ENTITY_WORKFLOW) {
if (l.phaseAtChange === PE_PHASE_TRALAI) return true if (l.phaseAtChange === PE_PHASE_TRALAI) return true
if (l.summary?.includes('TraLai →')) return true if (l.summary?.includes('TraLai →')) return true
if (l.summary?.includes('Trả lại')) return true
return false return false
} }
if (l.entityType === PE_ENTITY_HEADER && l.summary?.toLowerCase().includes('ngân sách')) {
return true
}
return l.phaseAtChange === PE_PHASE_TRALAI return l.phaseAtChange === PE_PHASE_TRALAI
}) })
if (filtered.length === 0) return <p className="text-sm text-slate-500">Chưa lịch sử trả lại / gửi duyệt lại.</p> if (filtered.length === 0) return <p className="text-sm text-slate-500">Chưa lịch sử trả lại / điều chỉnh ngân sách / gửi duyệt lại.</p>
return ( return (
<ol className="space-y-1.5 text-sm"> <ol className="space-y-1.5 text-sm">
{filtered.map(l => ( {filtered.map(l => (

View File

@ -2027,24 +2027,31 @@ function HistoryTab({ ev }: { ev: PeDetailBundle }) {
queryFn: async () => (await api.get<PeChangelog[]>(`/purchase-evaluations/${ev.id}/changelogs`)).data, queryFn: async () => (await api.get<PeChangelog[]>(`/purchase-evaluations/${ev.id}/changelogs`)).data,
}) })
if (logs.isLoading) return <p className="text-sm text-slate-500">Đang tải</p> if (logs.isLoading) return <p className="text-sm text-slate-500">Đang tải</p>
// User UAT 2026-05-08: chỉ track events liên quan Trả lại + Gửi duyệt lại. // User UAT 2026-05-08: chỉ track events Trả lại + Gửi duyệt lại.
// Bỏ trạng thái duyệt (Cấp 1 → Cấp 2 → DaDuyet) + bỏ thay đổi trước Trả lại. // User UAT 2026-05-19: + track Budget Adjust (Bug 1) + 4 mode Trả lại (Bug 2).
// Filter giữ: // Filter giữ:
// - Workflow transition về TraLai (phaseAtChange = TraLai = 98) // - Workflow transition về TraLai (phaseAtChange = TraLai = 98)
// - Workflow transition từ TraLai → khác (Drafter gửi lại — summary chứa "TraLai →") // - Workflow transition từ TraLai → khác (Drafter gửi lại — summary "TraLai →")
// - Mọi thay đổi nội dung khi phaseAtChange = TraLai (sửa trong giai đoạn chờ gửi lại) // - Workflow Trả lại 4 mode (summary chứa "Trả lại" — Plan AB S25 fix Bug 2)
// - Header Budget Adjust (summary chứa "ngân sách" — Plan AB S25 fix Bug 1)
// - Mọi thay đổi nội dung khi phaseAtChange = TraLai (Drafter sửa trước gửi lại)
// BE giữ data đầy đủ (audit trail) — chỉ filter ở UI, reversible. // BE giữ data đầy đủ (audit trail) — chỉ filter ở UI, reversible.
const PE_PHASE_TRALAI = 98 const PE_PHASE_TRALAI = 98
const PE_ENTITY_WORKFLOW = 5 const PE_ENTITY_WORKFLOW = 5
const PE_ENTITY_HEADER = 1
const filtered = (logs.data ?? []).filter(l => { const filtered = (logs.data ?? []).filter(l => {
if (l.entityType === PE_ENTITY_WORKFLOW) { if (l.entityType === PE_ENTITY_WORKFLOW) {
if (l.phaseAtChange === PE_PHASE_TRALAI) return true if (l.phaseAtChange === PE_PHASE_TRALAI) return true
if (l.summary?.includes('TraLai →')) return true if (l.summary?.includes('TraLai →')) return true
if (l.summary?.includes('Trả lại')) return true
return false return false
} }
if (l.entityType === PE_ENTITY_HEADER && l.summary?.toLowerCase().includes('ngân sách')) {
return true
}
return l.phaseAtChange === PE_PHASE_TRALAI return l.phaseAtChange === PE_PHASE_TRALAI
}) })
if (filtered.length === 0) return <p className="text-sm text-slate-500">Chưa lịch sử trả lại / gửi duyệt lại.</p> if (filtered.length === 0) return <p className="text-sm text-slate-500">Chưa lịch sử trả lại / điều chỉnh ngân sách / gửi duyệt lại.</p>
return ( return (
<ol className="space-y-1.5 text-sm"> <ol className="space-y-1.5 text-sm">
{filtered.map(l => ( {filtered.map(l => (

View File

@ -276,104 +276,141 @@ public class PurchaseEvaluationWorkflowService(
$"Cấp Approver hiện tại không bật mode '{mode}'. Liên hệ Admin Designer để config Level slot."); $"Cấp Approver hiện tại không bật mode '{mode}'. Liên hệ Admin Designer để config Level slot.");
} }
// Mode Drafter — Session 17 default (Phase=TraLai clear pointer) var summary = string.Empty;
// Mode Drafter — Session 17 default (Phase=TraLai clear pointer).
// Plan AB S25 — KHÔNG return early, fallthrough vào log block dưới cuối để
// Changelog uniform 4 mode (Bug 2 fix: trước chỉ TransitionAsync log phase
// transition generic, không log return mode detail → FE History tab miss).
if (mode == WorkflowReturnMode.Drafter) if (mode == WorkflowReturnMode.Drafter)
{ {
evaluation.Phase = PurchaseEvaluationPhase.TraLai; evaluation.Phase = PurchaseEvaluationPhase.TraLai;
evaluation.CurrentWorkflowStepIndex = null; evaluation.CurrentWorkflowStepIndex = null;
evaluation.CurrentApprovalLevelOrder = null; evaluation.CurrentApprovalLevelOrder = null;
evaluation.SlaDeadline = null; evaluation.SlaDeadline = null;
return "Trả về Người soạn thảo"; summary = "Trả về Người soạn thảo";
} }
else
// 3 mode còn lại — yêu cầu pointer hợp lệ
if (evaluation.CurrentWorkflowStepIndex is not int curStepIdx
|| evaluation.CurrentApprovalLevelOrder is not int curLevel)
throw new ConflictException(
$"Mode '{mode}' yêu cầu phiếu đang ChoDuyet + pointer init. " +
$"State hiện tại: Step={evaluation.CurrentWorkflowStepIndex}, Level={evaluation.CurrentApprovalLevelOrder}.");
var summary = string.Empty;
switch (mode)
{ {
case WorkflowReturnMode.OneLevel: // 3 mode còn lại — yêu cầu pointer hợp lệ
// Lùi 1 Cấp trong cùng Step. Nếu đang Cấp 1 → lùi sang Bước trước if (evaluation.CurrentWorkflowStepIndex is not int curStepIdx
// Cấp cuối. Nếu đang Bước 1 Cấp 1 → reset về (0, 1) giữ ChoDuyet || evaluation.CurrentApprovalLevelOrder is not int curLevel)
// (Plan M S23 t3 — KHÔNG fallback Drafter, phiếu giữ "đang duyệt"). throw new ConflictException(
if (curLevel > 1) $"Mode '{mode}' yêu cầu phiếu đang ChoDuyet + pointer init. " +
{ $"State hiện tại: Step={evaluation.CurrentWorkflowStepIndex}, Level={evaluation.CurrentApprovalLevelOrder}.");
evaluation.CurrentApprovalLevelOrder = curLevel - 1;
summary = $"Trả về Cấp {curLevel - 1} (cùng Bước {stepsOrdered[curStepIdx].Order})";
}
else if (curStepIdx > 0)
{
var prevStep = stepsOrdered[curStepIdx - 1];
var prevMaxLevel = prevStep.Levels.OrderBy(l => l.Order).Last().Order;
evaluation.CurrentWorkflowStepIndex = curStepIdx - 1;
evaluation.CurrentApprovalLevelOrder = prevMaxLevel;
summary = $"Trả về Bước {prevStep.Order} Cấp {prevMaxLevel} (Bước trước)";
}
else
{
// Bước 1 Cấp 1 → reset về (0, 1) giữ Phase=ChoDuyet (no-op effective
// — Approver A hiện tại). Audit log rõ "không lùi được". SLA reset
// dưới cuối hàm cho approver giữ nguyên.
evaluation.CurrentWorkflowStepIndex = 0;
evaluation.CurrentApprovalLevelOrder = 1;
summary = "Action 'Trả lại 1 Cấp' không lùi được — phiếu reset về Approver Bước 1 Cấp 1";
}
break;
case WorkflowReturnMode.OneStep: switch (mode)
// Lùi sang Bước trước, set Level = max của Bước đó. {
if (curStepIdx > 0) case WorkflowReturnMode.OneLevel:
{ // Lùi 1 Cấp trong cùng Step. Nếu đang Cấp 1 → lùi sang Bước trước
var prevStep = stepsOrdered[curStepIdx - 1]; // Cấp cuối. Nếu đang Bước 1 Cấp 1 → reset về (0, 1) giữ ChoDuyet
var prevMaxLevel = prevStep.Levels.OrderBy(l => l.Order).Last().Order; // (Plan M S23 t3 — KHÔNG fallback Drafter, phiếu giữ "đang duyệt").
evaluation.CurrentWorkflowStepIndex = curStepIdx - 1; if (curLevel > 1)
evaluation.CurrentApprovalLevelOrder = prevMaxLevel;
summary = $"Trả về Bước {prevStep.Order} Cấp {prevMaxLevel}";
}
else
{
// Bước 1 → reset về (0, 1) giữ Phase=ChoDuyet (Plan M S23 t3 —
// KHÔNG fallback Drafter, phiếu giữ "đang duyệt").
evaluation.CurrentWorkflowStepIndex = 0;
evaluation.CurrentApprovalLevelOrder = 1;
summary = "Action 'Trả lại 1 Bước' không lùi được — phiếu reset về Approver Bước 1 Cấp 1";
}
break;
case WorkflowReturnMode.Assignee:
if (returnTargetUserId is not Guid targetUid)
throw new ConflictException("returnTargetUserId yêu cầu khi mode=Assignee.");
var foundStepIdx = -1;
int foundLevel = -1;
string? foundStepName = null;
for (int si = 0; si < stepsOrdered.Count; si++)
{
var match = stepsOrdered[si].Levels
.FirstOrDefault(l => l.ApproverUserId == targetUid);
if (match is not null)
{ {
foundStepIdx = si; evaluation.CurrentApprovalLevelOrder = curLevel - 1;
foundLevel = match.Order; summary = $"Trả về Cấp {curLevel - 1} (cùng Bước {stepsOrdered[curStepIdx].Order})";
foundStepName = stepsOrdered[si].Name;
break;
} }
} else if (curStepIdx > 0)
if (foundStepIdx < 0) {
throw new ConflictException( var prevStep = stepsOrdered[curStepIdx - 1];
"Không tìm thấy người chỉ định trong workflow. " + var prevMaxLevel = prevStep.Levels.OrderBy(l => l.Order).Last().Order;
"Chỉ pick từ list NV đã duyệt trước đó (PeLevelOpinions)."); evaluation.CurrentWorkflowStepIndex = curStepIdx - 1;
evaluation.CurrentWorkflowStepIndex = foundStepIdx; evaluation.CurrentApprovalLevelOrder = prevMaxLevel;
evaluation.CurrentApprovalLevelOrder = foundLevel; summary = $"Trả về Bước {prevStep.Order} Cấp {prevMaxLevel} (Bước trước)";
summary = $"Trả về Người chỉ định — Bước {stepsOrdered[foundStepIdx].Order} ({foundStepName}) Cấp {foundLevel}"; }
break; else
{
// Bước 1 Cấp 1 → reset về (0, 1) giữ Phase=ChoDuyet (no-op effective
// — Approver A hiện tại). Audit log rõ "không lùi được". SLA reset
// dưới cuối hàm cho approver giữ nguyên.
evaluation.CurrentWorkflowStepIndex = 0;
evaluation.CurrentApprovalLevelOrder = 1;
summary = "Action 'Trả lại 1 Cấp' không lùi được — phiếu reset về Approver Bước 1 Cấp 1";
}
break;
case WorkflowReturnMode.OneStep:
// Lùi sang Bước trước, set Level = max của Bước đó.
if (curStepIdx > 0)
{
var prevStep = stepsOrdered[curStepIdx - 1];
var prevMaxLevel = prevStep.Levels.OrderBy(l => l.Order).Last().Order;
evaluation.CurrentWorkflowStepIndex = curStepIdx - 1;
evaluation.CurrentApprovalLevelOrder = prevMaxLevel;
summary = $"Trả về Bước {prevStep.Order} Cấp {prevMaxLevel}";
}
else
{
// Bước 1 → reset về (0, 1) giữ Phase=ChoDuyet (Plan M S23 t3 —
// KHÔNG fallback Drafter, phiếu giữ "đang duyệt").
evaluation.CurrentWorkflowStepIndex = 0;
evaluation.CurrentApprovalLevelOrder = 1;
summary = "Action 'Trả lại 1 Bước' không lùi được — phiếu reset về Approver Bước 1 Cấp 1";
}
break;
case WorkflowReturnMode.Assignee:
if (returnTargetUserId is not Guid targetUid)
throw new ConflictException("returnTargetUserId yêu cầu khi mode=Assignee.");
var foundStepIdx = -1;
int foundLevel = -1;
string? foundStepName = null;
for (int si = 0; si < stepsOrdered.Count; si++)
{
var match = stepsOrdered[si].Levels
.FirstOrDefault(l => l.ApproverUserId == targetUid);
if (match is not null)
{
foundStepIdx = si;
foundLevel = match.Order;
foundStepName = stepsOrdered[si].Name;
break;
}
}
if (foundStepIdx < 0)
throw new ConflictException(
"Không tìm thấy người chỉ định trong workflow. " +
"Chỉ pick từ list NV đã duyệt trước đó (PeLevelOpinions).");
evaluation.CurrentWorkflowStepIndex = foundStepIdx;
evaluation.CurrentApprovalLevelOrder = foundLevel;
summary = $"Trả về Người chỉ định — Bước {stepsOrdered[foundStepIdx].Order} ({foundStepName}) Cấp {foundLevel}";
break;
}
// 3 mode trên đều giữ Phase=ChoDuyet — reset SLA cho approver mới.
evaluation.SlaDeadline = dateTime.UtcNow.AddDays(7);
} }
// 3 mode trên đều giữ Phase=ChoDuyet — reset SLA cho approver mới. // Plan AB S25 Bug 2 fix — single Changelog.Add() cover all 4 mode uniform.
evaluation.SlaDeadline = dateTime.UtcNow.AddDays(7); // EntityType=Workflow + Action=Update để FE History filter discriminate qua
// summary substring "Trả lại" (Workflow entity + summary != phase-transition
// pattern "Phase X → Y" — distinct từ TransitionAsync caller line 100).
// KHÔNG SaveChangesAsync call mới — TransitionAsync caller có downstream save.
var modeName = mode switch
{
WorkflowReturnMode.Drafter => "Người soạn thảo",
WorkflowReturnMode.OneLevel => "1 Cấp",
WorkflowReturnMode.OneStep => "1 Bước",
WorkflowReturnMode.Assignee => "Người chỉ định",
_ => mode.ToString(),
};
string? actorName = null;
if (actorUserId is Guid actorUid)
{
var actor = await userManager.FindByIdAsync(actorUid.ToString());
actorName = actor?.FullName ?? actor?.Email;
}
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
PurchaseEvaluationId = evaluation.Id,
EntityType = PurchaseEvaluationEntityType.Workflow,
Action = ChangelogAction.Update,
PhaseAtChange = evaluation.Phase,
UserId = actorUserId,
UserName = actorName ?? "Hệ thống",
Summary = $"Trả lại ({modeName}): {summary}",
});
return summary; return summary;
} }