[CLAUDE] PE-Workflow: Chunk B — BE Service + handlers + DTOs (F1+F2+F3)

F1 — 4 mode Trả lại (Service.ApplyReturnModeAsync helper):
- WorkflowReturnMode enum (OneLevel / OneStep / Assignee / Drafter)
- OneLevel: lùi 1 Cấp trong cùng Step (peer review). Bước 1 Cấp 1 → fallback Drafter.
- OneStep: lùi sang Bước trước Cấp cuối. Bước 1 → fallback Drafter.
- Assignee: pick runtime → tìm Step+Level match ApproverUserId trong workflow.
- Drafter: Phase=TraLai clear pointer như S17 (backward compat).
- 3 mode đầu giữ Phase=ChoDuyet, reset SLA 7d. Mode Drafter clear SLA.
- Admin bypass workflow.Allow* flag check. Non-admin → throw ConflictException
  với message rõ "Workflow không bật mode X".

F2 — Drafter skipToFinal (extend DRAFTER trình branch):
- Workflow.AllowDrafterSkipToFinal=true required (non-admin)
- Set CurrentWorkflowStepIndex = Steps.Count-1 + CurrentApprovalLevelOrder = max Level
- Audit comment append "[Drafter gửi thẳng Cấp cuối]"

F3 — Approver edit Section 2 (Detail + NCC + Báo giá):
- New helper `EnsureEditableForDetailsAsync` (extend pattern PurchaseEvaluationDraftGuard):
  - Drafter scope: DangSoanThao OR TraLai (any role, Controller [Authorize] handles)
  - F3 Approver scope: ChoDuyet + workflow.AllowApproverEditDetails=true +
    actor.Id match CurrentLevel.ApproverUserId. Admin bypass flag check.
  - Throw ForbiddenException nếu approver Cấp khác nhau (rõ Bước/Cấp trong message).
- 8 handler switch helper + inject ICurrentUser khi cần:
  - Detail: Add (existing ICurrentUser) / Update + Delete (inject new)
  - Quote: Upsert + Delete (inject new)
  - Supplier: Add (existing) / Update + Delete (inject new + add guard, trước
    đây hoàn toàn KHÔNG có phase guard — bonus security fix)
- Audit: thêm changelog Update/Delete handler (trước đây silent). Khi phase=
  ChoDuyet append " [Approver edit khi đang duyệt]" cho lịch sử rõ ai sửa.

Extension Service `TransitionAsync` signature (backward compat — 3 optional
param thêm cuối + default null/false):
- WorkflowReturnMode? returnMode = null
- Guid? returnTargetUserId = null
- bool skipToFinal = false

TransitionPurchaseEvaluationCommand DTO + Validator + Handler — mirror signature.

DTO extensions:
- ApprovalWorkflowOptionsDto NEW sub-record (6 Allow* flag) cho FE filter
- PurchaseEvaluationDetailBundleDto + WorkflowOptions field (null nếu V1 legacy)
- GetPe handler populate awOptions từ ApprovalWorkflow entity load (Mig 23 path)
- AwDefinitionDto + 6 Allow* field (admin Designer GET overview)
- CreateAwDefinitionCommand + 6 Allow* param (admin Designer POST new version)
- Handler ToDto + entity new() — propagate Allow* end-to-end

Default backward compat: workflow cũ → AllowReturnToDrafter=true (Mig 28 DB
default), 5 flag còn lại false. Phiếu cũ V2 vẫn Trả lại Drafter như S17 sau
deploy — no breaking change.

Verify:
- dotnet build SolutionErp.slnx → 0 err, 2 warn pre-existing DocxRenderer
- 3 regression test gotcha #45 vẫn PASS (backward compat signature change)
- LocalDB Dev + Design đã apply Mig 28 (Chunk A)

Pending Chunk C: FE Admin Designer mirror 2 app (6 checkbox + DTO types).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-13 18:57:09 +07:00
parent 0294693a4a
commit c56024ba25
7 changed files with 478 additions and 29 deletions

View File

