[CLAUDE] PE-Workflow: S21 t5 Chunk A — Mig 29 refactor Allow* sang per-NV (per-Level + per-Drafter)

Refactor 6 Allow* options từ workflow-level (Mig 28 S21 t4) sang per-NV scope:
- F1 (4 mode Trả lại) + F3 (Edit Section 2) → 5 flag MOVE xuống
  `ApprovalWorkflowLevels` (per slot Approver, cùng table với ApproverUserId).
- F2 (AllowDrafterSkipToFinal) → MOVE xuống `Users` (per-Drafter user, User Mgmt).

Mig 29 `RefactorAdvancedOptionsToPerLevelAndDrafterUser` — 4-stage migration
(EF auto-generated drop-then-add đã được REORDER manual):
1. ADD 5 column trên `ApprovalWorkflowLevels` (AllowReturnOneLevel/OneStep/
   ToAssignee/ToDrafter[default true]/AllowApproverEditDetails)
2. ADD 1 column trên `Users` (AllowDrafterSkipToFinal default false)
3. BACKFILL bulk SQL (preserve admin config Mig 28):
   - Levels: copy workflow.Allow* → all Levels của workflow (JOIN Steps)
   - Users: SET TRUE cho user nào từng Drafter PE link workflow Allow=true
4. DROP 6 column workflow-level (Mig 28 cleanup)
3-file rule complete. Apply LocalDB Dev + Design success.

Domain entity refactor:
- `ApprovalWorkflow.cs` — REMOVE 6 Allow* field (S21 t4 Mig 28 cũ)
- `ApprovalWorkflowLevel.cs` — ADD 5 Allow* field (F1 + F3)
- `User.cs` — ADD 1 Allow* field (F2 AllowDrafterSkipToFinal)

EF config update:
- `ApprovalWorkflowConfiguration.cs` — remove 6 HasDefaultValue workflow-level,
  add 5 HasDefaultValue per-Level (4 false + 1 AllowReturnToDrafter true S17)

Service refactor `ApplyReturnModeAsync` (`PurchaseEvaluationWorkflowService.cs`):
- Resolve currentLevel slot (CurrentWorkflowStepIndex + CurrentApprovalLevelOrder)
- Read 5 Allow* từ `currentLevel.AllowXxx` thay vì workflow.Allow*
- Admin bypass per-Level flag check (unchanged behavior)
- Drafter mode đặc biệt: check AllowReturnToDrafter của currentLevel (vẫn validate)
- V1 legacy (no V2 schema) → fallback Drafter behavior tự động

DRAFTER trình refactor (`TransitionAsync` skipToFinal branch):
- Permission check moved from workflow-level → `drafterUser.AllowDrafterSkipToFinal`
- Use `userManager.FindByIdAsync(actorUserId)` để get current Drafter user entity
- Admin bypass user flag check (unchanged)

Helper `EnsureEditableForDetailsAsync` refactor:
- Read `level.AllowApproverEditDetails` thay vì workflow.AllowApproverEditDetails
- Error message rõ "Cấp Approver hiện tại (Bước X / Cấp Y)" thay vì "Workflow"

DTO refactor:
- `AwLevelDto` ADD 5 Allow* field (admin Designer GET per-Level)
- `AwDefinitionDto` REMOVE 6 Allow* (no longer workflow-level)
- `CreateAwLevelInput` ADD 5 Allow* param (admin Designer POST per-Level)
- `CreateAwDefinitionCommand` REMOVE 6 Allow* (Steps[].Levels[] now has them)
- `ApprovalWorkflowOptionsDto` chỉ còn 5 flag (F2 removed — separate field)
- `PurchaseEvaluationDetailBundleDto`:
  - rename `WorkflowOptions` → `CurrentLevelOptions` (clearer semantic per-slot)
  - ADD `DrafterAllowSkipToFinal bool` (resolve từ DrafterUserId → User entity)

GetPurchaseEvaluationQueryHandler populate:
- `currentLevelOptions` = 5 Allow* của Cấp hiện tại (null nếu V1 legacy / no pointer)
- `drafterAllowSkipToFinal` = User.AllowDrafterSkipToFinal lookup từ DrafterUserId

Backward compat verified:
- Mig 29 backfill preserve admin config S21 t4 — workflow cũ vẫn chạy đúng
  sau deploy. User chưa từng làm Drafter F2 phải opt-in lần đầu (no auto-set).
