[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:
pqhuy1987
2026-05-13 22:25:49 +07:00
parent 0e707891ff
commit 37b51d7f07
2 changed files with 163 additions and 0 deletions

View File

@ -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)
{

View File

@ -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(