@ -41,6 +41,9 @@ public class PurchaseEvaluationWorkflowService(
IReadOnlyList<string> actorRoles,
ApprovalDecision decision,
string? comment,
WorkflowReturnMode? returnMode = null,
Guid? returnTargetUserId = null,
bool skipToFinal = false,
CancellationToken ct = default)
{
var fromPhase = evaluation.Phase;
@ -67,23 +70,26 @@ public class PurchaseEvaluationWorkflowService(
"(xem gotcha #45 + docs/workflow-contract.md).");
}
// ===== REJECT BRANCH =====
// ===== REJECT BRANCH (extended Mig 28 — F1 multi-mode Trả lại) =====
if (decision == ApprovalDecision.Reject)
{
if (targetPhase == PurchaseEvaluationPhase.TuChoi)
{
// Từ chối hoàn toàn — phiếu khoá vĩnh viễn (lock edit Mig 16).
evaluation.Phase = PurchaseEvaluationPhase.TuChoi;
evaluation.SlaDeadline = null;
}
else
{
// Trả lại — Phase=TraLai RIÊNG (không revert về DangSoanThao).
// Drafter sửa từ TraLai rồi gửi lại sẽ chạy lại từ Cấp 1 Bước 1.
evaluation.Phase = PurchaseEvaluationPhase.TraLai;
evaluation.CurrentWorkflowStepIndex = null;
evaluation.CurrentApprovalLevelOrder = null;
// F1 (S21 t4) — 4 mode Trả lại theo workflow.Allow* flag.
// Default fallback (returnMode=null) = Drafter mode = S17 behavior.
var effectiveMode = returnMode ?? WorkflowReturnMode.Drafter;
var returnSummary = await ApplyReturnModeAsync(
evaluation, effectiveMode, returnTargetUserId, isAdmin, ct);
comment = string.IsNullOrWhiteSpace(comment)
? returnSummary
: $"{comment} [{returnSummary}]";
}
evaluation.SlaDeadline = null;
await LogTransitionAsync(evaluation, fromPhase, evaluation.Phase, actorUserId, decision, comment, ct);
await db.SaveChangesAsync(ct);
return;
@ -104,9 +110,36 @@ public class PurchaseEvaluationWorkflowService(
$"Role ({string.Join(",", actorRoles)}) không đủ quyền trình duyệt phiếu.");
}
evaluation.Phase = PurchaseEvaluationPhase.ChoDuyet;
evaluation.CurrentWorkflowStepIndex = 0;
// Chỉ init levelOrder=1 nếu pin schema V2 (ApprovalWorkflowId set).
evaluation.CurrentApprovalLevelOrder = evaluation.ApprovalWorkflowId is not null ? 1 : null;
// F2 (Mig 28 — S21 t4) — Drafter skip thẳng Cấp cuối. Workflow phải
// AllowDrafterSkipToFinal=true. Set pointer = max Step + max Level.
// Audit changelog ghi rõ "Drafter skip" để approver Cấp cuối biết.
if (skipToFinal && evaluation.ApprovalWorkflowId is Guid skipAwId)
{
var wfSkip = await db.ApprovalWorkflows
.Include(w => w.Steps).ThenInclude(s => s.Levels)
.FirstOrDefaultAsync(w => w.Id == skipAwId, ct)
?? throw new ConflictException("Workflow không tồn tại.");
if (!wfSkip.AllowDrafterSkipToFinal)
throw new ConflictException(
"Workflow không bật mode 'Gửi thẳng Cấp cuối'. " +
"Liên hệ Admin để config Designer.");
var finalStep = wfSkip.Steps.OrderBy(s => s.Order).LastOrDefault()
?? throw new ConflictException("Workflow chưa có Bước nào.");
var finalLevelOrder = finalStep.Levels.OrderBy(l => l.Order).LastOrDefault()?.Order
?? throw new ConflictException($"Bước {finalStep.Order} chưa có Cấp nào.");
evaluation.CurrentWorkflowStepIndex = wfSkip.Steps.Count - 1; // 0-based last step
evaluation.CurrentApprovalLevelOrder = finalLevelOrder;
comment = string.IsNullOrWhiteSpace(comment)
? "[Drafter gửi thẳng Cấp cuối — skip Bước/Cấp trung gian]"
: $"{comment} [Drafter gửi thẳng Cấp cuối — skip Bước/Cấp trung gian]";
}
else
{
evaluation.CurrentWorkflowStepIndex = 0;
// Chỉ init levelOrder=1 nếu pin schema V2 (ApprovalWorkflowId set).
evaluation.CurrentApprovalLevelOrder = evaluation.ApprovalWorkflowId is not null ? 1 : null;
}
evaluation.SlaDeadline = dateTime.UtcNow.AddDays(7);
await LogTransitionAsync(evaluation, fromPhase, PurchaseEvaluationPhase.ChoDuyet, actorUserId, decision, comment, ct);
await db.SaveChangesAsync(ct);
@ -144,6 +177,154 @@ public class PurchaseEvaluationWorkflowService(
throw new ConflictException($"Transition {fromPhase} → {targetPhase} không hỗ trợ.");
}
// ===== F1 (Mig 28 — S21 t4) — Apply Return Mode =====
// Switch theo effectiveMode → set Phase + pointer. 3 mode đầu giữ ChoDuyet
// (peer review chain). Mode Drafter set Phase=TraLai như S17.
// Validate workflow.Allow* flag match mode → throw nếu disabled.
// Return summary text để chèn vào comment changelog (audit trail).
private async Task<string> ApplyReturnModeAsync(
PurchaseEvaluation evaluation,
WorkflowReturnMode mode,
Guid? returnTargetUserId,
bool isAdmin,
CancellationToken ct)
{
// Mode Drafter — Session 17 default (always allowed for backward compat,
// workflow.AllowReturnToDrafter default true).
if (mode == WorkflowReturnMode.Drafter)
{
// Validate workflow flag (admin có thể disable mode này force peer review)
if (evaluation.ApprovalWorkflowId is Guid awId0 && !isAdmin)
{
var wf0 = await db.ApprovalWorkflows.FirstOrDefaultAsync(w => w.Id == awId0, ct);
if (wf0 is not null && !wf0.AllowReturnToDrafter)
throw new ConflictException(
"Workflow không bật mode 'Trả về Drafter'. Phải dùng mode khác.");
}
evaluation.Phase = PurchaseEvaluationPhase.TraLai;
evaluation.CurrentWorkflowStepIndex = null;
evaluation.CurrentApprovalLevelOrder = null;
evaluation.SlaDeadline = null;
return "Trả về Người soạn thảo";
}
// 3 mode còn lại (OneLevel / OneStep / Assignee) — yêu cầu V2 schema +
// pointer hợp lệ.
if (evaluation.ApprovalWorkflowId is not Guid awId)
throw new ConflictException(
$"Mode '{mode}' yêu cầu phiếu pin V2 workflow (ApprovalWorkflowId).");
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 workflow = await db.ApprovalWorkflows
.Include(w => w.Steps).ThenInclude(s => s.Levels)
.FirstOrDefaultAsync(w => w.Id == awId, ct)
?? throw new ConflictException("Workflow không tồn tại.");
// Validate Allow* flag (Admin bypass — admin có thể trả lại bất chấp config)
if (!isAdmin)
{
var allowed = mode switch
{
WorkflowReturnMode.OneLevel => workflow.AllowReturnOneLevel,
WorkflowReturnMode.OneStep => workflow.AllowReturnOneStep,
WorkflowReturnMode.Assignee => workflow.AllowReturnToAssignee,
_ => false,
};
if (!allowed)
throw new ConflictException(
$"Workflow không bật mode '{mode}'. Liên hệ Admin Designer để config.");
}
var stepsOrdered = workflow.Steps.OrderBy(s => s.Order).ToList();
var summary = string.Empty;
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 → fallback Drafter (no further).
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 — no further back. Fallback Drafter.
evaluation.Phase = PurchaseEvaluationPhase.TraLai;
evaluation.CurrentWorkflowStepIndex = null;
evaluation.CurrentApprovalLevelOrder = null;
evaluation.SlaDeadline = null;
return "Trả về Người soạn thảo (fallback — đang 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
{
// Đang Bước 1 → fallback Drafter
evaluation.Phase = PurchaseEvaluationPhase.TraLai;
evaluation.CurrentWorkflowStepIndex = null;
evaluation.CurrentApprovalLevelOrder = null;
evaluation.SlaDeadline = null;
return "Trả về Người soạn thảo (fallback — đang Bước đầu)";
}
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);
return summary;
}
// ===== V2 schema (Mig 22-24) — iterate ApprovalWorkflowSteps + Levels =====
private async Task ApproveV2Async(
PurchaseEvaluation evaluation,