- 84 test PASS (58 Domain + 26 Infra unchanged, 3 gotcha #45 guard test backward
  compat signature).

Pending Chunk B/C: FE Admin Designer move 5 checkbox xuống per-Level slot + FE
eOffice read currentLevelOptions + drafterAllowSkipToFinal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-13 20:03:28 +07:00
parent eea86fdfe7
commit 036694638e
11 changed files with 4361 additions and 177 deletions

View File

@ -19,14 +19,9 @@ public class ApprovalWorkflowConfiguration : IEntityTypeConfiguration<ApprovalWo
e.HasIndex(x => new { x.Code, x.Version }).IsUnique();
e.HasIndex(x => new { x.ApplicableType, x.IsActive });
// Mig 28 6 advanced options. 5 default false (admin opt-in). 1
// AllowReturnToDrafter default true (backward compat S17 fallback).
e.Property(x => x.AllowReturnOneLevel).HasDefaultValue(false);
e.Property(x => x.AllowReturnOneStep).HasDefaultValue(false);
e.Property(x => x.AllowReturnToAssignee).HasDefaultValue(false);
e.Property(x => x.AllowReturnToDrafter).HasDefaultValue(true);
e.Property(x => x.AllowDrafterSkipToFinal).HasDefaultValue(false);
e.Property(x => x.AllowApproverEditDetails).HasDefaultValue(false);
// Mig 28 6 column Allow* đã DROP trong Mig 29 (S21 t5) — refactor sang
// per-NV (Level table cho F1+F3, Users table cho F2). Backfill bulk SQL
// preserve config admin từ S21 t4 trước khi drop.
}
}
@ -74,5 +69,13 @@ public class ApprovalWorkflowLevelConfiguration : IEntityTypeConfiguration<Appro
e.HasIndex(x => new { x.ApprovalWorkflowStepId, x.Order });
e.HasIndex(x => x.ApproverUserId);
// Mig 29 (S21 t5) — 5 per-NV advanced options. 4 default false (admin
// opt-in). 1 AllowReturnToDrafter default true (backward compat S17).
e.Property(x => x.AllowReturnOneLevel).HasDefaultValue(false);
e.Property(x => x.AllowReturnOneStep).HasDefaultValue(false);
e.Property(x => x.AllowReturnToAssignee).HasDefaultValue(false);
e.Property(x => x.AllowReturnToDrafter).HasDefaultValue(true);
e.Property(x => x.AllowApproverEditDetails).HasDefaultValue(false);
}
}

View File

