[CLAUDE] Workflow: State machine 5 trạng thái — Trả lại = Phase riêng
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m17s

Session 17 spec: chốt 5 trạng thái phiếu PE/HĐ/Budget theo state diagram:
  Nháp ─trình──► Đã gửi duyệt ─approve cấp cuối──► Đã duyệt (terminal)
                              ├─ Trả lại ────────► Trả lại
                              └─ Từ chối ────────► Từ chối (terminal)
  Trả lại ──Drafter sửa+gửi lại──► Đã gửi duyệt (chạy LẠI từ đầu)

Khác Mig 21 (Session 16): bỏ smart-reject jump-back. Trả lại = Phase
RIÊNG (TraLai=98), không revert về DangSoanThao + không jump-back step.
Drafter từ TraLai gửi lại như case Nháp — workflow chạy lại từ Cấp 1
Bước 1 (Option A diagram chốt với user).

BE Domain:
- ContractPhase + TraLai = 98
- BudgetPhase + TraLai = 98
- PurchaseEvaluationPhase: TraLai=98 đổi từ [LEGACY deprecated] thành
  primary state. Comment update enum docs cho cả 3.

BE Policy (PE/HĐ/Budget):
- Reject transitions trỏ về TraLai (thay DangSoanThao)
- Mirror entry transitions: TraLai → next phase (cho Drafter resubmit)
- ActivePhases thêm TraLai
- FromDefinition mirror: TraLai → step.Phase + reject → TraLai
- DefaultSla cho TraLai = same as DangSoanThao

BE Service (PE + Contract):
- Reject branch: target=TuChoi giữ; else set Phase=TraLai, clear
  CurrentWorkflowStepIndex=null
- Bỏ ResumeAfterReject branch + RejectedAtStepIndex/RejectedFromPhase
  assignment (DB column giữ deprecated cho data cũ)
- Drafter trình branch: từ DangSoanThao HOẶC TraLai → ChoDuyet, init
  CurrentWorkflowStepIndex=0 (cùng entry point, chạy lại từ đầu)
- Notification: TraLai when fromPhase=ChoDuyet → "bị trả lại"
- Budget Handler: simplify reject → TraLai, bỏ smart-reject + isResuming

BE Tests update:
- WorkflowPolicyTests: Standard_RejectFromCCM → TraLai (rename + assert)
  + Standard_TraLai_To_DangGopY_Allowed_For_Drafter (new)
- PurchaseEvaluationPolicyTests: BothPolicies_RejectFromCCM → TraLai
  + BothPolicies_TraLai_To_ChoPurchasing_AllowedForDrafter (new theory)
- BudgetPolicyTests: Default_CostControl_ChoCCM_To_TraLai (rename)
  + ActivePhases All6States (was All5) + NextPhasesFrom_TraLai (new)
  + NextPhasesFrom_ChoCEO_Includes_DaDuyet_And_TraLai (rename)
- 77 → 81 test pass (+4 tests TraLai entry point)

FE rename "Bản nháp" → "Nháp" (cả 2 app + types):
- types/purchaseEvaluation.ts: PurchaseEvaluationPhaseLabel 1=Nháp,
  10=Đã gửi duyệt. PeDisplayStatus.BanNhap → Nhap (key + value).
  PhaseLabel/Color cho TraLai update active.
- types/contracts.ts: +ChoDuyet=10, +TraLai=98 const + label/color.
  Phase 2 'Đang soạn thảo' → 'Nháp'.
- types/budget.ts: +TraLai=98 const + label/color. Phase 1 → 'Nháp'.
- PeListPanel + PurchaseEvaluationsListPage filter dropdown: Nhap +
  TraLai map đúng phase value.

BE label maps update consistent:
- ContractExcelExporter PhaseLabel: DangSoanThao → "Nháp" + add ChoDuyet/
  TraLai entries.
- PeWorkflowAdminFeatures + WorkflowAdminFeatures PhaseLabels: same.

Verify: dotnet test 81 pass · npm build × 2 app pass · BE 0 error.

Field RejectedAtStepIndex/RejectedFromPhase giữ DB column (nullable,
không set value mới). Cleanup migration sau.
This commit is contained in:
pqhuy1987
2026-05-08 14:12:38 +07:00
parent f3bea3c616
commit ff21120c8c
25 changed files with 286 additions and 187 deletions

View File

