Compare commits

...

2 Commits

Author SHA1 Message Date
b04a11a62f [CLAUDE] FE-PE: S22+5 Chunk B — Designer checkbox +AllowApproverEditBudget per slot + Section read flag (mirror 2 app)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m23s
FE Admin Designer (ApprovalWorkflowsV2Page.tsx):
- LevelDto + EditLevelEntry +allowApproverEditBudget
- copyFromDefinition + makeDefaultLevelEntry propagate default false
- POST body include allowApproverEditBudget cho mỗi Level slot
- NEW checkbox UI per Level inline panel:
  "Cho phép chỉnh sửa Section ngân sách lúc đang duyệt"
  (col-span-2, mirror pattern allowApproverEditDetails Mig 29)

FE Types mirror 2 app:
- fe-admin + fe-user `ApprovalWorkflowOptions` +allowApproverEditBudget

FE BudgetAdjustSection refactor (mirror 2 app):
- Trước: isApproverChoDuyet = phase ChoDuyet + actor in approvers
- Sau: isApproverChoDuyet = phase ChoDuyet + actor in approvers
  + currentLevelOptions.allowApproverEditBudget=true (per slot opt-in)
- Drafter scope Nháp/Trả lại unchanged
- Admin bypass unchanged

UX impact:
- Admin Designer phải tick checkbox cho NV slot mới được edit ngân sách lúc duyệt
- Nếu KHÔNG tick → button "Điều chỉnh" trong Section 5 KHÔNG hiện cho approver
- Drafter vẫn edit bình thường khi phiếu Nháp/Trả lại

Verify:
- npm run build fe-admin — 569ms pass
- npm run build fe-user — 528ms pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:12:43 +07:00
b079b27343 [CLAUDE] PE-Workflow: S22+5 Chunk A — Mig 30 +AllowApproverEditBudget per-Level slot
Bro clarify spec S22+4:
- KHÔNG đổi logic edit ngân sách (Drafter Nháp/TraLai vẫn duy nhất default)
- Thêm flag per-NV slot trong Designer: "Cho phép NV này edit Section ngân sách
  lúc đang duyệt" (mirror pattern F3 AllowApproverEditDetails Mig 29)

Mig 30 `AddAllowApproverEditBudgetToLevels`:
- ALTER ApprovalWorkflowLevels +AllowApproverEditBudget bit NOT NULL DEFAULT 0
- 3-file rule (mig.cs + Designer.cs + Snapshot.cs)
- Apply LocalDB Dev + Design

Domain entity ApprovalWorkflowLevel +AllowApproverEditBudget (default false).
EF config HasDefaultValue(false). DTO AwLevelDto + ApprovalWorkflowOptionsDto
+ CreateAwLevelInput all extend +AllowApproverEditBudget.

PE GET handler populate currentLevelOptions thêm AllowApproverEditBudget từ
curLevel slot. Admin Designer GET/POST handler propagate flag.

AdjustBudgetCommand handler refactor ChoDuyet branch:
- Trước: check actor match level.ApproverUserId (cho phép mặc định)
- Sau: check level.AllowApproverEditBudget=true AND actor match ApproverUserId
  → throw ConflictException nếu slot chưa được cấp quyền

Verify:
- dotnet build SolutionErp.slnx — 0 err, 2 warn DocxRenderer pre-existing
- Mig 30 applied Dev + Design DB

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:09:48 +07:00
13 changed files with 4034 additions and 12 deletions

View File