@ -0,0 +1,196 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class RefactorAdvancedOptionsToPerLevelAndDrafterUser : Migration
{
// Mig 29 (S21 t5) — Refactor Allow* options từ workflow-level (Mig 28
// S21 t4) sang per-NV:
// - F1 (4 mode Trả lại) + F3 (Edit Section 2) = 5 flag move xuống
// ApprovalWorkflowLevels (per slot Approver).
// - F2 (AllowDrafterSkipToFinal) move xuống Users (per-Drafter user).
//
// Migration order: ADD columns mới TRƯỚC → BACKFILL bulk SQL từ workflow
// → DROP columns workflow-level. Preserve admin config S21 t4 (Mig 28).
//
// EF auto-generated order (drop-then-add) đã được reorder manual.
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// ===== Stage 1: ADD 5 column trên ApprovalWorkflowLevels (per slot) =====
migrationBuilder.AddColumn<bool>(
name: "AllowReturnOneLevel",
table: "ApprovalWorkflowLevels",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "AllowReturnOneStep",
table: "ApprovalWorkflowLevels",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "AllowReturnToAssignee",
table: "ApprovalWorkflowLevels",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "AllowReturnToDrafter",
table: "ApprovalWorkflowLevels",
type: "bit",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "AllowApproverEditDetails",
table: "ApprovalWorkflowLevels",
type: "bit",
nullable: false,
defaultValue: false);
// ===== Stage 2: ADD 1 column trên Users (per-Drafter F2) =====
migrationBuilder.AddColumn<bool>(
name: "AllowDrafterSkipToFinal",
table: "Users",
type: "bit",
nullable: false,
defaultValue: false);
// ===== Stage 3: BACKFILL bulk từ workflow-level (Mig 28) =====
// Copy 5 F1+F3 flag từ workflow → all Levels của workflow đó.
// SQL Server compatible (UPDATE ... FROM ... JOIN ...).
migrationBuilder.Sql(@"
UPDATE l SET
l.AllowReturnOneLevel = w.AllowReturnOneLevel,
l.AllowReturnOneStep = w.AllowReturnOneStep,
l.AllowReturnToAssignee = w.AllowReturnToAssignee,
l.AllowReturnToDrafter = w.AllowReturnToDrafter,
l.AllowApproverEditDetails = w.AllowApproverEditDetails
FROM ApprovalWorkflowLevels l
INNER JOIN ApprovalWorkflowSteps s ON s.Id = l.ApprovalWorkflowStepId
INNER JOIN ApprovalWorkflows w ON w.Id = s.ApprovalWorkflowId;
");
// Backfill Users.AllowDrafterSkipToFinal: set TRUE cho user nào
// từng làm Drafter PE pin workflow có AllowDrafterSkipToFinal=true.
// Conservative: preserve admin config Mig 28 cho user thực tế dùng,
// các user khác giữ false (admin opt-in lần đầu).
migrationBuilder.Sql(@"
UPDATE u SET u.AllowDrafterSkipToFinal = 1
FROM Users u
WHERE EXISTS (
SELECT 1
FROM PurchaseEvaluations pe
INNER JOIN ApprovalWorkflows w ON w.Id = pe.ApprovalWorkflowId
WHERE pe.DrafterUserId = u.Id
AND w.AllowDrafterSkipToFinal = 1
);
");
// ===== Stage 4: DROP 6 column workflow-level (Mig 28 cleanup) =====
migrationBuilder.DropColumn(
name: "AllowApproverEditDetails",
table: "ApprovalWorkflows");
migrationBuilder.DropColumn(
name: "AllowDrafterSkipToFinal",
table: "ApprovalWorkflows");
migrationBuilder.DropColumn(
name: "AllowReturnOneLevel",
table: "ApprovalWorkflows");
migrationBuilder.DropColumn(
name: "AllowReturnOneStep",
table: "ApprovalWorkflows");
migrationBuilder.DropColumn(
name: "AllowReturnToAssignee",
table: "ApprovalWorkflows");
migrationBuilder.DropColumn(
name: "AllowReturnToDrafter",
table: "ApprovalWorkflows");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Rollback: re-add 6 column workflow-level + drop 5 Level + 1 User.
// No reverse backfill (data loss accepted khi rollback).
migrationBuilder.AddColumn<bool>(
name: "AllowApproverEditDetails",
table: "ApprovalWorkflows",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "AllowDrafterSkipToFinal",
table: "ApprovalWorkflows",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "AllowReturnOneLevel",
table: "ApprovalWorkflows",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "AllowReturnOneStep",
table: "ApprovalWorkflows",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "AllowReturnToAssignee",
table: "ApprovalWorkflows",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "AllowReturnToDrafter",
table: "ApprovalWorkflows",
type: "bit",
nullable: false,
defaultValue: true);
migrationBuilder.DropColumn(
name: "AllowDrafterSkipToFinal",
table: "Users");
migrationBuilder.DropColumn(
name: "AllowApproverEditDetails",
table: "ApprovalWorkflowLevels");
migrationBuilder.DropColumn(
name: "AllowReturnOneLevel",
table: "ApprovalWorkflowLevels");
migrationBuilder.DropColumn(
name: "AllowReturnOneStep",
table: "ApprovalWorkflowLevels");
migrationBuilder.DropColumn(
name: "AllowReturnToAssignee",
table: "ApprovalWorkflowLevels");
migrationBuilder.DropColumn(
name: "AllowReturnToDrafter",
table: "ApprovalWorkflowLevels");
}
}
}

View File

