[CLAUDE] PurchaseEvaluation: S22+4 Chunk A — BE attachment view endpoint + AdjustBudget command
Feature 1 (attachment preview):
- NEW `GET /api/purchase-evaluations/{id}/attachments/{attId}/view`
- Cùng handler download, override `Content-Disposition: inline` để FE nhúng iframe
- Permission: same scope GET phiếu (Plan E V2 strict scope)
Feature 2 (điều chỉnh ngân sách):
- NEW `AdjustPurchaseEvaluationBudgetCommand` + Handler + Validator
- NEW `PATCH /api/purchase-evaluations/{id}/budget-adjust` body
`{budgetId, budgetManualName, budgetManualAmount}`
- Phase + actor scope guard:
* DangSoanThao/TraLai → chỉ Drafter của phiếu
* ChoDuyet → Approver currentLevel (match ApproverUserId) — V2 only
* Admin → bypass tất cả
- Audit changelog với diff narrative: "Điều chỉnh ngân sách: link X→Y, số tiền A→Bđ [Drafter/Approver Bước/Cấp/Admin]"
- Tách riêng KHÔNG dùng UpdatePeDraft vì Approver scope KHÔNG nên được edit
Section 1 fields (TenGoiThau/DiaDiem/MoTa/PaymentTerms)
Verify:
- dotnet build SolutionErp.slnx — 0 err, 2 warn DocxRenderer pre-existing
- Test defer carry Plan C (UAT mode §7) — guard logic critical, ưu tiên cho S23+
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -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<IActionResult> 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<IActionResult> 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 <img>).
|
||||
// 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<IActionResult> 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<IActionResult> DeleteAttachment(Guid id, Guid attId, CancellationToken ct)
|
||||
{
|
||||
|
||||
@ -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<AdjustPurchaseEvaluationBudgetCommand>
|
||||
{
|
||||
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<AdjustPurchaseEvaluationBudgetCommand>
|
||||
{
|
||||
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<string>();
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user