@ -974,9 +974,13 @@ function BudgetAdjustSection({ ev, readOnly }: { ev: PeDetailBundle; readOnly: b
const isDrafter = currentUser?.id != null && ev.drafterUserId === currentUser.id const isDrafter = currentUser?.id != null && ev.drafterUserId === currentUser.id
const isDrafterPhase = ev.phase === PurchaseEvaluationPhase.DangSoanThao const isDrafterPhase = ev.phase === PurchaseEvaluationPhase.DangSoanThao
|| ev.phase === PurchaseEvaluationPhase.TraLai || ev.phase === PurchaseEvaluationPhase.TraLai
// Approver currentLevel match — phase ChoDuyet + actor in current approval level // F4 Approver scope (Mig 30): phase ChoDuyet + actor in currentApproval.approvers
// + currentLevel có flag AllowApproverEditBudget=true (admin Designer tick per slot).
const actorInCurrentLevel = ev.currentApproval?.approvers?.some(a => a.userId === currentUser?.id) ?? false const actorInCurrentLevel = ev.currentApproval?.approvers?.some(a => a.userId === currentUser?.id) ?? false
const isApproverChoDuyet = ev.phase === PurchaseEvaluationPhase.ChoDuyet && actorInCurrentLevel const approverEditBudgetAllowed = ev.currentLevelOptions?.allowApproverEditBudget ?? false
const isApproverChoDuyet = ev.phase === PurchaseEvaluationPhase.ChoDuyet
&& actorInCurrentLevel
&& approverEditBudgetAllowed
const canAdjust = !readOnly && (isAdmin || (isDrafter && isDrafterPhase) || isApproverChoDuyet) const canAdjust = !readOnly && (isAdmin || (isDrafter && isDrafterPhase) || isApproverChoDuyet)

View File

@ -42,11 +42,13 @@ type LevelDto = {
approverUserName: string | null approverUserName: string | null
approverEmail: string | null approverEmail: string | null
// Mig 29 (S21 t5) — 5 Allow* options per slot Approver // Mig 29 (S21 t5) — 5 Allow* options per slot Approver
// Mig 30 (S22+5) — +AllowApproverEditBudget cho Section ngân sách
allowReturnOneLevel: boolean allowReturnOneLevel: boolean
allowReturnOneStep: boolean allowReturnOneStep: boolean
allowReturnToAssignee: boolean allowReturnToAssignee: boolean
allowReturnToDrafter: boolean allowReturnToDrafter: boolean
allowApproverEditDetails: boolean allowApproverEditDetails: boolean
allowApproverEditBudget: boolean
} }
type StepDto = { type StepDto = {
id: string id: string
@ -86,11 +88,13 @@ type EditLevelEntry = {
approverUserId: string approverUserId: string
// Mig 29 (S21 t5) — 5 Allow* per slot (default backward compat S17: chỉ // Mig 29 (S21 t5) — 5 Allow* per slot (default backward compat S17: chỉ
// AllowReturnToDrafter=true, 4 còn lại false). // AllowReturnToDrafter=true, 4 còn lại false).
// Mig 30 (S22+5) — +AllowApproverEditBudget cho Section ngân sách (default false).
allowReturnOneLevel: boolean allowReturnOneLevel: boolean
allowReturnOneStep: boolean allowReturnOneStep: boolean
allowReturnToAssignee: boolean allowReturnToAssignee: boolean
allowReturnToDrafter: boolean allowReturnToDrafter: boolean
allowApproverEditDetails: boolean allowApproverEditDetails: boolean
allowApproverEditBudget: boolean
} }
type EditStep = { name: string; departmentId: string | null; levelEntries: EditLevelEntry[] } type EditStep = { name: string; departmentId: string | null; levelEntries: EditLevelEntry[] }
@ -137,6 +141,7 @@ function copyFromDefinition(d: DefinitionDto): EditStep[] {
allowReturnToAssignee: l.allowReturnToAssignee ?? false, allowReturnToAssignee: l.allowReturnToAssignee ?? false,
allowReturnToDrafter: l.allowReturnToDrafter ?? true, allowReturnToDrafter: l.allowReturnToDrafter ?? true,
allowApproverEditDetails: l.allowApproverEditDetails ?? false, allowApproverEditDetails: l.allowApproverEditDetails ?? false,
allowApproverEditBudget: l.allowApproverEditBudget ?? false,
})), })),
})) }))
} }
@ -152,6 +157,7 @@ function makeDefaultLevelEntry(order: LevelOrder, approverUserId: string): EditL
allowReturnToAssignee: false, allowReturnToAssignee: false,
allowReturnToDrafter: true, allowReturnToDrafter: true,
allowApproverEditDetails: false, allowApproverEditDetails: false,
allowApproverEditBudget: false,
} }
} }
@ -553,6 +559,7 @@ function Designer({
allowReturnToAssignee: e.allowReturnToAssignee, allowReturnToAssignee: e.allowReturnToAssignee,
allowReturnToDrafter: e.allowReturnToDrafter, allowReturnToDrafter: e.allowReturnToDrafter,
allowApproverEditDetails: e.allowApproverEditDetails, allowApproverEditDetails: e.allowApproverEditDetails,
allowApproverEditBudget: e.allowApproverEditBudget,
})), })),
})), })),
}) })
@ -911,6 +918,15 @@ function Designer({
/> />
<span>Cho phép chỉnh sửa Section 2 (Hạng mục/NCC/Báo giá) lúc đang duyệt</span> <span>Cho phép chỉnh sửa Section 2 (Hạng mục/NCC/Báo giá) lúc đang duyệt</span>
</label> </label>
<label className="col-span-2 flex items-center gap-1 text-[11px] text-slate-700">
<input
type="checkbox"
className="h-3 w-3"
checked={entry.allowApproverEditBudget}
onChange={e => updateField('allowApproverEditBudget', e.target.checked)}
/>
<span>Cho phép chỉnh sửa Section ngân sách lúc đang duyệt</span>
</label>
</div> </div>
</div> </div>
) )