@ -134,36 +134,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<DateTime?>("ActivatedAt")
.HasColumnType("datetime2");
b.Property<bool>("AllowApproverEditDetails")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool>("AllowDrafterSkipToFinal")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool>("AllowReturnOneLevel")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool>("AllowReturnOneStep")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool>("AllowReturnToAssignee")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool>("AllowReturnToDrafter")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(true);
b.Property<int>("ApplicableType")
.HasColumnType("int");
@ -218,6 +188,31 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<bool>("AllowApproverEditDetails")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool>("AllowReturnOneLevel")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool>("AllowReturnOneStep")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool>("AllowReturnToAssignee")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool>("AllowReturnToDrafter")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(true);
b.Property<Guid>("ApprovalWorkflowStepId")
.HasColumnType("uniqueidentifier");
@ -1945,6 +1940,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<int>("AccessFailedCount")
.HasColumnType("int");
b.Property<bool>("AllowDrafterSkipToFinal")
.HasColumnType("bit");
b.Property<bool>("CanBypassReview")
.HasColumnType("bit");

View File

@ -4,6 +4,7 @@ using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Notifications;
using SolutionErp.Application.PurchaseEvaluations.Services;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Common;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Identity;
@ -111,19 +112,27 @@ public class PurchaseEvaluationWorkflowService(
}
evaluation.Phase = PurchaseEvaluationPhase.ChoDuyet;
// 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.
// F2 (Mig 29 — S21 t5) — Drafter skip thẳng Cấp cuối. Permission
// check moved sang `User.AllowDrafterSkipToFinal` (per-Drafter user,
// không còn workflow-level Mig 28).
// Admin bypass user flag check.
if (skipToFinal && evaluation.ApprovalWorkflowId is Guid skipAwId)
{
if (!isAdmin)
{
if (actorUserId is null)
throw new ConflictException("skipToFinal yêu cầu authenticated user.");
var drafterUser = await userManager.FindByIdAsync(actorUserId.Value.ToString())
?? throw new ConflictException("User không tồn tại.");
if (!drafterUser.AllowDrafterSkipToFinal)
throw new ConflictException(
$"User '{drafterUser.FullName}' không được phép gửi thẳng Cấp cuối. " +
"Liên hệ Admin để cấp quyền ở User Management.");
}
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
@ -189,18 +198,57 @@ public class PurchaseEvaluationWorkflowService(
bool isAdmin,
CancellationToken ct)
{
// Mode Drafter — Session 17 default (always allowed for backward compat,
// workflow.AllowReturnToDrafter default true).
// Mig 29 (S21 t5) refactor: Allow* flag đã move xuống ApprovalWorkflowLevel
// (per-slot Approver). Cần load workflow Steps+Levels để lấy Level hiện
// tại (curStepIdx + curLevel).
if (evaluation.ApprovalWorkflowId is not Guid awId)
{
// Phiếu V1 legacy không có Allow* → fallback Drafter (S17 behavior).
evaluation.Phase = PurchaseEvaluationPhase.TraLai;
evaluation.CurrentWorkflowStepIndex = null;
evaluation.CurrentApprovalLevelOrder = null;
evaluation.SlaDeadline = null;
return mode == WorkflowReturnMode.Drafter
? "Trả về Người soạn thảo"
: $"Trả về Người soạn thảo (fallback — phiếu V1 không hỗ trợ mode '{mode}')";
}
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.");
var stepsOrdered = workflow.Steps.OrderBy(s => s.Order).ToList();
// Resolve Level hiện tại (slot Approver đang duyệt) — đọc Allow* từ slot
// này. Required cho mọi mode (kể cả Drafter — Approver hiện tại quyết
// định mode Trả lại theo flag riêng của slot).
ApprovalWorkflowLevel? currentLevel = null;
if (evaluation.CurrentWorkflowStepIndex is int csi && csi >= 0 && csi < stepsOrdered.Count)
{
var step = stepsOrdered[csi];
currentLevel = step.Levels.FirstOrDefault(l => l.Order == evaluation.CurrentApprovalLevelOrder);
}
// Validate Allow* flag từ Level slot hiện tại (Admin bypass)
if (!isAdmin && currentLevel is not null)
{
var allowed = mode switch
{
WorkflowReturnMode.OneLevel => currentLevel.AllowReturnOneLevel,
WorkflowReturnMode.OneStep => currentLevel.AllowReturnOneStep,
WorkflowReturnMode.Assignee => currentLevel.AllowReturnToAssignee,
WorkflowReturnMode.Drafter => currentLevel.AllowReturnToDrafter,
_ => false,
};
if (!allowed)
throw new ConflictException(
$"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)
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;
@ -208,38 +256,12 @@ public class PurchaseEvaluationWorkflowService(
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).");
// 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 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)