[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

@ -244,7 +244,7 @@ export function PeListPanel({
// hết, user vẫn thấy được phiếu Đã gửi duyệt cùng với tất cả khác. Trade-off
// chấp nhận tới khi BE thêm multi-phase param.
function statusToPhaseValue(status: PeDisplayStatus): string {
if (status === PeDisplayStatus.BanNhap) return String(PurchaseEvaluationPhase.DangSoanThao)
if (status === PeDisplayStatus.Nhap) return String(PurchaseEvaluationPhase.DangSoanThao)
if (status === PeDisplayStatus.DaDuyet) return String(PurchaseEvaluationPhase.DaDuyet)
if (status === PeDisplayStatus.TuChoi) return String(PurchaseEvaluationPhase.TuChoi)
return '' // DaGuiDuyet — multi-phase, không filter exact (TODO BE add support)

View File

@ -122,10 +122,12 @@ export function PurchaseEvaluationsListPage() {
<Select value={phase} onChange={e => setParam('phase', e.target.value)}>
<option value="">Tất cả trạng thái</option>
{Object.values(PeDisplayStatus).map(s => {
const phaseValue = s === PeDisplayStatus.BanNhap
const phaseValue = s === PeDisplayStatus.Nhap
? String(PurchaseEvaluationPhase.DangSoanThao)
: s === PeDisplayStatus.DaDuyet
? String(PurchaseEvaluationPhase.DaDuyet)
: s === PeDisplayStatus.TraLai
? String(PurchaseEvaluationPhase.TraLai)
: s === PeDisplayStatus.TuChoi
? String(PurchaseEvaluationPhase.TuChoi)
: '' // DaGuiDuyet — multi-phase, không filter exact (TODO BE)

View File

@ -7,15 +7,17 @@ export const BudgetPhase = {
ChoCCM: 2,
ChoCEO: 3,
DaDuyet: 4,
TraLai: 98,
TuChoi: 99,
} as const
export type BudgetPhase = typeof BudgetPhase[keyof typeof BudgetPhase]
export const BudgetPhaseLabel: Record<number, string> = {
1: 'Đang soạn thảo',
1: 'Nháp',
2: 'Chờ CCM',
3: 'Chờ CEO',
4: 'Đã duyệt',
98: 'Trả lại',
99: 'Từ chối',
}
@ -24,6 +26,7 @@ export const BudgetPhaseColor: Record<number, string> = {
2: 'bg-indigo-100 text-indigo-700',
3: 'bg-pink-100 text-pink-700',
4: 'bg-emerald-100 text-emerald-700',
98: 'bg-yellow-100 text-yellow-800',
99: 'bg-red-100 text-red-700',
}

View File

@ -8,6 +8,8 @@ export const ContractPhase = {
DangTrinhKy: 7,
DangDongDau: 8,
DaPhatHanh: 9,
ChoDuyet: 10,
TraLai: 98,
TuChoi: 99,
} as const
@ -15,7 +17,7 @@ export type ContractPhase = typeof ContractPhase[keyof typeof ContractPhase]
export const ContractPhaseLabel: Record<number, string> = {
1: 'Đang chọn NCC',
2: 'Đang soạn thảo',
2: 'Nháp',
3: 'Đang góp ý',
4: 'Đang đàm phán',
5: 'Đang in ký',
@ -23,12 +25,14 @@ export const ContractPhaseLabel: Record<number, string> = {
7: 'Đang trình ký',
8: 'Đang đóng dấu',
9: 'Đã phát hành',
10: 'Đã gửi duyệt',
98: 'Trả lại',
99: 'Từ chối',
}
export const ContractPhaseColor: Record<number, string> = {
1: 'bg-slate-100 text-slate-700',
2: 'bg-blue-100 text-blue-700',
2: 'bg-slate-100 text-slate-700',
3: 'bg-amber-100 text-amber-700',
4: 'bg-orange-100 text-orange-700',
5: 'bg-purple-100 text-purple-700',
@ -36,6 +40,8 @@ export const ContractPhaseColor: Record<number, string> = {
7: 'bg-fuchsia-100 text-fuchsia-700',
8: 'bg-pink-100 text-pink-700',
9: 'bg-emerald-100 text-emerald-700',
10: 'bg-amber-100 text-amber-700',
98: 'bg-yellow-100 text-yellow-800',
99: 'bg-red-100 text-red-700',
}

View File

@ -27,20 +27,20 @@ export const PurchaseEvaluationPhase = {
ChoCEODuyetNCC: 6, // [LEGACY]
DaDuyet: 7,
ChoDuyet: 10, // [Mig 21] generic intermediate
TraLai: 98, // [LEGACY]
TraLai: 98, // Phase riêng — Drafter sửa+gửi lại chạy từ đầu
TuChoi: 99,
} as const
export type PurchaseEvaluationPhase = typeof PurchaseEvaluationPhase[keyof typeof PurchaseEvaluationPhase]
export const PurchaseEvaluationPhaseLabel: Record<number, string> = {
1: 'Đang soạn thảo',
1: 'Nháp',
2: 'Chờ Purchasing',
3: 'Chờ Dự án',
4: 'Chờ CCM',
5: 'Chờ CEO duyệt PA',
6: 'Chờ CEO duyệt NCC',
7: 'Đã duyệt',
10: ang duyệt',
10: ã gửi duyệt',
98: 'Trả lại',
99: 'Từ chối',
}
@ -65,15 +65,14 @@ export function isEditablePhase(phase: number): boolean {
|| phase === PurchaseEvaluationPhase.TraLai
}
// Display status meta — gom các phase chi tiết thành 4 nhóm hiển thị end-user
// friendly. Workflow timeline + workflow service vẫn dùng phase chi tiết.
// User 2026-05-07 chỉnh:
// - Bản nháp = DangSoanThao (chỉ hiện ở Thao tác workspace, ko Duyệt menu)
// - Đã gửi duyệt = bất kỳ phase trung gian (ChoPurchasing/ChoDuAn/ChoCCM/...)
// - Đã duyệt = DaDuyet
// - Từ chối = TuChoi
// Display status meta — 5 trạng thái spec Session 17:
// Nháp = DangSoanThao (chưa vào quy trình duyệt)
// Đã gửi duyệt = ChoDuyet/legacy intermediate (đang chạy quy trình)
// Trả lại = TraLai (có history đã đi qua quy trình, sửa+gửi lại chạy từ đầu)
// Đã duyệt = DaDuyet (terminal OK — input cho phiếu khác / in trình ký)
// Từ chối = TuChoi (terminal lock — không thao tác gì được nữa)
export const PeDisplayStatus = {
BanNhap: 'BanNhap',
Nhap: 'Nhap',
DaGuiDuyet: 'DaGuiDuyet',
TraLai: 'TraLai',
DaDuyet: 'DaDuyet',
@ -82,7 +81,7 @@ export const PeDisplayStatus = {
export type PeDisplayStatus = typeof PeDisplayStatus[keyof typeof PeDisplayStatus]
export const PeDisplayStatusLabel: Record<PeDisplayStatus, string> = {
BanNhap: 'Bản nháp',
Nhap: 'Nháp',
DaGuiDuyet: 'Đã gửi duyệt',
TraLai: 'Trả lại',
DaDuyet: 'Đã duyệt',
@ -90,7 +89,7 @@ export const PeDisplayStatusLabel: Record<PeDisplayStatus, string> = {
}
export const PeDisplayStatusColor: Record<PeDisplayStatus, string> = {
BanNhap: 'bg-slate-100 text-slate-700',
Nhap: 'bg-slate-100 text-slate-700',
DaGuiDuyet: 'bg-amber-100 text-amber-700',
TraLai: 'bg-yellow-100 text-yellow-800',
DaDuyet: 'bg-emerald-100 text-emerald-700',
@ -98,11 +97,11 @@ export const PeDisplayStatusColor: Record<PeDisplayStatus, string> = {
}
export function getPeDisplayStatus(phase: number): PeDisplayStatus {
if (phase === PurchaseEvaluationPhase.DangSoanThao) return PeDisplayStatus.BanNhap
if (phase === PurchaseEvaluationPhase.DangSoanThao) return PeDisplayStatus.Nhap
if (phase === PurchaseEvaluationPhase.DaDuyet) return PeDisplayStatus.DaDuyet
if (phase === PurchaseEvaluationPhase.TraLai) return PeDisplayStatus.TraLai
if (phase === PurchaseEvaluationPhase.TuChoi) return PeDisplayStatus.TuChoi
// Mig 21 ChoDuyet=10 + legacy intermediate 2-6 → all map "Đã gửi duyệt"
// ChoDuyet=10 + legacy intermediate 2-6 → all map "Đã gửi duyệt"
return PeDisplayStatus.DaGuiDuyet
}

View File

@ -244,7 +244,7 @@ export function PeListPanel({
// hết, user vẫn thấy được phiếu Đã gửi duyệt cùng với tất cả khác. Trade-off
// chấp nhận tới khi BE thêm multi-phase param.
function statusToPhaseValue(status: PeDisplayStatus): string {
if (status === PeDisplayStatus.BanNhap) return String(PurchaseEvaluationPhase.DangSoanThao)
if (status === PeDisplayStatus.Nhap) return String(PurchaseEvaluationPhase.DangSoanThao)
if (status === PeDisplayStatus.DaDuyet) return String(PurchaseEvaluationPhase.DaDuyet)
if (status === PeDisplayStatus.TuChoi) return String(PurchaseEvaluationPhase.TuChoi)
return '' // DaGuiDuyet — multi-phase, không filter exact (TODO BE add support)

View File

@ -122,10 +122,12 @@ export function PurchaseEvaluationsListPage() {
<Select value={phase} onChange={e => setParam('phase', e.target.value)}>
<option value="">Tất cả trạng thái</option>
{Object.values(PeDisplayStatus).map(s => {
const phaseValue = s === PeDisplayStatus.BanNhap
const phaseValue = s === PeDisplayStatus.Nhap
? String(PurchaseEvaluationPhase.DangSoanThao)
: s === PeDisplayStatus.DaDuyet
? String(PurchaseEvaluationPhase.DaDuyet)
: s === PeDisplayStatus.TraLai
? String(PurchaseEvaluationPhase.TraLai)
: s === PeDisplayStatus.TuChoi
? String(PurchaseEvaluationPhase.TuChoi)
: '' // DaGuiDuyet — multi-phase, không filter exact (TODO BE)

View File

@ -7,15 +7,17 @@ export const BudgetPhase = {
ChoCCM: 2,
ChoCEO: 3,
DaDuyet: 4,
TraLai: 98,
TuChoi: 99,
} as const
export type BudgetPhase = typeof BudgetPhase[keyof typeof BudgetPhase]
export const BudgetPhaseLabel: Record<number, string> = {
1: 'Đang soạn thảo',
1: 'Nháp',
2: 'Chờ CCM',
3: 'Chờ CEO',
4: 'Đã duyệt',
98: 'Trả lại',
99: 'Từ chối',
}
@ -24,6 +26,7 @@ export const BudgetPhaseColor: Record<number, string> = {
2: 'bg-indigo-100 text-indigo-700',
3: 'bg-pink-100 text-pink-700',
4: 'bg-emerald-100 text-emerald-700',
98: 'bg-yellow-100 text-yellow-800',
99: 'bg-red-100 text-red-700',
}

View File

@ -8,6 +8,8 @@ export const ContractPhase = {
DangTrinhKy: 7,
DangDongDau: 8,
DaPhatHanh: 9,
ChoDuyet: 10,
TraLai: 98,
TuChoi: 99,
} as const
@ -15,7 +17,7 @@ export type ContractPhase = typeof ContractPhase[keyof typeof ContractPhase]
export const ContractPhaseLabel: Record<number, string> = {
1: 'Đang chọn NCC',
2: 'Đang soạn thảo',
2: 'Nháp',
3: 'Đang góp ý',
4: 'Đang đàm phán',
5: 'Đang in ký',
@ -23,12 +25,14 @@ export const ContractPhaseLabel: Record<number, string> = {
7: 'Đang trình ký',
8: 'Đang đóng dấu',
9: 'Đã phát hành',
10: 'Đã gửi duyệt',
98: 'Trả lại',
99: 'Từ chối',
}
export const ContractPhaseColor: Record<number, string> = {
1: 'bg-slate-100 text-slate-700',
2: 'bg-blue-100 text-blue-700',
2: 'bg-slate-100 text-slate-700',
3: 'bg-amber-100 text-amber-700',
4: 'bg-orange-100 text-orange-700',
5: 'bg-purple-100 text-purple-700',
@ -36,6 +40,8 @@ export const ContractPhaseColor: Record<number, string> = {
7: 'bg-fuchsia-100 text-fuchsia-700',
8: 'bg-pink-100 text-pink-700',
9: 'bg-emerald-100 text-emerald-700',
10: 'bg-amber-100 text-amber-700',
98: 'bg-yellow-100 text-yellow-800',
99: 'bg-red-100 text-red-700',
}

View File

@ -27,20 +27,20 @@ export const PurchaseEvaluationPhase = {
ChoCEODuyetNCC: 6, // [LEGACY]
DaDuyet: 7,
ChoDuyet: 10, // [Mig 21] generic intermediate
TraLai: 98, // [LEGACY]
TraLai: 98, // Phase riêng — Drafter sửa+gửi lại chạy từ đầu
TuChoi: 99,
} as const
export type PurchaseEvaluationPhase = typeof PurchaseEvaluationPhase[keyof typeof PurchaseEvaluationPhase]
export const PurchaseEvaluationPhaseLabel: Record<number, string> = {
1: 'Đang soạn thảo',
1: 'Nháp',
2: 'Chờ Purchasing',
3: 'Chờ Dự án',
4: 'Chờ CCM',
5: 'Chờ CEO duyệt PA',
6: 'Chờ CEO duyệt NCC',
7: 'Đã duyệt',
10: ang duyệt',
10: ã gửi duyệt',
98: 'Trả lại',
99: 'Từ chối',
}
@ -65,15 +65,14 @@ export function isEditablePhase(phase: number): boolean {
|| phase === PurchaseEvaluationPhase.TraLai
}
// Display status meta — gom các phase chi tiết thành 4 nhóm hiển thị end-user
// friendly. Workflow timeline + workflow service vẫn dùng phase chi tiết.
// User 2026-05-07 chỉnh:
// - Bản nháp = DangSoanThao (chỉ hiện ở Thao tác workspace, ko Duyệt menu)
// - Đã gửi duyệt = bất kỳ phase trung gian (ChoPurchasing/ChoDuAn/ChoCCM/...)
// - Đã duyệt = DaDuyet
// - Từ chối = TuChoi
// Display status meta — 5 trạng thái spec Session 17:
// Nháp = DangSoanThao (chưa vào quy trình duyệt)
// Đã gửi duyệt = ChoDuyet/legacy intermediate (đang chạy quy trình)
// Trả lại = TraLai (có history đã đi qua quy trình, sửa+gửi lại chạy từ đầu)
// Đã duyệt = DaDuyet (terminal OK — input cho phiếu khác / in trình ký)
// Từ chối = TuChoi (terminal lock — không thao tác gì được nữa)
export const PeDisplayStatus = {
BanNhap: 'BanNhap',
Nhap: 'Nhap',
DaGuiDuyet: 'DaGuiDuyet',
TraLai: 'TraLai',
DaDuyet: 'DaDuyet',
@ -82,7 +81,7 @@ export const PeDisplayStatus = {
export type PeDisplayStatus = typeof PeDisplayStatus[keyof typeof PeDisplayStatus]
export const PeDisplayStatusLabel: Record<PeDisplayStatus, string> = {
BanNhap: 'Bản nháp',
Nhap: 'Nháp',
DaGuiDuyet: 'Đã gửi duyệt',
TraLai: 'Trả lại',
DaDuyet: 'Đã duyệt',
@ -90,7 +89,7 @@ export const PeDisplayStatusLabel: Record<PeDisplayStatus, string> = {
}
export const PeDisplayStatusColor: Record<PeDisplayStatus, string> = {
BanNhap: 'bg-slate-100 text-slate-700',
Nhap: 'bg-slate-100 text-slate-700',
DaGuiDuyet: 'bg-amber-100 text-amber-700',
TraLai: 'bg-yellow-100 text-yellow-800',
DaDuyet: 'bg-emerald-100 text-emerald-700',
@ -98,7 +97,7 @@ export const PeDisplayStatusColor: Record<PeDisplayStatus, string> = {
}
export function getPeDisplayStatus(phase: number): PeDisplayStatus {
if (phase === PurchaseEvaluationPhase.DangSoanThao) return PeDisplayStatus.BanNhap
if (phase === PurchaseEvaluationPhase.DangSoanThao) return PeDisplayStatus.Nhap
if (phase === PurchaseEvaluationPhase.DaDuyet) return PeDisplayStatus.DaDuyet
if (phase === PurchaseEvaluationPhase.TraLai) return PeDisplayStatus.TraLai
if (phase === PurchaseEvaluationPhase.TuChoi) return PeDisplayStatus.TuChoi

View File

@ -136,29 +136,24 @@ public class TransitionBudgetCommandHandler(
var entity = await db.Budgets.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
?? throw new NotFoundException("Budget", request.Id);
// ===== Smart reject + resume (Phase 9 — Migration 16) =====
// ===== Reject → TraLai (Session 17 spec mới) =====
// Bỏ smart-reject jump-back. Trả lại = Phase riêng (TraLai).
// Drafter từ TraLai gửi lại như Nháp — Policy `(TraLai, ChoCCM)` đã wire.
// Field RejectedFromPhase giữ DB column nhưng KHÔNG set value mới (data cũ vẫn đọc).
var fromPhase = entity.Phase;
var targetPhase = request.TargetPhase;
var isResumingAfterReject = request.Decision == ApprovalDecision.Approve
&& fromPhase == BudgetPhase.DangSoanThao
&& entity.RejectedFromPhase != null;
if (request.Decision == ApprovalDecision.Reject)
if (request.Decision == ApprovalDecision.Reject && targetPhase != BudgetPhase.TuChoi)
{
entity.RejectedFromPhase = fromPhase;
targetPhase = BudgetPhase.DangSoanThao;
}
else if (isResumingAfterReject)
{
targetPhase = entity.RejectedFromPhase!.Value;
entity.RejectedFromPhase = null;
// Trả lại — override target → TraLai
targetPhase = BudgetPhase.TraLai;
}
var policy = BudgetPolicies.Default;
var isAdmin = currentUser.Roles.Contains(AppRoles.Admin);
// Policy guard — bypass khi resume sau reject.
if (!isAdmin && !isResumingAfterReject
// Policy guard
if (!isAdmin
&& !policy.IsTransitionAllowed(fromPhase, targetPhase, currentUser.Roles))
throw new ForbiddenException(
$"Role không đủ quyền chuyển {fromPhase} → {targetPhase}.");
@ -168,8 +163,8 @@ public class TransitionBudgetCommandHandler(
// nhưng giữ consistent UX 3 module.
if (request.Decision == ApprovalDecision.Approve
&& targetPhase != BudgetPhase.DangSoanThao
&& targetPhase != BudgetPhase.TraLai
&& targetPhase != BudgetPhase.TuChoi
&& !isResumingAfterReject
&& !isAdmin
&& currentUser.UserId is Guid actorUid)
{

View File

@ -74,7 +74,7 @@ public class GetWorkflowAdminOverviewQueryHandler(
private static readonly Dictionary<ContractPhase, string> PhaseLabels = new()
{
[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ý",
@ -82,6 +82,9 @@ public class GetWorkflowAdminOverviewQueryHandler(
[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",
};
public async Task<WorkflowAdminOverviewDto> Handle(GetWorkflowAdminOverviewQuery request, CancellationToken ct)

View File

@ -69,13 +69,16 @@ public class GetPeWorkflowAdminOverviewQueryHandler(
private static readonly Dictionary<PurchaseEvaluationPhase, string> PhaseLabels = new()
{
[PurchaseEvaluationPhase.DangSoanThao] = "Đang soạn thảo",
[PurchaseEvaluationPhase.DangSoanThao] = "Nháp",
[PurchaseEvaluationPhase.ChoPurchasing] = "Chờ Purchasing",
[PurchaseEvaluationPhase.ChoDuAn] = "Chờ Dự án",
[PurchaseEvaluationPhase.ChoCCM] = "Chờ CCM",
[PurchaseEvaluationPhase.ChoCEODuyetPA] = "Chờ CEO duyệt PA",
[PurchaseEvaluationPhase.ChoCEODuyetNCC] = "Chờ CEO duyệt NCC",
[PurchaseEvaluationPhase.ChoDuyet] = "Đã gửi duyệt",
[PurchaseEvaluationPhase.DaDuyet] = "Đã duyệt",
[PurchaseEvaluationPhase.TraLai] = "Trả lại",
[PurchaseEvaluationPhase.TuChoi] = "Từ chối",
};
public async Task<PeWorkflowAdminOverviewDto> Handle(GetPeWorkflowAdminOverviewQuery request, CancellationToken ct)

View File

@ -1,14 +1,17 @@
namespace SolutionErp.Domain.Budgets;
// State machine ngân sách — đơn giản 3 bước duyệt + 2 terminal.
// DangSoanThao → ChoCCM → ChoCEO → DaDuyet
// Bất kỳ phase duyệt → DangSoanThao (reject)
// DangSoanThao → TuChoi
// State machine ngân sách — Session 17 spec mới (5 trạng thái mirror PE/HĐ):
// DangSoanThao (Nháp) → ChoCCM (Drafter trình)
// TraLai (Trả lại) → ChoCCM (Drafter sửa+gửi lại, chạy từ đầu)
// ChoCCM/ChoCEO → next phase OR TraLai OR TuChoi
// ChoCEO → DaDuyet (terminal)
// DangSoanThao/TraLai → TuChoi (Drafter huỷ)
public enum BudgetPhase
{
DangSoanThao = 1,
ChoCCM = 2,
ChoCEO = 3,
DaDuyet = 4,
TraLai = 98, // Phase riêng (không revert DangSoanThao)
TuChoi = 99,
}

View File

@ -31,12 +31,15 @@ public static class BudgetPolicies
private static readonly Dictionary<BudgetPhase, TimeSpan?> DefaultSla = new()
{
[BudgetPhase.DangSoanThao] = TimeSpan.FromDays(5),
[BudgetPhase.TraLai] = TimeSpan.FromDays(5),
[BudgetPhase.ChoCCM] = TimeSpan.FromDays(3),
[BudgetPhase.ChoCEO] = TimeSpan.FromDays(2),
[BudgetPhase.DaDuyet] = null,
[BudgetPhase.TuChoi] = null,
};
// Session 17 spec: Reject = về TraLai (Phase riêng). Drafter từ TraLai
// gửi lại = entry point thứ 2 (mirror DangSoanThao → ChoCCM).
public static readonly BudgetPolicy Default = new(
Name: "Default",
Description: "Quy trình ngân sách 3-step (Drafter → CCM → CEO).",
@ -44,17 +47,22 @@ public static class BudgetPolicies
{
[(BudgetPhase.DangSoanThao, BudgetPhase.ChoCCM)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(BudgetPhase.DangSoanThao, BudgetPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(BudgetPhase.TraLai, BudgetPhase.ChoCCM)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(BudgetPhase.TraLai, BudgetPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(BudgetPhase.ChoCCM, BudgetPhase.ChoCEO)] = [AppRoles.CostControl],
[(BudgetPhase.ChoCCM, BudgetPhase.DangSoanThao)] = [AppRoles.CostControl],
[(BudgetPhase.ChoCCM, BudgetPhase.TraLai)] = [AppRoles.CostControl],
[(BudgetPhase.ChoCCM, BudgetPhase.TuChoi)] = [AppRoles.CostControl],
[(BudgetPhase.ChoCEO, BudgetPhase.DaDuyet)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
[(BudgetPhase.ChoCEO, BudgetPhase.DangSoanThao)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
[(BudgetPhase.ChoCEO, BudgetPhase.TraLai)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
[(BudgetPhase.ChoCEO, BudgetPhase.TuChoi)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
},
PhaseSla: DefaultSla,
ActivePhases:
[
BudgetPhase.DangSoanThao,
BudgetPhase.TraLai,
BudgetPhase.ChoCCM,
BudgetPhase.ChoCEO,
BudgetPhase.DaDuyet,

View File

@ -1,25 +1,28 @@
namespace SolutionErp.Domain.Contracts;
// State machine HĐ — Session 16 drastic refactor (Mig 21):
// DangSoanThao → ChoDuyet (Drafter trình, init CurrentWorkflowStepIndex=0)
// ChoDuyet → ChoDuyet (advance step pointer per approve)
// ChoDuyet → DaPhatHanh (last step done — terminal)
// ChoDuyet → DangSoanThao (Trả lại — save RejectedAtStepIndex, Drafter sửa)
// ChoDuyet → TuChoi (Từ chối — terminal khoá)
// State machine HĐ — Session 17 spec mới (5 trạng thái):
// DangSoanThao (Nháp) ──Drafter trình──► ChoDuyet
// TraLai (Trả lại) ──Drafter sửa+gửi lại──► ChoDuyet (chạy lại từ Cấp 1 Bước 1)
// ChoDuyet (Đã gửi duyệt) ──advance step pointer──► ChoDuyet
// ──last step done──────────► DaPhatHanh (terminal + gen mã HĐ)
// ──Approver Trả lại────────► TraLai
// ──Approver Từ chối────────► TuChoi (terminal)
//
// LEGACY values (DangChon, DangGopY, DangDamPhan, DangInKy, DangKiemTraCCM,
// DangTrinhKy, DangDongDau) deprecated post-Mig 21 — giữ enum cho data cũ.
// TraLai=98: Session 17 thêm mới — Trả lại là Phase RIÊNG (mirror PE).
public enum ContractPhase
{
DangChon = 1, // [LEGACY]
DangSoanThao = 2,
DangSoanThao = 2, // Nháp
DangGopY = 3, // [LEGACY]
DangDamPhan = 4, // [LEGACY]
DangInKy = 5, // [LEGACY]
DangKiemTraCCM = 6, // [LEGACY]
DangTrinhKy = 7, // [LEGACY]
DangDongDau = 8, // [LEGACY]
DaPhatHanh = 9, // terminal thành công (= DaDuyet cho HĐ)
ChoDuyet = 10, // [Mig 21] generic intermediate, dùng CurrentWorkflowStepIndex tracking
TuChoi = 99, // terminal khoá
DaPhatHanh = 9, // Đã duyệt (cho HĐ — gen mã + phát hành) — terminal thành công
ChoDuyet = 10, // Đã gửi duyệt — generic intermediate, CurrentWorkflowStepIndex tracking
TraLai = 98, // Trả lại — Phase riêng, Drafter sửa rồi gửi lại chạy từ đầu
TuChoi = 99, // Từ chối — terminal khoá
}

View File

@ -52,6 +52,7 @@ public static class WorkflowPolicies
private static readonly Dictionary<ContractPhase, TimeSpan?> DefaultSla = new()
{
[ContractPhase.DangSoanThao] = TimeSpan.FromDays(7),
[ContractPhase.TraLai] = TimeSpan.FromDays(7),
[ContractPhase.DangGopY] = TimeSpan.FromDays(7),
[ContractPhase.DangDamPhan] = TimeSpan.FromDays(7),
[ContractPhase.DangInKy] = TimeSpan.FromDays(1),
@ -65,6 +66,8 @@ public static class WorkflowPolicies
// ===== STANDARD: 9-phase formal workflow =====
// Per QT-TP-NCC.docx: Thầu phụ / NCC / Tổ đội — full CCM review required.
// Session 17: Reject = về TraLai (Phase riêng). Drafter từ TraLai gửi lại
// = entry point thứ 2 (mirror DangSoanThao → DangGopY).
public static readonly WorkflowPolicy Standard = new(
Name: "Standard",
Description: "Quy trình đầy đủ 8 phase — CCM kiểm tra + BOD duyệt. Áp dụng HĐ Thầu phụ / NCC / Giao khoán.",
@ -72,26 +75,28 @@ public static class WorkflowPolicies
{
[(ContractPhase.DangSoanThao, ContractPhase.DangGopY)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(ContractPhase.DangSoanThao, ContractPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(ContractPhase.TraLai, ContractPhase.DangGopY)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(ContractPhase.TraLai, ContractPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(ContractPhase.DangGopY, ContractPhase.DangDamPhan)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(ContractPhase.DangGopY, ContractPhase.DangSoanThao)] = [AppRoles.ProjectManager, AppRoles.Procurement, AppRoles.CostControl, AppRoles.Finance, AppRoles.Accounting, AppRoles.Equipment],
[(ContractPhase.DangGopY, ContractPhase.TraLai)] = [AppRoles.ProjectManager, AppRoles.Procurement, AppRoles.CostControl, AppRoles.Finance, AppRoles.Accounting, AppRoles.Equipment],
[(ContractPhase.DangDamPhan, ContractPhase.DangInKy)] = [AppRoles.Drafter, AppRoles.DeptManager, AppRoles.ProjectManager],
[(ContractPhase.DangInKy, ContractPhase.DangKiemTraCCM)] = [AppRoles.Drafter, AppRoles.DeptManager, AppRoles.ProjectManager],
[(ContractPhase.DangKiemTraCCM, ContractPhase.DangTrinhKy)] = [AppRoles.CostControl],
[(ContractPhase.DangKiemTraCCM, ContractPhase.DangSoanThao)] = [AppRoles.CostControl],
[(ContractPhase.DangKiemTraCCM, ContractPhase.TraLai)] = [AppRoles.CostControl],
[(ContractPhase.DangTrinhKy, ContractPhase.DangDongDau)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
[(ContractPhase.DangTrinhKy, ContractPhase.DangSoanThao)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
[(ContractPhase.DangTrinhKy, ContractPhase.TraLai)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
[(ContractPhase.DangDongDau, ContractPhase.DaPhatHanh)] = [AppRoles.HrAdmin],
},
PhaseSla: DefaultSla,
ActivePhases:
[
ContractPhase.DangSoanThao, ContractPhase.DangGopY, ContractPhase.DangDamPhan,
ContractPhase.DangSoanThao, ContractPhase.TraLai, ContractPhase.DangGopY, ContractPhase.DangDamPhan,
ContractPhase.DangInKy, ContractPhase.DangKiemTraCCM, ContractPhase.DangTrinhKy,
ContractPhase.DangDongDau, ContractPhase.DaPhatHanh, ContractPhase.TuChoi,
]);
@ -106,9 +111,11 @@ public static class WorkflowPolicies
{
[(ContractPhase.DangSoanThao, ContractPhase.DangGopY)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(ContractPhase.DangSoanThao, ContractPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(ContractPhase.TraLai, ContractPhase.DangGopY)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(ContractPhase.TraLai, ContractPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(ContractPhase.DangGopY, ContractPhase.DangDamPhan)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(ContractPhase.DangGopY, ContractPhase.DangSoanThao)] = [AppRoles.ProjectManager, AppRoles.Procurement, AppRoles.CostControl, AppRoles.Finance, AppRoles.Accounting, AppRoles.Equipment],
[(ContractPhase.DangGopY, ContractPhase.TraLai)] = [AppRoles.ProjectManager, AppRoles.Procurement, AppRoles.CostControl, AppRoles.Finance, AppRoles.Accounting, AppRoles.Equipment],
[(ContractPhase.DangDamPhan, ContractPhase.DangInKy)] = [AppRoles.Drafter, AppRoles.DeptManager, AppRoles.ProjectManager],
@ -116,14 +123,14 @@ public static class WorkflowPolicies
[(ContractPhase.DangInKy, ContractPhase.DangTrinhKy)] = [AppRoles.Drafter, AppRoles.DeptManager, AppRoles.ProjectManager],
[(ContractPhase.DangTrinhKy, ContractPhase.DangDongDau)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
[(ContractPhase.DangTrinhKy, ContractPhase.DangSoanThao)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
[(ContractPhase.DangTrinhKy, ContractPhase.TraLai)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
[(ContractPhase.DangDongDau, ContractPhase.DaPhatHanh)] = [AppRoles.HrAdmin],
},
PhaseSla: DefaultSla,
ActivePhases:
[
ContractPhase.DangSoanThao, ContractPhase.DangGopY, ContractPhase.DangDamPhan,
ContractPhase.DangSoanThao, ContractPhase.TraLai, ContractPhase.DangGopY, ContractPhase.DangDamPhan,
ContractPhase.DangInKy, ContractPhase.DangTrinhKy,
ContractPhase.DangDongDau, ContractPhase.DaPhatHanh, ContractPhase.TuChoi,
]);
@ -213,22 +220,35 @@ public static class WorkflowPolicyRegistry
transitions[(prev.Value, s.Phase)] = roles;
if (userIds.Length > 0) userTransitions[(prev.Value, s.Phase)] = userIds;
// Reject path back to Drafter (common pattern QT docx)
// Mirror: TraLai → s.Phase (Drafter resubmit từ Trả lại = entry point thứ 2)
if (prev.Value == ContractPhase.DangSoanThao)
{
transitions.TryAdd((ContractPhase.TraLai, s.Phase), roles);
if (userIds.Length > 0)
userTransitions.TryAdd((ContractPhase.TraLai, s.Phase), userIds);
}
// Reject path → TraLai (Phase riêng, không revert DangSoanThao)
if (prev.Value != ContractPhase.DangSoanThao && s.Phase != ContractPhase.DangSoanThao)
{
transitions.TryAdd((s.Phase, ContractPhase.DangSoanThao), roles);
transitions.TryAdd((s.Phase, ContractPhase.TraLai), roles);
if (userIds.Length > 0)
userTransitions.TryAdd((s.Phase, ContractPhase.DangSoanThao), userIds);
userTransitions.TryAdd((s.Phase, ContractPhase.TraLai), userIds);
}
}
prev = s.Phase;
}
// First step có thể reject to TuChoi
// First step có thể reject to TuChoi (cả Nháp + Trả lại)
if (steps.Count > 0)
{
transitions.TryAdd((steps[0].Phase, ContractPhase.TuChoi),
[AppRoles.Drafter, AppRoles.DeptManager]);
transitions.TryAdd((ContractPhase.TraLai, ContractPhase.TuChoi),
[AppRoles.Drafter, AppRoles.DeptManager]);
}
if (!activePhases.Contains(ContractPhase.TuChoi)) activePhases.Add(ContractPhase.TuChoi);
if (!activePhases.Contains(ContractPhase.TraLai)) activePhases.Add(ContractPhase.TraLai);
return new WorkflowPolicy(
Name: $"{def.Code}-v{def.Version:D2}",

View File

@ -1,25 +1,27 @@
namespace SolutionErp.Domain.PurchaseEvaluations;
// State machine PE — Session 16 drastic refactor (Mig 21):
// DangSoanThao → ChoDuyet (Drafter trình, init CurrentWorkflowStepIndex=0)
// ChoDuyet → ChoDuyet (advance step pointer mỗi lần approve)
// ChoDuyet → DaDuyet (last step done — terminal thành công)
// ChoDuyet → DangSoanThao (Trả lại — save RejectedAtStepIndex, Drafter sửa)
// ChoDuyet → TuChoi (Từ chối — terminal khoá phiếu)
// State machine PE — Session 17 spec mới (5 trạng thái):
// DangSoanThao (Nháp) ──Drafter trình──► ChoDuyet
// TraLai (Trả lại) ──Drafter sửa+gửi lại──► ChoDuyet (chạy lại từ Cấp 1 Bước 1)
// ChoDuyet (Đã gửi duyệt) ──advance step pointer──► ChoDuyet
// ──last step done──────────► DaDuyet (terminal)
// ──Approver Trả lại────────► TraLai
// ──Approver Từ chối────────► TuChoi (terminal)
// DangSoanThao → TuChoi (Drafter huỷ trước trình)
//
// LEGACY values 2-6 + 98 deprecated post-Mig 21 (data cũ vẫn đọc OK,
// new workflow definitions chỉ dùng DangSoanThao/ChoDuyet/DaDuyet/TuChoi).
// LEGACY values 2-6 deprecated post-Mig 21 (data cũ vẫn đọc OK).
// TraLai=98: Session 17 restore làm primary state — Trả lại là Phase RIÊNG,
// không revert về DangSoanThao như Mig 21.
public enum PurchaseEvaluationPhase
{
DangSoanThao = 1,
ChoPurchasing = 2, // [LEGACY] deprecated
ChoDuAn = 3, // [LEGACY] deprecated
ChoCCM = 4, // [LEGACY] deprecated
ChoCEODuyetPA = 5, // [LEGACY] deprecated
ChoCEODuyetNCC = 6, // [LEGACY] deprecated
DaDuyet = 7, // terminal thành công
ChoDuyet = 10, // [Mig 21] generic intermediate, dùng CurrentWorkflowStepIndex tracking
TraLai = 98, // [LEGACY] deprecated — Session 14 chốt thay bằng Trả lại = về DangSoanThao
TuChoi = 99, // terminal từ chối — KHÔNG cho edit/thao tác
DangSoanThao = 1, // Nháp
ChoPurchasing = 2, // [LEGACY] deprecated
ChoDuAn = 3, // [LEGACY] deprecated
ChoCCM = 4, // [LEGACY] deprecated
ChoCEODuyetPA = 5, // [LEGACY] deprecated
ChoCEODuyetNCC = 6, // [LEGACY] deprecated
DaDuyet = 7, // Đã duyệt — terminal thành công
ChoDuyet = 10, // Đã gửi duyệt — generic intermediate, CurrentWorkflowStepIndex tracking
TraLai = 98, // Trả lại — Phase riêng, Drafter sửa rồi gửi lại chạy từ đầu
TuChoi = 99, // Từ chối — terminal khoá phiếu, KHÔNG cho edit/thao tác
}

View File

@ -38,6 +38,7 @@ public static class PurchaseEvaluationPolicies
private static readonly Dictionary<PurchaseEvaluationPhase, TimeSpan?> DefaultSla = new()
{
[PurchaseEvaluationPhase.DangSoanThao] = TimeSpan.FromDays(3),
[PurchaseEvaluationPhase.TraLai] = TimeSpan.FromDays(3),
[PurchaseEvaluationPhase.ChoPurchasing] = TimeSpan.FromDays(2),
[PurchaseEvaluationPhase.ChoDuAn] = TimeSpan.FromDays(2),
[PurchaseEvaluationPhase.ChoCCM] = TimeSpan.FromDays(2),
@ -48,33 +49,37 @@ public static class PurchaseEvaluationPolicies
};
// A — DuyetNcc (3 step thực + Drafter soạn): Drafter → Purchasing → CCM → CEO
// Session 17 spec: Reject = về TraLai (Phase riêng, không revert DangSoanThao).
// Drafter từ TraLai gửi lại = entry point thứ 2 (mirror DangSoanThao → ChoPurchasing).
public static readonly PurchaseEvaluationPolicy NccOnly = new(
Name: "NccOnly",
Description: "Duyệt NCC — 3 step (Purchasing → CCM → CEO). Không cần duyệt phương án.",
Transitions: new Dictionary<(PurchaseEvaluationPhase, PurchaseEvaluationPhase), string[]>
{
// Drafter trình từ Nháp HOẶC gửi lại từ Trả lại — cùng entry point.
[(PurchaseEvaluationPhase.DangSoanThao, PurchaseEvaluationPhase.ChoPurchasing)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(PurchaseEvaluationPhase.DangSoanThao, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(PurchaseEvaluationPhase.TraLai, PurchaseEvaluationPhase.ChoPurchasing)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(PurchaseEvaluationPhase.TraLai, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager],
// Phase trung gian: 3 hành động — Duyệt forward / Trả lại Drafter (DangSoanThao) / Từ chối hoàn toàn (TuChoi).
// Trả lại = smart reject pattern Mig 16 (set RejectedFromPhase + về DangSoanThao + Drafter sửa).
// Từ chối = phiếu khoá hoàn toàn (Phase=TuChoi → 17 handler Mig 16 lock edit).
// Phase trung gian: 3 hành động — Duyệt forward / Trả lại (TraLai Phase riêng) / Từ chối (TuChoi terminal).
[(PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.ChoCCM)] = [AppRoles.Procurement],
[(PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.DangSoanThao)] = [AppRoles.Procurement],
[(PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.TraLai)] = [AppRoles.Procurement],
[(PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.Procurement],
[(PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.ChoCEODuyetNCC)] = [AppRoles.CostControl],
[(PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.DangSoanThao)] = [AppRoles.CostControl],
[(PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.TraLai)] = [AppRoles.CostControl],
[(PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.CostControl],
[(PurchaseEvaluationPhase.ChoCEODuyetNCC, PurchaseEvaluationPhase.DaDuyet)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
[(PurchaseEvaluationPhase.ChoCEODuyetNCC, PurchaseEvaluationPhase.DangSoanThao)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
[(PurchaseEvaluationPhase.ChoCEODuyetNCC, PurchaseEvaluationPhase.TraLai)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
[(PurchaseEvaluationPhase.ChoCEODuyetNCC, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
},
PhaseSla: DefaultSla,
ActivePhases:
[
PurchaseEvaluationPhase.DangSoanThao,
PurchaseEvaluationPhase.TraLai,
PurchaseEvaluationPhase.ChoPurchasing,
PurchaseEvaluationPhase.ChoCCM,
PurchaseEvaluationPhase.ChoCEODuyetNCC,
@ -90,32 +95,35 @@ public static class PurchaseEvaluationPolicies
{
[(PurchaseEvaluationPhase.DangSoanThao, PurchaseEvaluationPhase.ChoPurchasing)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(PurchaseEvaluationPhase.DangSoanThao, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(PurchaseEvaluationPhase.TraLai, PurchaseEvaluationPhase.ChoPurchasing)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(PurchaseEvaluationPhase.TraLai, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager],
// Phase trung gian: 3 hành động — Duyệt forward / Trả lại / Từ chối (xem comment NccOnly).
// Phase trung gian: 3 hành động — Duyệt forward / Trả lại (TraLai) / Từ chối (TuChoi).
[(PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.ChoDuAn)] = [AppRoles.Procurement],
[(PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.DangSoanThao)] = [AppRoles.Procurement],
[(PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.TraLai)] = [AppRoles.Procurement],
[(PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.Procurement],
[(PurchaseEvaluationPhase.ChoDuAn, PurchaseEvaluationPhase.ChoCCM)] = [AppRoles.ProjectManager],
[(PurchaseEvaluationPhase.ChoDuAn, PurchaseEvaluationPhase.DangSoanThao)] = [AppRoles.ProjectManager],
[(PurchaseEvaluationPhase.ChoDuAn, PurchaseEvaluationPhase.TraLai)] = [AppRoles.ProjectManager],
[(PurchaseEvaluationPhase.ChoDuAn, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.ProjectManager],
[(PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.ChoCEODuyetPA)] = [AppRoles.CostControl],
[(PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.DangSoanThao)] = [AppRoles.CostControl],
[(PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.TraLai)] = [AppRoles.CostControl],
[(PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.CostControl],
[(PurchaseEvaluationPhase.ChoCEODuyetPA, PurchaseEvaluationPhase.ChoCEODuyetNCC)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
[(PurchaseEvaluationPhase.ChoCEODuyetPA, PurchaseEvaluationPhase.DangSoanThao)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
[(PurchaseEvaluationPhase.ChoCEODuyetPA, PurchaseEvaluationPhase.TraLai)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
[(PurchaseEvaluationPhase.ChoCEODuyetPA, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
[(PurchaseEvaluationPhase.ChoCEODuyetNCC, PurchaseEvaluationPhase.DaDuyet)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
[(PurchaseEvaluationPhase.ChoCEODuyetNCC, PurchaseEvaluationPhase.DangSoanThao)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
[(PurchaseEvaluationPhase.ChoCEODuyetNCC, PurchaseEvaluationPhase.TraLai)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
[(PurchaseEvaluationPhase.ChoCEODuyetNCC, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
},
PhaseSla: DefaultSla,
ActivePhases:
[
PurchaseEvaluationPhase.DangSoanThao,
PurchaseEvaluationPhase.TraLai,
PurchaseEvaluationPhase.ChoPurchasing,
PurchaseEvaluationPhase.ChoDuAn,
PurchaseEvaluationPhase.ChoCCM,
@ -183,30 +191,45 @@ public static class PurchaseEvaluationPolicyRegistry
transitions[(prev.Value, s.Phase)] = roles;
if (userIds.Length > 0) userTransitions[(prev.Value, s.Phase)] = userIds;
// 3 hành động phase trung gian — Duyệt forward + Trả lại Drafter + Từ chối hoàn toàn
// Mirror: TraLai → s.Phase cho cả Drafter (resubmit từ Trả lại = entry point thứ 2)
if (prev.Value == PurchaseEvaluationPhase.DangSoanThao)
{
transitions.TryAdd((PurchaseEvaluationPhase.TraLai, s.Phase), roles);
if (userIds.Length > 0)
userTransitions.TryAdd((PurchaseEvaluationPhase.TraLai, s.Phase), userIds);
}
// 3 hành động phase trung gian — Duyệt forward + Trả lại (TraLai) + Từ chối (TuChoi)
if (prev.Value != PurchaseEvaluationPhase.DangSoanThao && s.Phase != PurchaseEvaluationPhase.DangSoanThao)
{
transitions.TryAdd((s.Phase, PurchaseEvaluationPhase.DangSoanThao), roles);
transitions.TryAdd((s.Phase, PurchaseEvaluationPhase.TraLai), roles);
transitions.TryAdd((s.Phase, PurchaseEvaluationPhase.TuChoi), roles);
if (userIds.Length > 0)
{
userTransitions.TryAdd((s.Phase, PurchaseEvaluationPhase.DangSoanThao), userIds);
userTransitions.TryAdd((s.Phase, PurchaseEvaluationPhase.TraLai), userIds);
userTransitions.TryAdd((s.Phase, PurchaseEvaluationPhase.TuChoi), userIds);
}
}
}
prev = s.Phase;
}
// First step (DangSoanThao) — Drafter có thể TuChoi (huỷ phiếu)
// First step (DangSoanThao) — Drafter có thể TuChoi (huỷ phiếu).
// Tương tự cho TraLai — Drafter có thể TuChoi luôn từ Trả lại.
if (steps.Count > 0)
{
transitions.TryAdd((steps[0].Phase, PurchaseEvaluationPhase.TuChoi),
[AppRoles.Drafter, AppRoles.DeptManager]);
transitions.TryAdd((PurchaseEvaluationPhase.TraLai, PurchaseEvaluationPhase.TuChoi),
[AppRoles.Drafter, AppRoles.DeptManager]);
}
// Terminal states always available
// Terminal states always available + TraLai phase
if (!activePhases.Contains(PurchaseEvaluationPhase.TuChoi))
activePhases.Add(PurchaseEvaluationPhase.TuChoi);
if (!activePhases.Contains(PurchaseEvaluationPhase.DaDuyet))
activePhases.Add(PurchaseEvaluationPhase.DaDuyet);
if (!activePhases.Contains(PurchaseEvaluationPhase.TraLai))
activePhases.Add(PurchaseEvaluationPhase.TraLai);
return new PurchaseEvaluationPolicy(
Name: $"{def.Code}-v{def.Version:D2}",

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",

View File

@ -45,11 +45,11 @@ public class BudgetPolicyTests
}
[Fact]
public void Default_CostControl_ChoCCM_To_DangSoanThao_Allowed()
public void Default_CostControl_ChoCCM_To_TraLai_Allowed()
{
// Trả về Drafter
// Session 17 spec: Trả lại = Phase riêng (TraLai), không revert DangSoanThao
BudgetPolicies.Default
.IsTransitionAllowed(BudgetPhase.ChoCCM, BudgetPhase.DangSoanThao,
.IsTransitionAllowed(BudgetPhase.ChoCCM, BudgetPhase.TraLai,
[AppRoles.CostControl])
.Should().BeTrue();
}
@ -93,11 +93,12 @@ public class BudgetPolicyTests
}
[Fact]
public void Default_ActivePhases_Includes_All5States()
public void Default_ActivePhases_Includes_All6States()
{
// Session 17: thêm TraLai = Phase riêng cho Trả lại
BudgetPolicies.Default.ActivePhases.Should().BeEquivalentTo(new[]
{
BudgetPhase.DangSoanThao, BudgetPhase.ChoCCM,
BudgetPhase.DangSoanThao, BudgetPhase.TraLai, BudgetPhase.ChoCCM,
BudgetPhase.ChoCEO, BudgetPhase.DaDuyet, BudgetPhase.TuChoi,
});
}
@ -111,11 +112,21 @@ public class BudgetPolicyTests
}
[Fact]
public void Default_NextPhasesFrom_ChoCEO_Includes_DaDuyet_And_DangSoanThao()
public void Default_NextPhasesFrom_TraLai_Includes_ChoCCM_And_TuChoi()
{
// Drafter từ TraLai gửi lại = entry point thứ 2 (mirror DangSoanThao)
var next = BudgetPolicies.Default.NextPhasesFrom(BudgetPhase.TraLai);
next.Should().Contain(BudgetPhase.ChoCCM);
next.Should().Contain(BudgetPhase.TuChoi);
}
[Fact]
public void Default_NextPhasesFrom_ChoCEO_Includes_DaDuyet_And_TraLai()
{
var next = BudgetPolicies.Default.NextPhasesFrom(BudgetPhase.ChoCEO);
next.Should().Contain(BudgetPhase.DaDuyet);
next.Should().Contain(BudgetPhase.DangSoanThao);
next.Should().Contain(BudgetPhase.TraLai);
next.Should().Contain(BudgetPhase.TuChoi);
}
// SLA — chống regression khi đổi phase deadline accidentally

View File

@ -90,12 +90,23 @@ public class WorkflowPolicyTests
}
[Fact]
public void Standard_RejectFromCCM_GoesBackToDraft()
public void Standard_RejectFromCCM_GoesTo_TraLai()
{
// Session 17 spec: Trả lại = Phase riêng (TraLai), không revert DangSoanThao
WorkflowPolicies.Standard
.IsTransitionAllowed(ContractPhase.DangKiemTraCCM, ContractPhase.DangSoanThao,
.IsTransitionAllowed(ContractPhase.DangKiemTraCCM, ContractPhase.TraLai,
[AppRoles.CostControl])
.Should().BeTrue("CCM có quyền reject về Drafter");
.Should().BeTrue("CCM có quyền reject về TraLai");
}
[Fact]
public void Standard_TraLai_To_DangGopY_Allowed_For_Drafter()
{
// Drafter từ TraLai gửi lại = entry point thứ 2 (mirror DangSoanThao → DangGopY)
WorkflowPolicies.Standard
.IsTransitionAllowed(ContractPhase.TraLai, ContractPhase.DangGopY,
[AppRoles.Drafter])
.Should().BeTrue("Drafter resubmit từ Trả lại");
}
[Fact]

View File

@ -131,17 +131,33 @@ public class PurchaseEvaluationPolicyTests
[Theory]
[InlineData(nameof(PurchaseEvaluationPolicies.NccOnly))]
[InlineData(nameof(PurchaseEvaluationPolicies.NccWithPlan))]
public void BothPolicies_RejectFromCCM_GoesBackTo_DangSoanThao(string policyName)
public void BothPolicies_RejectFromCCM_GoesTo_TraLai(string policyName)
{
// Session 17 spec: Trả lại = Phase riêng (TraLai), không revert DangSoanThao
var policy = policyName == nameof(PurchaseEvaluationPolicies.NccOnly)
? PurchaseEvaluationPolicies.NccOnly
: PurchaseEvaluationPolicies.NccWithPlan;
policy.IsTransitionAllowed(PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.DangSoanThao,
policy.IsTransitionAllowed(PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.TraLai,
[AppRoles.CostControl])
.Should().BeTrue();
}
[Theory]
[InlineData(nameof(PurchaseEvaluationPolicies.NccOnly))]
[InlineData(nameof(PurchaseEvaluationPolicies.NccWithPlan))]
public void BothPolicies_TraLai_To_ChoPurchasing_AllowedForDrafter(string policyName)
{
// Drafter từ TraLai gửi lại = entry point thứ 2 (mirror DangSoanThao)
var policy = policyName == nameof(PurchaseEvaluationPolicies.NccOnly)
? PurchaseEvaluationPolicies.NccOnly
: PurchaseEvaluationPolicies.NccWithPlan;
policy.IsTransitionAllowed(PurchaseEvaluationPhase.TraLai, PurchaseEvaluationPhase.ChoPurchasing,
[AppRoles.Drafter])
.Should().BeTrue();
}
[Theory]
[InlineData(nameof(PurchaseEvaluationPolicies.NccOnly))]
[InlineData(nameof(PurchaseEvaluationPolicies.NccWithPlan))]