[CLAUDE] Domain+App+Infra+Api+FE-Admin+FE-User: S37 Mig 37 enum + Plan G-O3 Đề xuất full-stack
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m53s

Phase 10.3 G-O3 Đề xuất (Proposal) — Mig 37 enum extend +5 values + Mig 38
Proposal schema + BE CQRS 8 endpoint + FE 2 app SHA256 IDENTICAL.

Mig 37 (em main solo): extend ApprovalWorkflowApplicableType enum +5 values
  ProposalGeneral=4 / LeaveRequest=5 / OtRequest=6 / VehicleBooking=7 / ItTicket=8
  cookie-cutter Mig 22 pattern (Up/Down empty — enum mức Domain).

Mig 38 (em main solo): 4 entity Proposal (Code DX/YYYY/NNN) + ProposalAttachment
  + ProposalLevelOpinion (UNIQUE composite PEId+LevelId mirror PE Mig 26) +
  ProposalCodeSequence (Prefix PK atomic seq). 4 EF Config + 2 DbContext mod.

BE CQRS (em main solo ~700 LOC ProposalFeatures.cs sau Implementer truncate phase
exploration gotcha #53 5th + 529 Overload):
  - 4 Header handler (List paged + GetById detail + Create + UpdateDraft owner-OR-admin)
  - 4 Workflow handler (Submit gen MaDeXuat atomic + Approve UPSERT LevelOpinion advance + Reject + Return)
  - SERIALIZABLE transaction CodeGen
  - DTOs nested LevelOpinion với Step+Level metadata JOIN

ProposalsController 8 endpoint /api/proposals (List/GetById/Create/Update/Submit/Approve/Reject/Return)
class-level [Authorize] + handler-level owner-OR-admin guard.

DbInitializer: SeedSampleProposalWorkflowV2Async ~40 LOC seed QT-DX-V2-001 IsUserSelectable=true
NOT gated DemoSeed per gotcha #51. SeedMenuTreeAsync +4 row (Off_DeXuat sub-group + 3 leaf).

FE 2 app (em main solo + Implementer 529 fail fallback):
  - types/proposal.ts × 2 SHA256 IDENTICAL 95607052ff1138f2
  - ProposalsListPage.tsx × 2 IDENTICAL 603f0d9cf74cd09a — table 6 cột + Status badge + filter
  - ProposalCreatePage.tsx × 2 IDENTICAL 6aed3a76563dd576 — Form Header card
  - ProposalDetailPage.tsx × 2 IDENTICAL 3dc229ea8dcc9bc0 — 3 Section + WorkflowActions
  - Pattern 16-bis 8× cumulative (App.tsx + menuKeys + Layout staticMap 3 entry)

Verify:
- dotnet build PASS 0 error 2 warning pre-existing DocxRenderer
- dotnet test 130/130 PASS baseline preserve
- npm build × 2 PASS (fe-admin 14.72s + fe-user 6.40s)
- SHA256 verify 4 file × 2 app all IDENTICAL

Pattern reinforced cumulative S37:
- Pattern 12-bis cross-module mirror 11× (PE V2 → Proposal V2 ApproveV2)
- Pattern 16-bis 4-place mirror cross-app 8×
- gotcha #53 5th occurrence Implementer mid-exploration truncation + 529 Overload 1× — em main solo fallback proven

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-28 15:51:14 +07:00
parent 37593f95b5
commit de1c378279
35 changed files with 13650 additions and 0 deletions

View File

@ -0,0 +1,86 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SolutionErp.Application.Office;
namespace SolutionErp.Api.Controllers;
// Phase 10.3 G-O3 (S37 2026-05-28) — Đề xuất REST endpoint.
// Class-level [Authorize] — any authenticated user can list/create/view.
// Per-action ownership/role enforcement trong handler (Drafter OR Admin for write;
// Approver-of-current-Level OR Admin for approve/reject/return).
[ApiController]
[Route("api/proposals")]
[Authorize]
public class ProposalsController(IMediator mediator) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetList(
[FromQuery] int? status,
[FromQuery] Guid? departmentId,
[FromQuery] Guid? drafterUserId,
[FromQuery] bool inboxOnly = false,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50,
[FromQuery] string? search = null)
=> Ok(await mediator.Send(new GetProposalsQuery(status, departmentId, drafterUserId, inboxOnly, page, pageSize, search)));
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var dto = await mediator.Send(new GetProposalByIdQuery(id));
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateProposalCommand cmd)
{
var id = await mediator.Send(cmd);
return CreatedAtAction(nameof(GetById), new { id }, new { id });
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateProposalDraftBody body)
{
await mediator.Send(new UpdateProposalDraftCommand(id, body.Title, body.Description,
body.AmountEstimate, body.DepartmentId, body.ApprovalWorkflowId));
return NoContent();
}
[HttpPost("{id:guid}/submit")]
public async Task<IActionResult> Submit(Guid id)
{
await mediator.Send(new SubmitProposalCommand(id));
return NoContent();
}
[HttpPost("{id:guid}/approve")]
public async Task<IActionResult> Approve(Guid id, [FromBody] ApprovalActionBody body)
{
await mediator.Send(new ApproveProposalCommand(id, body.Comment));
return NoContent();
}
[HttpPost("{id:guid}/reject")]
public async Task<IActionResult> Reject(Guid id, [FromBody] ApprovalActionBody body)
{
await mediator.Send(new RejectProposalCommand(id, body.Comment));
return NoContent();
}
[HttpPost("{id:guid}/return")]
public async Task<IActionResult> Return(Guid id, [FromBody] ApprovalActionBody body)
{
await mediator.Send(new ReturnProposalCommand(id, body.Comment));
return NoContent();
}
public record UpdateProposalDraftBody(
string Title,
string? Description,
decimal? AmountEstimate,
Guid? DepartmentId,
Guid? ApprovalWorkflowId);
public record ApprovalActionBody(string? Comment);
}