@ -12,7 +12,7 @@ public class ContractExcelExporter(IApplicationDbContext db, IDateTime dateTime)
private static readonly Dictionary<ContractPhase, string> PhaseLabel = new()
{
[ContractPhase.DangChon] = "Đang chọn NCC",
[ContractPhase.DangSoanThao] = "Đang soạn thảo",
[ContractPhase.DangSoanThao] = "Nháp",
[ContractPhase.DangGopY] = "Đang góp ý",
[ContractPhase.DangDamPhan] = "Đang đàm phán",
[ContractPhase.DangInKy] = "Đang in ký",
@ -20,6 +20,8 @@ public class ContractExcelExporter(IApplicationDbContext db, IDateTime dateTime)
[ContractPhase.DangTrinhKy] = "Đang trình ký",
[ContractPhase.DangDongDau] = "Đang đóng dấu",
[ContractPhase.DaPhatHanh] = "Đã phát hành",
[ContractPhase.ChoDuyet] = "Đã gửi duyệt",
[ContractPhase.TraLai] = "Trả lại",
[ContractPhase.TuChoi] = "Từ chối",
};

View File

@ -11,10 +11,15 @@ using SolutionErp.Domain.Notifications;
namespace SolutionErp.Infrastructure.Services;
// Contract Workflow Service — Session 16 drastic refactor (Mig 21):
// Flat workflow model. Mỗi step = 1 (Phòng × Cấp + Approvers). Service iterate
// steps OrderBy Order, advance Contract.CurrentWorkflowStepIndex per approve.
// Phase enum simplified: DangSoanThao → ChoDuyet → DaPhatHanh / TuChoi.
// Contract Workflow Service — Session 17 spec mới (state machine 5 trạng thái):
// Nháp (DangSoanThao) ──trình──► Đã gửi duyệt (ChoDuyet) ─approve cấp cuối─► Đã phát hành (DaPhatHanh, terminal + gen mã)
// ├─ Trả lại ────────► Trả lại (TraLai)
// └─ Từ chối ────────► Từ chối (TuChoi, terminal)
// Trả lại ──Drafter sửa+gửi lại──► Đã gửi duyệt (chạy LẠI từ đầu, KHÔNG jump-back)
//
// Khác Mig 21 (Session 16): bỏ smart-reject jump-back. Trả lại = Phase RIÊNG
// (TraLai=98), không revert về DangSoanThao. Drafter từ TraLai gửi lại như
// case Nháp — workflow chạy lại từ Cấp 1 Bước 1.
public class ContractWorkflowService(
IApplicationDbContext db,
IContractCodeGenerator codeGenerator,
@ -48,9 +53,9 @@ public class ContractWorkflowService(
}
else
{
contract.RejectedFromPhase = fromPhase;
contract.RejectedAtStepIndex = contract.CurrentWorkflowStepIndex;
contract.Phase = ContractPhase.DangSoanThao;
// 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.
contract.Phase = ContractPhase.TraLai;
contract.CurrentWorkflowStepIndex = null;
}
contract.SlaDeadline = null;
@ -59,25 +64,9 @@ public class ContractWorkflowService(
return;
}
// ===== RESUME AFTER REJECT =====
var isResumingAfterReject = decision == ApprovalDecision.Approve
&& fromPhase == ContractPhase.DangSoanThao
&& contract.RejectedAtStepIndex != null;
if (isResumingAfterReject)
{
contract.Phase = ContractPhase.ChoDuyet;
contract.CurrentWorkflowStepIndex = contract.RejectedAtStepIndex;
contract.RejectedAtStepIndex = null;
contract.RejectedFromPhase = null;
contract.SlaDeadline = dateTime.UtcNow.AddDays(7);
await LogTransitionAsync(contract, fromPhase, ContractPhase.ChoDuyet, actorUserId, decision, comment, ct);
await db.SaveChangesAsync(ct);
return;
}
// ===== DRAFTER TRÌNH =====
if (fromPhase == ContractPhase.DangSoanThao
// ===== DRAFTER TRÌNH/GỬI LẠI (Nháp HOẶC Trả lại → ChoDuyet) =====
// Cả 2 entry point cùng logic: chạy lại từ đầu (CurrentWorkflowStepIndex=0).
if ((fromPhase == ContractPhase.DangSoanThao || fromPhase == ContractPhase.TraLai)
&& (targetPhase == ContractPhase.ChoDuyet || (!isAdmin && !isSystem)))
{
if (!isAdmin && !isSystem
@ -213,7 +202,7 @@ public class ContractWorkflowService(
NotificationType.ContractPublished),
ContractPhase.TuChoi => ($"HĐ {contract.TenHopDong ?? "của bạn"} bị từ chối",
NotificationType.ContractRejected),
ContractPhase.DangSoanThao when fromPhase == ContractPhase.ChoDuyet =>
ContractPhase.TraLai when fromPhase == ContractPhase.ChoDuyet =>
($"HĐ {contract.TenHopDong ?? "của bạn"} bị trả lại — vui lòng sửa và trình lại",
NotificationType.ContractRejected),
_ => ($"HĐ {contract.TenHopDong ?? contract.MaHopDong ?? ""} chuyển phase mới",

View File

@ -12,11 +12,17 @@ using SolutionErp.Domain.PurchaseEvaluations;
namespace SolutionErp.Infrastructure.Services;
// PE Workflow Service — Session 16 drastic refactor (Mig 21):
// Flat workflow model. Mỗi step = 1 (Phòng × Cấp + Approvers). Service iterate
// steps OrderBy Order, advance PE.CurrentWorkflowStepIndex per approve.
// Phase enum simplified: DangSoanThao → ChoDuyet (active workflow) → DaDuyet
// (terminal) / TuChoi (khoá). Trả lại = về DangSoanThao + save RejectedAtStepIndex.
// PE Workflow Service — Session 17 spec mới (state machine 5 trạng thái):
// Nháp (DangSoanThao) ──trình──► Đã gửi duyệt (ChoDuyet) ─approve cấp cuối─► Đã duyệt (DaDuyet, terminal)
// ├─ Trả lại ────────► Trả lại (TraLai)
// └─ Từ chối ────────► Từ chối (TuChoi, terminal)
// Trả lại ──Drafter sửa+gửi lại──► Đã gửi duyệt (chạy LẠI từ đầu, KHÔNG jump-back)
//
// Khác Mig 21 (Session 16): bỏ smart-reject jump-back. Trả lại giờ là Phase
// RIÊNG (TraLai=98), không revert về DangSoanThao. Drafter từ TraLai gửi lại
// như case Nháp — workflow chạy lại từ Cấp 1 Bước 1. Field RejectedAtStepIndex
// + RejectedFromPhase giữ DB column (nullable, không set value mới) cho data
// cũ — sẽ cleanup migration sau.
public class PurchaseEvaluationWorkflowService(
IApplicationDbContext db,
IDateTime dateTime,
@ -49,10 +55,9 @@ public class PurchaseEvaluationWorkflowService(
}
else
{
// Trả lại — về DangSoanThao + save RejectedAtStepIndex (resume jump-back).
evaluation.RejectedFromPhase = fromPhase;
evaluation.RejectedAtStepIndex = evaluation.CurrentWorkflowStepIndex;
evaluation.Phase = PurchaseEvaluationPhase.DangSoanThao;
// 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.SlaDeadline = null;
@ -61,25 +66,10 @@ public class PurchaseEvaluationWorkflowService(
return;
}
// ===== RESUME AFTER REJECT (Drafter trình lại) =====
var isResumingAfterReject = decision == ApprovalDecision.Approve
&& fromPhase == PurchaseEvaluationPhase.DangSoanThao
&& evaluation.RejectedAtStepIndex != null;
if (isResumingAfterReject)
{
evaluation.Phase = PurchaseEvaluationPhase.ChoDuyet;
evaluation.CurrentWorkflowStepIndex = evaluation.RejectedAtStepIndex;
evaluation.RejectedAtStepIndex = null;
evaluation.RejectedFromPhase = null;
evaluation.SlaDeadline = dateTime.UtcNow.AddDays(7);
await LogTransitionAsync(evaluation, fromPhase, PurchaseEvaluationPhase.ChoDuyet, actorUserId, decision, comment, ct);
await db.SaveChangesAsync(ct);
return;
}
// ===== DRAFTER TRÌNH (DangSoanThao → ChoDuyet) =====
if (fromPhase == PurchaseEvaluationPhase.DangSoanThao
// ===== DRAFTER TRÌNH/GỬI LẠI (Nháp HOẶC Trả lại → ChoDuyet) =====
// Cả 2 entry point cùng logic: chạy lại từ đầu (CurrentWorkflowStepIndex=0).
if ((fromPhase == PurchaseEvaluationPhase.DangSoanThao
|| fromPhase == PurchaseEvaluationPhase.TraLai)
&& (targetPhase == PurchaseEvaluationPhase.ChoDuyet || !isAdmin && !isSystem))
{
// Drafter/DeptManager only (or Admin bypass).
@ -230,7 +220,7 @@ public class PurchaseEvaluationWorkflowService(
NotificationType.ContractPublished),
PurchaseEvaluationPhase.TuChoi => ($"Phiếu {evaluation.TenGoiThau} bị từ chối",
NotificationType.ContractRejected),
PurchaseEvaluationPhase.DangSoanThao when fromPhase == PurchaseEvaluationPhase.ChoDuyet =>
PurchaseEvaluationPhase.TraLai when fromPhase == PurchaseEvaluationPhase.ChoDuyet =>
($"Phiếu {evaluation.TenGoiThau} bị trả lại — vui lòng sửa và trình lại",
NotificationType.ContractRejected),
_ => ($"Phiếu {evaluation.TenGoiThau} chuyển phase mới",