[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:
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user