diff --git a/fe-admin/src/components/pe/PeDetailTabs.tsx b/fe-admin/src/components/pe/PeDetailTabs.tsx index e24fdab..e73669b 100644 --- a/fe-admin/src/components/pe/PeDetailTabs.tsx +++ b/fe-admin/src/components/pe/PeDetailTabs.tsx @@ -2033,24 +2033,31 @@ function HistoryTab({ ev }: { ev: PeDetailBundle }) { queryFn: async () => (await api.get(`/purchase-evaluations/${ev.id}/changelogs`)).data, }) if (logs.isLoading) return

Đang tải…

- // User UAT 2026-05-08: chỉ track events liên quan 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-08: chỉ track events Trả lại + Gửi duyệt lại. + // User UAT 2026-05-19: + track Budget Adjust (Bug 1) + 4 mode Trả lại (Bug 2). // Filter giữ: // - Workflow transition về TraLai (phaseAtChange = TraLai = 98) - // - Workflow transition từ TraLai → khác (Drafter gửi lại — summary chứa "TraLai →") - // - Mọi thay đổi nội dung khi phaseAtChange = TraLai (sửa trong giai đoạn chờ gửi lại) + // - Workflow transition từ TraLai → khác (Drafter gửi lại — summary "TraLai →") + // - 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. const PE_PHASE_TRALAI = 98 const PE_ENTITY_WORKFLOW = 5 + const PE_ENTITY_HEADER = 1 const filtered = (logs.data ?? []).filter(l => { if (l.entityType === PE_ENTITY_WORKFLOW) { if (l.phaseAtChange === PE_PHASE_TRALAI) return true if (l.summary?.includes('TraLai →')) return true + if (l.summary?.includes('Trả lại')) return true return false } + if (l.entityType === PE_ENTITY_HEADER && l.summary?.toLowerCase().includes('ngân sách')) { + return true + } return l.phaseAtChange === PE_PHASE_TRALAI }) - if (filtered.length === 0) return

Chưa có lịch sử trả lại / gửi duyệt lại.

+ if (filtered.length === 0) return

Chưa có lịch sử trả lại / điều chỉnh ngân sách / gửi duyệt lại.

return (
    {filtered.map(l => ( diff --git a/fe-user/src/components/pe/PeDetailTabs.tsx b/fe-user/src/components/pe/PeDetailTabs.tsx index e856560..4413698 100644 --- a/fe-user/src/components/pe/PeDetailTabs.tsx +++ b/fe-user/src/components/pe/PeDetailTabs.tsx @@ -2027,24 +2027,31 @@ function HistoryTab({ ev }: { ev: PeDetailBundle }) { queryFn: async () => (await api.get(`/purchase-evaluations/${ev.id}/changelogs`)).data, }) if (logs.isLoading) return

    Đang tải…

    - // User UAT 2026-05-08: chỉ track events liên quan 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-08: chỉ track events Trả lại + Gửi duyệt lại. + // User UAT 2026-05-19: + track Budget Adjust (Bug 1) + 4 mode Trả lại (Bug 2). // Filter giữ: // - Workflow transition về TraLai (phaseAtChange = TraLai = 98) - // - Workflow transition từ TraLai → khác (Drafter gửi lại — summary chứa "TraLai →") - // - Mọi thay đổi nội dung khi phaseAtChange = TraLai (sửa trong giai đoạn chờ gửi lại) + // - Workflow transition từ TraLai → khác (Drafter gửi lại — summary "TraLai →") + // - 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. const PE_PHASE_TRALAI = 98 const PE_ENTITY_WORKFLOW = 5 + const PE_ENTITY_HEADER = 1 const filtered = (logs.data ?? []).filter(l => { if (l.entityType === PE_ENTITY_WORKFLOW) { if (l.phaseAtChange === PE_PHASE_TRALAI) return true if (l.summary?.includes('TraLai →')) return true + if (l.summary?.includes('Trả lại')) return true return false } + if (l.entityType === PE_ENTITY_HEADER && l.summary?.toLowerCase().includes('ngân sách')) { + return true + } return l.phaseAtChange === PE_PHASE_TRALAI }) - if (filtered.length === 0) return

    Chưa có lịch sử trả lại / gửi duyệt lại.

    + if (filtered.length === 0) return

    Chưa có lịch sử trả lại / điều chỉnh ngân sách / gửi duyệt lại.

    return (
      {filtered.map(l => ( diff --git a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs index 597ae0f..361b896 100644 --- a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs +++ b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs @@ -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."); } - // 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) { evaluation.Phase = PurchaseEvaluationPhase.TraLai; evaluation.CurrentWorkflowStepIndex = null; evaluation.CurrentApprovalLevelOrder = null; evaluation.SlaDeadline = null; - return "Trả về Người soạn thảo"; + summary = "Trả về Người soạn thảo"; } - - // 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) + else { - 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 - // Cấp cuối. Nếu đang Bước 1 Cấp 1 → reset về (0, 1) giữ ChoDuyet - // (Plan M S23 t3 — KHÔNG fallback Drafter, phiếu giữ "đang duyệt"). - if (curLevel > 1) - { - 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; + // 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}."); - 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) + switch (mode) + { + 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 + // Cấp cuối. Nếu đang Bước 1 Cấp 1 → reset về (0, 1) giữ ChoDuyet + // (Plan M S23 t3 — KHÔNG fallback Drafter, phiếu giữ "đang duyệt"). + if (curLevel > 1) { - foundStepIdx = si; - foundLevel = match.Order; - foundStepName = stepsOrdered[si].Name; - break; + evaluation.CurrentApprovalLevelOrder = curLevel - 1; + summary = $"Trả về Cấp {curLevel - 1} (cùng Bước {stepsOrdered[curStepIdx].Order})"; } - } - 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; + 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: + // 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. - evaluation.SlaDeadline = dateTime.UtcNow.AddDays(7); + // Plan AB S25 Bug 2 fix — single Changelog.Add() cover all 4 mode uniform. + // 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; }