View File

@ -355,6 +355,7 @@ export type ApprovalWorkflowOptions = {
allowReturnToAssignee: boolean allowReturnToAssignee: boolean
allowReturnToDrafter: boolean allowReturnToDrafter: boolean
allowApproverEditDetails: boolean allowApproverEditDetails: boolean
allowApproverEditBudget: boolean // Mig 30 (S22+5) — F4 Section ngân sách
} }
// Mig 28 (S21 t4) — F1 mode Trả lại payload gửi BE // Mig 28 (S21 t4) — F1 mode Trả lại payload gửi BE

View File

@ -981,8 +981,12 @@ function BudgetAdjustSection({ ev, readOnly }: { ev: PeDetailBundle; readOnly: b
const isDrafter = currentUser?.id != null && ev.drafterUserId === currentUser.id const isDrafter = currentUser?.id != null && ev.drafterUserId === currentUser.id
const isDrafterPhase = ev.phase === PurchaseEvaluationPhase.DangSoanThao const isDrafterPhase = ev.phase === PurchaseEvaluationPhase.DangSoanThao
|| ev.phase === PurchaseEvaluationPhase.TraLai || ev.phase === PurchaseEvaluationPhase.TraLai
// F4 Approver scope (Mig 30): ChoDuyet + actor in approvers + flag tick.
const actorInCurrentLevel = ev.currentApproval?.approvers?.some(a => a.userId === currentUser?.id) ?? false const actorInCurrentLevel = ev.currentApproval?.approvers?.some(a => a.userId === currentUser?.id) ?? false
const isApproverChoDuyet = ev.phase === PurchaseEvaluationPhase.ChoDuyet && actorInCurrentLevel const approverEditBudgetAllowed = ev.currentLevelOptions?.allowApproverEditBudget ?? false
const isApproverChoDuyet = ev.phase === PurchaseEvaluationPhase.ChoDuyet
&& actorInCurrentLevel
&& approverEditBudgetAllowed
const canAdjust = !readOnly && (isAdmin || (isDrafter && isDrafterPhase) || isApproverChoDuyet) const canAdjust = !readOnly && (isAdmin || (isDrafter && isDrafterPhase) || isApproverChoDuyet)

View File

@ -353,6 +353,7 @@ export type ApprovalWorkflowOptions = {
allowReturnToAssignee: boolean allowReturnToAssignee: boolean
allowReturnToDrafter: boolean allowReturnToDrafter: boolean
allowApproverEditDetails: boolean allowApproverEditDetails: boolean
allowApproverEditBudget: boolean // Mig 30 (S22+5) — F4 Section ngân sách
} }
// Mig 28 (S21 t4) — F1 mode Trả lại payload gửi BE // Mig 28 (S21 t4) — F1 mode Trả lại payload gửi BE

View File

@ -28,12 +28,14 @@ public record AwLevelDto(
string? ApproverUserName, string? ApproverUserName,
string? ApproverEmail, string? ApproverEmail,
// Mig 29 (S21 t5) — 5 advanced options per slot Approver (F1 mode Trả lại // Mig 29 (S21 t5) — 5 advanced options per slot Approver (F1 mode Trả lại
// + F3 Edit Section 2). Mỗi NV trong workflow có quyền riêng. // + F3 Edit Section 2). Mig 30 (S22+5) — F4 +AllowApproverEditBudget.
// Mỗi NV trong workflow có quyền riêng.
bool AllowReturnOneLevel, bool AllowReturnOneLevel,
bool AllowReturnOneStep, bool AllowReturnOneStep,
bool AllowReturnToAssignee, bool AllowReturnToAssignee,
bool AllowReturnToDrafter, bool AllowReturnToDrafter,
bool AllowApproverEditDetails); bool AllowApproverEditDetails,
bool AllowApproverEditBudget);
public record AwStepDto( public record AwStepDto(
Guid Id, Guid Id,
@ -152,7 +154,7 @@ public class GetAwAdminOverviewQueryHandler(
// Mig 29 (S21 t5) — 5 Allow* flag per slot Level // Mig 29 (S21 t5) — 5 Allow* flag per slot Level
return new AwLevelDto(l.Id, l.Order, l.Name, l.ApproverUserId, info.FullName, info.Email, return new AwLevelDto(l.Id, l.Order, l.Name, l.ApproverUserId, info.FullName, info.Email,
l.AllowReturnOneLevel, l.AllowReturnOneStep, l.AllowReturnToAssignee, l.AllowReturnOneLevel, l.AllowReturnOneStep, l.AllowReturnToAssignee,
l.AllowReturnToDrafter, l.AllowApproverEditDetails); l.AllowReturnToDrafter, l.AllowApproverEditDetails, l.AllowApproverEditBudget);
}).ToList() }).ToList()
)).ToList()); )).ToList());
@ -184,12 +186,13 @@ public record CreateAwLevelInput(
Guid ApproverUserId, Guid ApproverUserId,
// Mig 29 (S21 t5) — 5 Allow* options per slot. Admin Designer tick per // Mig 29 (S21 t5) — 5 Allow* options per slot. Admin Designer tick per
// Level row. Default backward compat: AllowReturnToDrafter=true, 4 còn lại // Level row. Default backward compat: AllowReturnToDrafter=true, 4 còn lại
// false (admin opt-in từng slot). // false (admin opt-in từng slot). Mig 30 (S22+5) — F4 AllowApproverEditBudget.
bool AllowReturnOneLevel = false, bool AllowReturnOneLevel = false,
bool AllowReturnOneStep = false, bool AllowReturnOneStep = false,
bool AllowReturnToAssignee = false, bool AllowReturnToAssignee = false,
bool AllowReturnToDrafter = true, bool AllowReturnToDrafter = true,
bool AllowApproverEditDetails = false); bool AllowApproverEditDetails = false,
bool AllowApproverEditBudget = false);
public record CreateAwStepInput( public record CreateAwStepInput(
int Order, int Order,
@ -315,6 +318,7 @@ public class CreateAwDefinitionCommandHandler(IApplicationDbContext db)
AllowReturnToAssignee = l.AllowReturnToAssignee, AllowReturnToAssignee = l.AllowReturnToAssignee,
AllowReturnToDrafter = l.AllowReturnToDrafter, AllowReturnToDrafter = l.AllowReturnToDrafter,
AllowApproverEditDetails = l.AllowApproverEditDetails, AllowApproverEditDetails = l.AllowApproverEditDetails,
AllowApproverEditBudget = l.AllowApproverEditBudget,
}).ToList(), }).ToList(),
}) })
.ToList(), .ToList(),

