diff --git a/src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs b/src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs index 511d991..dbe1260 100644 --- a/src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs +++ b/src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs @@ -52,6 +52,18 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase return NoContent(); } + // S22+4 — Feature 2: Section "Điều chỉnh ngân sách" tách endpoint riêng. + // Cho phép Drafter (DangSoanThao/TraLai) OR Approver currentLevel (ChoDuyet) + // OR Admin adjust Budget* fields. Handler kiểm phase + actor scope. + [HttpPatch("{id:guid}/budget-adjust")] + public async Task AdjustBudget(Guid id, [FromBody] AdjustBudgetBody body, CancellationToken ct) + { + await mediator.Send(new AdjustPurchaseEvaluationBudgetCommand( + id, body.BudgetId, body.BudgetManualName, body.BudgetManualAmount), ct); + return NoContent(); + } + public record AdjustBudgetBody(Guid? BudgetId, string? BudgetManualName, decimal? BudgetManualAmount); + [HttpPost("{id:guid}/transitions")] public async Task Transition(Guid id, [FromBody] TransitionPeBody body, CancellationToken ct) { @@ -182,6 +194,17 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase return File(f.Content, f.ContentType, f.FileName); } + // S22+4 — Inline view endpoint cho FE preview modal (PDF iframe, image ). + // Cùng handler download, khác Content-Disposition (inline vs attachment). + // Permission: same actor scope như GET phiếu (Plan E V2 strict). + [HttpGet("{id:guid}/attachments/{attId:guid}/view")] + public async Task ViewAttachment(Guid id, Guid attId, CancellationToken ct) + { + var f = await mediator.Send(new DownloadPurchaseEvaluationAttachmentQuery(id, attId), ct); + Response.Headers["Content-Disposition"] = $"inline; filename=\"{f.FileName}\""; + return File(f.Content, f.ContentType); + } + [HttpDelete("{id:guid}/attachments/{attId:guid}")] public async Task DeleteAttachment(Guid id, Guid attId, CancellationToken ct) { diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs index eb590c6..6b7fc55 100644 --- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs @@ -239,6 +239,146 @@ public class UpdatePurchaseEvaluationDraftCommandHandler( } } +// ========== ADJUST BUDGET (S22+4 — Feature 2) ========== +// Section "Điều chỉnh ngân sách" cho phép sửa BudgetId + BudgetManualName + +// BudgetManualAmount khi: +// - Drafter scope (Phase=DangSoanThao OR TraLai) — actor là Drafter của phiếu +// - Approver scope (Phase=ChoDuyet) — actor là ApproverUserId của currentLevel +// - Admin → bypass +// +// Tách endpoint riêng (KHÔNG dùng UpdatePeDraft) vì UpdatePeDraft chỉ accept +// DangSoanThao/TraLai phase + cập nhật cả Section 1 (TenGoiThau, DiaDiem, +// MoTa, PaymentTerms) — Approver KHÔNG nên được edit các field đó khi đang duyệt. +// AdjustBudget chỉ adjust Budget* — narrow scope hợp lý. +public record AdjustPurchaseEvaluationBudgetCommand( + Guid Id, + Guid? BudgetId, + string? BudgetManualName, + decimal? BudgetManualAmount) : IRequest; + +public class AdjustPurchaseEvaluationBudgetCommandValidator : AbstractValidator +{ + public AdjustPurchaseEvaluationBudgetCommandValidator() + { + RuleFor(x => x.BudgetManualName).MaximumLength(200); + RuleFor(x => x.BudgetManualAmount).GreaterThanOrEqualTo(0).When(x => x.BudgetManualAmount.HasValue); + } +} + +public class AdjustPurchaseEvaluationBudgetCommandHandler( + IApplicationDbContext db, + ICurrentUser currentUser) : IRequestHandler +{ + public async Task Handle(AdjustPurchaseEvaluationBudgetCommand request, CancellationToken ct) + { + var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.Id, ct) + ?? throw new NotFoundException("PurchaseEvaluation", request.Id); + + var isAdmin = currentUser.Roles.Contains(AppRoles.Admin); + var isDrafter = currentUser.UserId is Guid uid && entity.DrafterUserId == uid; + var actorTag = string.Empty; + + if (!isAdmin) + { + if (entity.Phase == PurchaseEvaluationPhase.DangSoanThao + || entity.Phase == PurchaseEvaluationPhase.TraLai) + { + // Drafter scope — chỉ Drafter của phiếu được adjust + if (!isDrafter) + throw new ForbiddenException("Chỉ Drafter của phiếu được điều chỉnh ngân sách khi Nháp/Trả lại."); + actorTag = "[Drafter]"; + } + else if (entity.Phase == PurchaseEvaluationPhase.ChoDuyet) + { + // Approver scope — actor phải là ApproverUserId của currentLevel + 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."); + if (entity.CurrentWorkflowStepIndex is not int csi || entity.CurrentApprovalLevelOrder is not int curLvl) + throw new ConflictException("Phiếu chưa init pointer workflow."); + if (currentUser.UserId is not Guid actorId) + throw new ConflictException("Yêu cầu authenticated user."); + + var workflow = await db.ApprovalWorkflows.AsNoTracking() + .Include(w => w.Steps).ThenInclude(s => s.Levels) + .FirstOrDefaultAsync(w => w.Id == awId, ct) + ?? throw new NotFoundException("ApprovalWorkflow", awId); + var stepsOrdered = workflow.Steps.OrderBy(s => s.Order).ToList(); + if (csi < 0 || csi >= stepsOrdered.Count) + throw new ConflictException("Pointer step out of range — schema lỗi."); + var step = stepsOrdered[csi]; + var level = step.Levels.FirstOrDefault(l => l.Order == curLvl); + if (level?.ApproverUserId != actorId) + 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."); + actorTag = $"[Approver Bước {step.Order}/Cấp {curLvl}]"; + } + else + { + throw new ConflictException( + $"Phase={entity.Phase} không cho phép điều chỉnh ngân sách. " + + "Chỉ cho phép khi Nháp/Trả lại (Drafter) hoặc Đang duyệt (Approver)."); + } + } + else + { + actorTag = "[Admin]"; + } + + // Validate Budget link nếu thay đổi + if (request.BudgetId is Guid bid && bid != entity.BudgetId) + { + var bg = await db.Budgets.AsNoTracking() + .FirstOrDefaultAsync(b => b.Id == bid, ct) + ?? throw new NotFoundException("Budget", bid); + if (bg.ProjectId != entity.ProjectId) + throw new ConflictException("Ngân sách phải cùng dự án với phiếu."); + if (bg.Phase != Domain.Budgets.BudgetPhase.DaDuyet) + throw new ConflictException("Chỉ link được ngân sách đã duyệt."); + } + + // Capture old + apply + var oldBudgetId = entity.BudgetId; + var oldBudgetManualName = entity.BudgetManualName; + var oldBudgetManualAmount = entity.BudgetManualAmount; + + entity.BudgetId = request.BudgetId; + entity.BudgetManualName = request.BudgetManualName; + entity.BudgetManualAmount = request.BudgetManualAmount; + + // Audit changelog with diff (Vietnamese friendly format) + var parts = new List(); + if (oldBudgetId != request.BudgetId) + { + var oldDesc = oldBudgetId is null ? "(chưa link)" : "Budget#" + oldBudgetId.Value.ToString()[..8]; + var newDesc = request.BudgetId is null ? "(huỷ link)" : "Budget#" + request.BudgetId.Value.ToString()[..8]; + parts.Add($"link {oldDesc} → {newDesc}"); + } + if (oldBudgetManualName != request.BudgetManualName) + { + parts.Add($"tên \"{oldBudgetManualName ?? "(trống)"}\" → \"{request.BudgetManualName ?? "(trống)"}\""); + } + if (oldBudgetManualAmount != request.BudgetManualAmount) + { + var oldAmt = oldBudgetManualAmount?.ToString("N0") ?? "(trống)"; + var newAmt = request.BudgetManualAmount?.ToString("N0") ?? "(trống)"; + parts.Add($"số tiền {oldAmt}đ → {newAmt}đ"); + } + var diffSummary = parts.Count == 0 ? "không đổi" : string.Join(", ", parts); + + db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog + { + PurchaseEvaluationId = entity.Id, + EntityType = PurchaseEvaluationEntityType.Header, + Action = ChangelogAction.Update, + PhaseAtChange = entity.Phase, + UserId = currentUser.UserId, + Summary = $"Điều chỉnh ngân sách: {diffSummary} {actorTag}", + }); + + await db.SaveChangesAsync(ct); + } +} + // ========== TRANSITION ========== public record TransitionPurchaseEvaluationCommand(