View File

@ -82,12 +82,14 @@ public record PurchaseEvaluationChangelogDto(
// FE eOffice filter Trả lại dropdown + Edit Section 2 enabled theo flag của // FE eOffice filter Trả lại dropdown + Edit Section 2 enabled theo flag của
// Cấp hiện tại NV đang duyệt. Null nếu phiếu V1 legacy hoặc không ChoDuyet. // Cấp hiện tại NV đang duyệt. Null nếu phiếu V1 legacy hoặc không ChoDuyet.
// F2 (Drafter skip) đã move sang `PeDetailBundleDto.DrafterAllowSkipToFinal`. // F2 (Drafter skip) đã move sang `PeDetailBundleDto.DrafterAllowSkipToFinal`.
// Mig 30 (S22+5) — F4 +AllowApproverEditBudget cho Section "Điều chỉnh ngân sách".
public record ApprovalWorkflowOptionsDto( public record ApprovalWorkflowOptionsDto(
bool AllowReturnOneLevel, bool AllowReturnOneLevel,
bool AllowReturnOneStep, bool AllowReturnOneStep,
bool AllowReturnToAssignee, bool AllowReturnToAssignee,
bool AllowReturnToDrafter, bool AllowReturnToDrafter,
bool AllowApproverEditDetails); bool AllowApproverEditDetails,
bool AllowApproverEditBudget);
public record PurchaseEvaluationWorkflowSummaryDto( public record PurchaseEvaluationWorkflowSummaryDto(
string PolicyName, string PolicyName,

View File

@ -290,7 +290,9 @@ public class AdjustPurchaseEvaluationBudgetCommandHandler(
} }
else if (entity.Phase == PurchaseEvaluationPhase.ChoDuyet) else if (entity.Phase == PurchaseEvaluationPhase.ChoDuyet)
{ {
// Approver scope — actor phải là ApproverUserId của currentLevel // F4 (Mig 30 — S22+5) — Approver scope chỉ accept khi
// currentLevel.AllowApproverEditBudget=true (admin Designer tick
// per slot) AND actor match ApproverUserId.
if (entity.ApprovalWorkflowId is not Guid awId) if (entity.ApprovalWorkflowId is not Guid awId)
throw new ConflictException("Phiếu V1 legacy không hỗ trợ điều chỉnh ngân sách lúc đang duyệt."); throw new ConflictException("Phiếu V1 legacy không hỗ trợ điều chỉnh ngân sách lúc đang duyệt.");
if (entity.CurrentWorkflowStepIndex is not int csi || entity.CurrentApprovalLevelOrder is not int curLvl) if (entity.CurrentWorkflowStepIndex is not int csi || entity.CurrentApprovalLevelOrder is not int curLvl)
@ -307,7 +309,14 @@ public class AdjustPurchaseEvaluationBudgetCommandHandler(
throw new ConflictException("Pointer step out of range — schema lỗi."); throw new ConflictException("Pointer step out of range — schema lỗi.");
var step = stepsOrdered[csi]; var step = stepsOrdered[csi];
var level = step.Levels.FirstOrDefault(l => l.Order == curLvl); var level = step.Levels.FirstOrDefault(l => l.Order == curLvl);
if (level?.ApproverUserId != actorId) if (level is null)
throw new ConflictException("Cấp duyệt không tìm thấy — schema lỗi.");
if (!level.AllowApproverEditBudget)
throw new ConflictException(
$"Cấp Approver hiện tại (Bước {step.Order} / Cấp {curLvl}) " +
"không được cấp quyền chỉnh sửa Section ngân sách. " +
"Liên hệ Admin Designer cấp quyền slot.");
if (level.ApproverUserId != actorId)
throw new ForbiddenException( throw new ForbiddenException(
$"Chỉ NV phụ trách Bước {step.Order} / Cấp {curLvl} mới được điều chỉnh ngân sách lúc đang duyệt."); $"Chỉ NV phụ trách Bước {step.Order} / Cấp {curLvl} mới được điều chỉnh ngân sách lúc đang duyệt.");
actorTag = $"[Approver Bước {step.Order}/Cấp {curLvl}]"; actorTag = $"[Approver Bước {step.Order}/Cấp {curLvl}]";
@ -761,7 +770,8 @@ public class GetPurchaseEvaluationQueryHandler(
curLevel.AllowReturnOneStep, curLevel.AllowReturnOneStep,
curLevel.AllowReturnToAssignee, curLevel.AllowReturnToAssignee,
curLevel.AllowReturnToDrafter, curLevel.AllowReturnToDrafter,
curLevel.AllowApproverEditDetails); curLevel.AllowApproverEditDetails,
curLevel.AllowApproverEditBudget);
} }
} }

View File

@ -98,5 +98,11 @@ public class ApprovalWorkflowLevel : BaseEntity
/// duyệt. KHÔNG đụng PE Header, KHÔNG reset workflow. /// duyệt. KHÔNG đụng PE Header, KHÔNG reset workflow.
public bool AllowApproverEditDetails { get; set; } public bool AllowApproverEditDetails { get; set; }
/// F4 (Mig 30 — S22+5) — Cho phép NV này edit Section "Điều chỉnh ngân sách"
/// (BudgetId / BudgetManualName / BudgetManualAmount) lúc đang duyệt. Default
/// false (admin opt-in per slot). Logic edit ngân sách giữ nguyên Drafter
/// Nháp/Trả lại — flag này CHỈ mở thêm scope cho Approver ChoDuyet.
public bool AllowApproverEditBudget { get; set; }
public ApprovalWorkflowStep? Step { get; set; } public ApprovalWorkflowStep? Step { get; set; }
} }

View File

@ -77,5 +77,9 @@ public class ApprovalWorkflowLevelConfiguration : IEntityTypeConfiguration<Appro
e.Property(x => x.AllowReturnToAssignee).HasDefaultValue(false); e.Property(x => x.AllowReturnToAssignee).HasDefaultValue(false);
e.Property(x => x.AllowReturnToDrafter).HasDefaultValue(true); e.Property(x => x.AllowReturnToDrafter).HasDefaultValue(true);
e.Property(x => x.AllowApproverEditDetails).HasDefaultValue(false); e.Property(x => x.AllowApproverEditDetails).HasDefaultValue(false);
// Mig 30 (S22+5) — F4 per-NV: cho phép edit Section "Điều chỉnh ngân sách"
// lúc đang duyệt. Default false (admin opt-in).
e.Property(x => x.AllowApproverEditBudget).HasDefaultValue(false);
} }
} }

View File

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddAllowApproverEditBudgetToLevels : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AllowApproverEditBudget",
table: "ApprovalWorkflowLevels",
type: "bit",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AllowApproverEditBudget",
table: "ApprovalWorkflowLevels");
}
}
}

View File

@ -188,6 +188,11 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier"); .HasColumnType("uniqueidentifier");
b.Property<bool>("AllowApproverEditBudget")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool>("AllowApproverEditDetails") b.Property<bool>("AllowApproverEditDetails")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("bit") .HasColumnType("bit")