diff --git a/.claude/agent-memory/cicd-monitor/MEMORY.md b/.claude/agent-memory/cicd-monitor/MEMORY.md index f9107e3..3691b95 100644 --- a/.claude/agent-memory/cicd-monitor/MEMORY.md +++ b/.claude/agent-memory/cicd-monitor/MEMORY.md @@ -45,11 +45,12 @@ Read-only CI/CD + post-deploy verifier SOLUTION_ERP. Polls Gitea Actions API, ve ## 🧠 SOLUTION_ERP CI/CD essentials (S40 verified) - **Gitea:** `git.baocaogiaoduc.vn/vietreport-admin/solution-erp` · workflow `.gitea/workflows/deploy.yml` · paths-ignore `['docs/**','**/*.md','.claude/skills/**']` -- **Prod:** api/admin/eoffice `.solutions.com.vn` · SSH `ssh vietreport-vps` (Administrator, id_ed25519) · DB `.\SQLEXPRESS`/`SolutionErp`/`vrapp` (fallback `C:\inetpub\solution-erp\api\appsettings.Production.json` khi `$env:PROD_DB_PASSWORD` empty) -- **Tests baseline:** **130 PASS** (58 Domain + 72 Infra). Phase 9 UAT mode skip per chunk OK. -- **Mig latest repo:** **Mig 40 `20260528090839_AddAttendances`** (S38 G-P1). Path `src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/`. Prod check `sqlcmd __EFMigrationsHistory ORDER BY MigrationId DESC TOP 5`. +- **Prod:** api/admin/eoffice `.solutions.com.vn` · SSH `ssh vietreport-vps` (Administrator, id_ed25519) · IIS site phys paths (S42 verified): API `C:\inetpub\solution-erp\api` · admin `\fe-admin` · user `\fe-user` (3 sites Started). DB `.\SQLEXPRESS`/`SolutionErp`/`vrapp` SQL-auth. **Conn string key = `ConnectionStrings.Default` (NOT `DefaultConnection`!)** — read pw from prod appsettings.Production.json when `$env:PROD_DB_PASSWORD` empty. + - **SSH→PS quoting (S42 lesson):** nested bash→ssh→powershell mangles `$var`/`\"`. Use `iconv UTF-16LE | base64` → `powershell -EncodedCommand $B64`. Single-quote literal paths. +- **Tests baseline:** **141 PASS** (58 Domain + 83 Infra) S42 (was 130). CI gate runs both test projects BEFORE build/deploy (`deploy.yml` L62+L71) → status=success ⟹ test gate passed. Local grep undercounts (Theory/InlineData) — trust CI conclusion. Phase 9 UAT mode skip per chunk OK. +- **Mig latest repo:** **Mig 41 `20260530021936_WireWorkflowAppsApprovalV2`** (S42 P11-A). Path `src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/`. Prod check `sqlcmd __EFMigrationsHistory ORDER BY MigrationId DESC TOP 5`. - **Bearer:** admin `admin@solutions.com.vn/Admin@123456` (full) · UAT `nv.test@solutions.com.vn/TestUser@123456` (Drafter CCM, gotcha #44 check) -- **Bundle hash live S38:** admin `cWAXid0q` · user `CX79e2kZ` (Run #247). Bundle size ~800KB/750KB gz. +- **Bundle hash live S42:** admin `BLA09-qv` · user `CXvejOE-` (Run #250). Prev S38 admin `cWAXid0q` · user `CX79e2kZ`. Bundle size ~800KB/750KB gz. ## 🔑 Critical config (flag commit nếu tái xuất) Node CI `20.x` (`feedback_node_cicd`) · MediatR `12.4.1` (gotcha #1, flag `Version="14`) · Swashbuckle `6.9.0` (gotcha #2) · act_runner manual checkout (#39) · npm cache DISABLED (#40, flag `cache: npm`) @@ -66,6 +67,7 @@ BE (test+build) ~90s · FE × 2 ~60s/app · deploy ~30s · **total ~3min code / ## 📅 Recent runs (FIFO — older → archive/git) +- **2026-05-30 Run #250 sha=`e7b66cd` PASS ~4m07s (S42 P11-A wire ApproveV2+LevelOpinions 4 WorkflowApps):** 1 commit BE+FE×2+Mig41+Tests. Status=success iter3. Bundle rotate admin `cWAXid0q→BLA09-qv` + user `CX79e2kZ→CXvejOE-`. **Mig 41 auto-applied prod** (latest=`20260530021936_WireWorkflowAppsApprovalV2`). Tables 84→**90** (+5: Leave/Ot/Travel/VehicleRequest LevelOpinions + WorkflowAppCodeSequences — ALL EXIST). 4 new endpoint smoke 200 auth (leave/ot/travel/vehicle-requests) + unauth 401 (route exists) + POST .../approve=411 (route reg). health live/ready 200. **Stage 4.6 seed gate PASS** (gotcha #51): 4 WF seeded prod despite DemoSeed:Disabled — QT-NP/OT/CT/XE-V2-001 AppType=5/6/7/9, verified call-site L142-145 OUTSIDE `if(!demoSeedDisabled)` gate. Test gate 141 (CI runs both proj pre-deploy). Note: table count 90 vs spec-expected 89 = baseline-count diff, NOT missing table (all 5 present). Stale doc drift deploy.yml comments "54/17 test" (cosmetic, flag em main). Tag `[s42, run250, pass, p11a-approvev2-workflowapps]`. - **2026-05-28 Run #247 sha=`e54a22d` PASS 3m25s (S38 SKELETON 5-plan combo Mig 39+40 dual):** Push 1 commit mega `Domain+App+Infra+Api+FE×2`. ALL PASS. Bundle rotate admin `CGueDk22→cWAXid0q` + user `CEt0QRgX→CX79e2kZ`. Mig 39+40 dual auto-applied startup (90830→90839). 6 endpoint smoke 200 (leave/ot/travel/vehicle/it-tickets/hr-dashboard `totalEmployees=33 male=17 female=16`). 6 new tables + 8 menu seeded. 0 regression. Fastest S38 deploy. Tag `[s38, run247, pass, skeleton-combo]`. - **2026-05-28 Run #246 sha=`de1c378` PASS 3m53s (S37 Proposal Mig 37+38):** Bundle admin `C9kzTTmq→CGueDk22` + user `CC4DQ-Tr→CEt0QRgX`. Mig 38 AddProposals + 37 ExtendApplicableType. `/api/proposals` 200 empty + workflow `QT-DX-V2-001` ApplicableType=4 seed + 4 Off_DeXuat menu. Stage 4.6 sample seed INFRASTRUCTURE-gated correct (gotcha #51). Tag `[s37, run246, pass, proposal-v2]`. - **Archived Run #359/#243/#242/#241/#240 + S35/S36 startup → `archive/2026-05-q4.md` + git d2f52ba (S40 curate):** Run #359 G-O2 Meeting Mig 36 · #243 HrmConfig BE 16 endpoint (BE-only bundle unchanged anti-pattern verify) · #242 FE inline forms 5 satellite · #241 Mig 35 HRM foundation · #240 satellite CRUD. Discovery #7 path-filter eval/** + #8 collection `proj_*`. KEY absorbed in essentials/Stage sections above. diff --git a/docs/STATUS.md b/docs/STATUS.md index 55180c7..6091bc4 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -3,7 +3,7 @@ > **Update rule:** trước khi bắt đầu 1 task → ghi row `🔥 In Progress`. Xong → `✅ Recently Done`. > **Tiering rule (S40):** chỉ giữ **state hiện tại + 3 session gần nhất** ở file này. Session cũ hơn → `docs/changelog/sessions/`. Full history pre-S40 → `docs/_archive/STATUS-preS40-fullhistory.md`. (Tránh over-context — xóa double, không cắt nội dung.) -**Last updated:** 2026-05-29 (Session 41 — **RAG corpus cleanup** w/ AI_INFRA: exclude `**/`-anchor fix + re-bootstrap 3080→2406 (−674 junk) + 5/5 store_memory preserved. KHÔNG feature/schema change.) +**Last updated:** 2026-05-30 (Session 42 — **Phase 11 P11-A SHIPPED**: wire ApproveV2 + LevelOpinions cho 4 WorkflowApps module (Leave/OT/Travel/Vehicle). Mig 41 + 30 handler + 8 route + FE detail ×2 + 11 test. Run #250 PASS, deployed prod.) --- @@ -11,30 +11,30 @@ | Metric | Value | Note | |---|---|---| -| Migrations | **40** | last `AddAttendances` (20260528090839) | -| SQL tables | **84** | 77 app DbSet + 7 ASP.NET Identity (`.ToTable()` in ModelSnapshot) | -| API endpoints | **~211** | `[HttpVerb]` attrs in Controllers | -| FE pages | **65** | 36 fe-admin + 29 fe-user (`*Page.tsx`) | +| Migrations | **41** | last `WireWorkflowAppsApprovalV2` (20260530021936) | +| SQL tables | **~90** | +5 S42 (4 WorkflowApp LevelOpinions + WorkflowAppCodeSequences) — all verified prod | +| API endpoints | **~235** | +24 S42 (4 module × 6 route approval wire) | +| FE pages | **67** | +2 S42 WorkflowAppDetailPage (admin+user SHA256 identical) | | Menu keys | **~53** | BE `MenuKeys` const (FE menuKeys.ts mirror 54) | -| Tests | **130 PASS** | 58 Domain + 72 Infra · 0 fail / 0 skip (~15s) | +| Tests | **144 PASS** | 58 Domain + 86 Infra · 0 fail / 0 skip · +11 ApproveV2 +3 SetWorkflow S42 | | Gotchas | **55** | `docs/gotchas.md` (latest #54 529-fallback, #55 truncation-mid-exploration) | | User memory | 27 | + `MEMORY.md` index | | Skills | 6 | 3 domain + 3 ops | | Sub-agents | **7** | Opus 4.8 1M (S39 split 4→7) | | RAG chunks | **2406** | ✅ S41 re-bootstrap clean (3080→2406, −674 junk: node_modules+_archive now excluded; user-memory 60 chunks/10 files slug-fixed + S38-S41 indexed) | -**Bundle hash live (prod):** admin `cWAXid0q` · user `CX79e2kZ` (Run #247, S38). -**Phase:** ✅ **Phase 10 COMPLETE 11/11** Plan G-* · ⬜ Phase 11 polish NOT started · 🚫 Phase 9 Ops blocked (anh main coordinate). +**Bundle hash live (prod):** admin `BLA09-qv` · user `CXvejOE-` (Run #250, S42). +**Phase:** ✅ Phase 10 COMPLETE · 🔄 **Phase 11 IN PROGRESS** — P11-A DONE (4 WorkflowApps ApproveV2 wired+deployed) · ⬜ P11-B..F pending · 🚫 Phase 9 Ops blocked (anh main coordinate). > ⚠️ **Count drift fixed S40:** endpoints ~223→**211**, FE pages 53→**65**, menu keys 85→**~53**. Tables **84 confirmed correct** (DbSet 77 + Identity 7). 3 số "khó fake" (mig/gotcha/git) luôn đúng. Cause: số "incremented mỗi session" over/under-count optimistic — re-ground định kỳ. --- -## 🔥 In Progress (S40) +## 🔥 In Progress (S42) | Task | Owner | Status | |---|---|---| -| _(none — S40 maintenance combo ALL DONE)_ | — | ✅ | +| _(none — P11-A SHIPPED + verified prod. Next: P11-B..F khi anh chọn)_ | — | ✅ | **S40 done:** ✅ Consolidation (`d2f52ba`) · ✅ Curate 4 agent MEMORY >25KB→<8.4KB (`78c9de3`) · ✅ RAG catch-up chunk S37-S40 (rerank 0.867) · ✅ **AI_INFRA bulletin 2026-05-29 adopt 4/4** (MỤC2 Tiered Memory Policy v1 `6f08d1f` + MỤC3 /session-start+/session-end slash commands `c8ff5e1`). ⏳ Full RAG re-index = AI_INFRA op (cần VOYAGE_API_KEY). @@ -44,6 +44,17 @@ ## ✅ Recently Done (newest on top — 3 session; cũ hơn → session logs) +### S42 (2026-05-30) — 🔄 Phase 11 P11-A: wire ApproveV2 + LevelOpinions 4 WorkflowApps module +- **Commit `e7b66cd` → Run #250 PASS ~4m07s, deployed prod.** Cookie-cutter mirror Proposal (Mig 38). 7-agent end-to-end (recon → BE×3 → FE → test → reviewer → cicd). +- **Mig 41 `WireWorkflowAppsApprovalV2`** (additive): +4 bảng `{Leave,Ot,Travel,Vehicle}LevelOpinions` (UNIQUE composite + Cascade/Restrict) + `WorkflowAppCodeSequences` (shared atomic MaDonTu) + 4 cột `RejectedFromStatus` + enum `TravelRequest=9`. +- **BE:** 30 handler (`LeaveOt` + `TravelVehicle` ApprovalFeatures.cs) — GetById/Update/Submit/Approve(UPSERT+advance)/Reject/Return + verify ApplicableType per module. 8 controller route. Seed 4 WF mẫu (QT-NP/OT/CT/XE-V2-001, AppType 5/6/7/9) — gotcha #51 INFRASTRUCTURE-gated PASS prod. +- **FE:** `WorkflowAppDetailPage.tsx` declarative 4-kind (admin+user SHA256 identical) — workflow status + opinion timeline + Submit/Approve/Reject/Return actions; gỡ banner skeleton + row nav. +- **Test:** +11 `WorkflowAppApproveV2Tests` (130→141) — state machine + UPSERT invariant + guards + forbidden + placeholder + codegen. No prod bug. +- **Verified:** Mig 41 applied prod (5 bảng EXIST) · bundle rotate cả 2 app · 4 endpoint live · seed prod · reviewer checklist (no copy-paste bug, [Authorize] OK). +- ⚠️ **Gotcha #53/#55 tái diễn 3× session này** (FE + reviewer + cicd-monitor truncate output cuối) — mỗi lần recover qua MEMORY + em main manual verify. Anti-truncation lesson reinforced. +- 🔬 **Max re-review (agents chạy High) phát hiện + fix 2 bug FE picker** (chưa commit lúc deploy đầu): (#1) `pinWorkflow` PUT `/{id}` partial → 400 validator; (#2) fetch expect array nhưng endpoint trả `{types}` object → picker rỗng. Fix: thêm endpoint chuyên dụng `PUT /{id}/workflow` (set workflow only, verify ApplicableType) + sửa fetch mirror PE/Contract `data.types.find().history.filter(isUserSelectable)`. +3 test SetWorkflow (141→144). **Bonus phát hiện:** `ProposalCreatePage` (S37) có bug #2 có sẵn → spawn task riêng. +- 📌 Follow-up minor (non-blocking): known-minor unreachable (Reject/Return actor-check khi CurrentApprovalLevelOrder null) · deploy.yml stale comment "54/17 test" (cosmetic) · test Travel/Vehicle mirror pending (Leave full + Ot smoke có). + ### S41 (2026-05-29) — RAG corpus cleanup (w/ AI_INFRA) - AI_INFRA RAG audit → SE-side prep: `.claude/rag.json` exclude root-anchored→`**/`-anchored (defeats gotcha #10: `node_modules/**`+`docs/_archive/**` weren't matching nested paths) + retired stale `_decision_log` "+321%/11,922". - **store_memory reconcile (anti-data-loss, NAMGROUP lesson):** unified at-risk rule = content reproducible từ file {exists ∧ matches corpus glob ∧ not excluded}. 5/5 accounted: 3 broadcasts disk-twinned + `16a6b6db` audit-response twin-safe + `0307141b` S37-S40 catch-up **promoted-to-disk** (`docs/changelog/sessions/2026-05-29-S37-S40-rag-catchup.md` — anchor was virtual, not real STATUS section). @@ -67,9 +78,9 @@ ## 🎯 Next up -### ⬜ Phase 11 — Polish/wire skeleton (NEXT plan lớn) -- **P11-A** wire ApproveV2 + LevelOpinions 4 module (Leave/OT/Travel/Vehicle) — 👤 schema + 🟨 BE + 🟧 FE + 🟪 test + 🟥 reviewer + 🟩 cicd -- **P11-B** LeaveBalance business logic (👤 cross-stack) · **P11-C** Vehicle+Driver catalog Mig 41 · **P11-D** ItTicket auto-assign + SLA timer · **P11-E** AttendanceReport + Excel + OtPolicy multiplier · **P11-F** CodeGen atomic MaDonTu/MaTicket +### 🔄 Phase 11 — Polish/wire skeleton (IN PROGRESS) +- ✅ **P11-A** wire ApproveV2 + LevelOpinions 4 module (Leave/OT/Travel/Vehicle) — **DONE S42** (Run #250, deployed prod). CodeGen MaDonTu cho 4 module ĐÃ gộp luôn (WorkflowAppCodeSequences) → P11-F phần ItTicket MaTicket còn lại. +- **P11-B** LeaveBalance business logic (👤 cross-stack) · **P11-C** Vehicle+Driver catalog Mig 42 · **P11-D** ItTicket auto-assign + SLA timer · **P11-E** AttendanceReport + Excel + OtPolicy multiplier · **P11-F** CodeGen atomic MaTicket (MaDonTu xong S42) ### 🔧 Maintenance backlog - Curate 4 agent MEMORY >25KB: investigator-codebase 35.7 / cicd-monitor 35.3 / implementer-backend 30.9 / reviewer 28.4 diff --git a/fe-admin/src/pages/office/WorkflowAppDetailPage.tsx b/fe-admin/src/pages/office/WorkflowAppDetailPage.tsx index 1e51fd8..451a035 100644 --- a/fe-admin/src/pages/office/WorkflowAppDetailPage.tsx +++ b/fe-admin/src/pages/office/WorkflowAppDetailPage.tsx @@ -26,7 +26,7 @@ import { type Kind = 'leave' | 'ot' | 'travel' | 'vehicle' type ActionKind = 'approve' | 'reject' | 'return' -interface WorkflowOption { id: string; code: string; name: string } +interface WorkflowOption { id: string; code: string; name: string; isActive: boolean; isUserSelectable: boolean } function formatDate(iso?: string): string { if (!iso) return '—' @@ -140,12 +140,19 @@ export function WorkflowAppDetailPage() { const hasWorkflow = !!d?.approvalWorkflowId // Workflow picker — chỉ fetch khi draft chưa pin workflow. + // Endpoint trả AwAdminOverviewDto { types: [{ applicableType, history: [...] }] } — + // KHÔNG phải flat array. Mirror pattern ContractCreatePage/PeWorkspace: extract bucket + // theo applicableType rồi filter isUserSelectable (admin ghim cho user pick). const workflows = useQuery({ - queryKey: ['approval-workflows-v2', { applicableType: config?.applicableType, isUserSelectable: true }], - queryFn: async () => - (await api.get('/approval-workflows-v2', { - params: { applicableType: config.applicableType, isUserSelectable: true }, - })).data, + queryKey: ['approval-workflows-v2', config?.applicableType], + queryFn: async () => { + const res = await api.get<{ types: { applicableType: number; history: WorkflowOption[] }[] }>( + '/approval-workflows-v2', + { params: { applicableType: config.applicableType } }, + ) + const bucket = res.data.types.find((t) => t.applicableType === config.applicableType) + return (bucket?.history ?? []).filter((w) => w.isUserSelectable) + }, enabled: !!config && isDraft && !hasWorkflow, }) @@ -156,7 +163,9 @@ export function WorkflowAppDetailPage() { const pinWorkflow = useMutation({ mutationFn: async (workflowId: string) => { - await api.put(`${config.endpoint}/${id}`, { approvalWorkflowId: workflowId }) + // Endpoint chuyên dụng /workflow — chỉ set ApprovalWorkflowId trên draft. + // KHÔNG dùng PUT /{id} (UpdateDraft) vì nó validate Reason/NumDays... → 400. + await api.put(`${config.endpoint}/${id}/workflow`, { approvalWorkflowId: workflowId }) }, onSuccess: () => { toast.success('Đã chọn quy trình duyệt') diff --git a/fe-user/src/pages/office/WorkflowAppDetailPage.tsx b/fe-user/src/pages/office/WorkflowAppDetailPage.tsx index 1e51fd8..451a035 100644 --- a/fe-user/src/pages/office/WorkflowAppDetailPage.tsx +++ b/fe-user/src/pages/office/WorkflowAppDetailPage.tsx @@ -26,7 +26,7 @@ import { type Kind = 'leave' | 'ot' | 'travel' | 'vehicle' type ActionKind = 'approve' | 'reject' | 'return' -interface WorkflowOption { id: string; code: string; name: string } +interface WorkflowOption { id: string; code: string; name: string; isActive: boolean; isUserSelectable: boolean } function formatDate(iso?: string): string { if (!iso) return '—' @@ -140,12 +140,19 @@ export function WorkflowAppDetailPage() { const hasWorkflow = !!d?.approvalWorkflowId // Workflow picker — chỉ fetch khi draft chưa pin workflow. + // Endpoint trả AwAdminOverviewDto { types: [{ applicableType, history: [...] }] } — + // KHÔNG phải flat array. Mirror pattern ContractCreatePage/PeWorkspace: extract bucket + // theo applicableType rồi filter isUserSelectable (admin ghim cho user pick). const workflows = useQuery({ - queryKey: ['approval-workflows-v2', { applicableType: config?.applicableType, isUserSelectable: true }], - queryFn: async () => - (await api.get('/approval-workflows-v2', { - params: { applicableType: config.applicableType, isUserSelectable: true }, - })).data, + queryKey: ['approval-workflows-v2', config?.applicableType], + queryFn: async () => { + const res = await api.get<{ types: { applicableType: number; history: WorkflowOption[] }[] }>( + '/approval-workflows-v2', + { params: { applicableType: config.applicableType } }, + ) + const bucket = res.data.types.find((t) => t.applicableType === config.applicableType) + return (bucket?.history ?? []).filter((w) => w.isUserSelectable) + }, enabled: !!config && isDraft && !hasWorkflow, }) @@ -156,7 +163,9 @@ export function WorkflowAppDetailPage() { const pinWorkflow = useMutation({ mutationFn: async (workflowId: string) => { - await api.put(`${config.endpoint}/${id}`, { approvalWorkflowId: workflowId }) + // Endpoint chuyên dụng /workflow — chỉ set ApprovalWorkflowId trên draft. + // KHÔNG dùng PUT /{id} (UpdateDraft) vì nó validate Reason/NumDays... → 400. + await api.put(`${config.endpoint}/${id}/workflow`, { approvalWorkflowId: workflowId }) }, onSuccess: () => { toast.success('Đã chọn quy trình duyệt') diff --git a/src/Backend/SolutionErp.Api/Controllers/LeaveRequestsController.cs b/src/Backend/SolutionErp.Api/Controllers/LeaveRequestsController.cs index 57fe68c..0fdc782 100644 --- a/src/Backend/SolutionErp.Api/Controllers/LeaveRequestsController.cs +++ b/src/Backend/SolutionErp.Api/Controllers/LeaveRequestsController.cs @@ -64,6 +64,15 @@ public class LeaveRequestsController(IMediator mediator) : ControllerBase return NoContent(); } + [HttpPut("{id:guid}/workflow")] + public async Task SetWorkflow(Guid id, [FromBody] SetWorkflowBody body) + { + await mediator.Send(new SetLeaveRequestWorkflowCommand(id, body.ApprovalWorkflowId)); + return NoContent(); + } + + public record SetWorkflowBody(Guid ApprovalWorkflowId); + public record UpdateLeaveRequestDraftBody( Guid LeaveTypeId, DateTime StartDate, diff --git a/src/Backend/SolutionErp.Api/Controllers/OtRequestsController.cs b/src/Backend/SolutionErp.Api/Controllers/OtRequestsController.cs index 522e607..fb94145 100644 --- a/src/Backend/SolutionErp.Api/Controllers/OtRequestsController.cs +++ b/src/Backend/SolutionErp.Api/Controllers/OtRequestsController.cs @@ -64,6 +64,15 @@ public class OtRequestsController(IMediator mediator) : ControllerBase return NoContent(); } + [HttpPut("{id:guid}/workflow")] + public async Task SetWorkflow(Guid id, [FromBody] SetWorkflowBody body) + { + await mediator.Send(new SetOtRequestWorkflowCommand(id, body.ApprovalWorkflowId)); + return NoContent(); + } + + public record SetWorkflowBody(Guid ApprovalWorkflowId); + public record UpdateOtRequestDraftBody( DateTime OtDate, TimeSpan StartTime, diff --git a/src/Backend/SolutionErp.Api/Controllers/TravelRequestsController.cs b/src/Backend/SolutionErp.Api/Controllers/TravelRequestsController.cs index cf14fed..f663c84 100644 --- a/src/Backend/SolutionErp.Api/Controllers/TravelRequestsController.cs +++ b/src/Backend/SolutionErp.Api/Controllers/TravelRequestsController.cs @@ -64,6 +64,15 @@ public class TravelRequestsController(IMediator mediator) : ControllerBase return NoContent(); } + [HttpPut("{id:guid}/workflow")] + public async Task SetWorkflow(Guid id, [FromBody] SetWorkflowBody body) + { + await mediator.Send(new SetTravelRequestWorkflowCommand(id, body.ApprovalWorkflowId)); + return NoContent(); + } + + public record SetWorkflowBody(Guid ApprovalWorkflowId); + public record UpdateTravelRequestBody( string Destination, DateTime StartDate, diff --git a/src/Backend/SolutionErp.Api/Controllers/VehicleBookingsController.cs b/src/Backend/SolutionErp.Api/Controllers/VehicleBookingsController.cs index 1e3bf80..380b16c 100644 --- a/src/Backend/SolutionErp.Api/Controllers/VehicleBookingsController.cs +++ b/src/Backend/SolutionErp.Api/Controllers/VehicleBookingsController.cs @@ -64,6 +64,15 @@ public class VehicleBookingsController(IMediator mediator) : ControllerBase return NoContent(); } + [HttpPut("{id:guid}/workflow")] + public async Task SetWorkflow(Guid id, [FromBody] SetWorkflowBody body) + { + await mediator.Send(new SetVehicleBookingWorkflowCommand(id, body.ApprovalWorkflowId)); + return NoContent(); + } + + public record SetWorkflowBody(Guid ApprovalWorkflowId); + public record UpdateVehicleBookingBody( string VehicleLicense, string? VehicleName, diff --git a/src/Backend/SolutionErp.Application/Office/LeaveOtApprovalFeatures.cs b/src/Backend/SolutionErp.Application/Office/LeaveOtApprovalFeatures.cs index 0376386..75e5401 100644 --- a/src/Backend/SolutionErp.Application/Office/LeaveOtApprovalFeatures.cs +++ b/src/Backend/SolutionErp.Application/Office/LeaveOtApprovalFeatures.cs @@ -395,6 +395,69 @@ public class ReturnLeaveRequestHandler(IApplicationDbContext db, ICurrentUser cu } } +// ========================================================================= +// Set workflow (pin quy trình cho draft) — Phase 11 P11-A fix S42. +// Endpoint riêng PUT /{id}/workflow: chỉ set ApprovalWorkflowId trên draft +// Nhap/TraLai (verify ApplicableType). KHÔNG validate field khác (khác UpdateDraft — +// tránh 400 khi FE chỉ gửi workflowId). Single-responsibility: chọn quy trình. +// ========================================================================= + +public record SetLeaveRequestWorkflowCommand(Guid Id, Guid ApprovalWorkflowId) : IRequest; + +public class SetLeaveRequestWorkflowHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock) + : IRequestHandler +{ + public async Task Handle(SetLeaveRequestWorkflowCommand req, CancellationToken ct) + { + var p = await db.LeaveRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct); + if (p is null) throw new NotFoundException("LeaveRequest", req.Id); + var isOwner = p.RequesterUserId == cu.UserId; + var isAdmin = cu.Roles.Contains("Admin"); + if (!isOwner && !isAdmin) + throw new ForbiddenException("Chỉ người tạo hoặc Admin được chọn quy trình."); + if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai) + throw new ConflictException("Chỉ chọn quy trình khi trạng thái Nháp hoặc Trả lại."); + var wfType = await db.ApprovalWorkflows.AsNoTracking() + .Where(w => w.Id == req.ApprovalWorkflowId) + .Select(w => (int?)w.ApplicableType).FirstOrDefaultAsync(ct); + if (wfType is null) throw new NotFoundException("ApprovalWorkflow", req.ApprovalWorkflowId); + if (wfType.Value != (int)ApprovalWorkflowApplicableType.LeaveRequest) + throw new ConflictException("Quy trình duyệt không thuộc loại Đơn nghỉ phép."); + p.ApprovalWorkflowId = req.ApprovalWorkflowId; + p.UpdatedAt = clock.UtcNow; + p.UpdatedBy = cu.UserId; + await db.SaveChangesAsync(ct); + } +} + +public record SetOtRequestWorkflowCommand(Guid Id, Guid ApprovalWorkflowId) : IRequest; + +public class SetOtRequestWorkflowHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock) + : IRequestHandler +{ + public async Task Handle(SetOtRequestWorkflowCommand req, CancellationToken ct) + { + var p = await db.OtRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct); + if (p is null) throw new NotFoundException("OtRequest", req.Id); + var isOwner = p.RequesterUserId == cu.UserId; + var isAdmin = cu.Roles.Contains("Admin"); + if (!isOwner && !isAdmin) + throw new ForbiddenException("Chỉ người tạo hoặc Admin được chọn quy trình."); + if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai) + throw new ConflictException("Chỉ chọn quy trình khi trạng thái Nháp hoặc Trả lại."); + var wfType = await db.ApprovalWorkflows.AsNoTracking() + .Where(w => w.Id == req.ApprovalWorkflowId) + .Select(w => (int?)w.ApplicableType).FirstOrDefaultAsync(ct); + if (wfType is null) throw new NotFoundException("ApprovalWorkflow", req.ApprovalWorkflowId); + if (wfType.Value != (int)ApprovalWorkflowApplicableType.OtRequest) + throw new ConflictException("Quy trình duyệt không thuộc loại Đơn OT."); + p.ApprovalWorkflowId = req.ApprovalWorkflowId; + p.UpdatedAt = clock.UtcNow; + p.UpdatedBy = cu.UserId; + await db.SaveChangesAsync(ct); + } +} + // ========================================================================= // MODULE B: OtRequest (ApplicableType=6, prefix "DT/OT") // ========================================================================= diff --git a/src/Backend/SolutionErp.Application/Office/TravelVehicleApprovalFeatures.cs b/src/Backend/SolutionErp.Application/Office/TravelVehicleApprovalFeatures.cs index 1d458dc..16c1522 100644 --- a/src/Backend/SolutionErp.Application/Office/TravelVehicleApprovalFeatures.cs +++ b/src/Backend/SolutionErp.Application/Office/TravelVehicleApprovalFeatures.cs @@ -744,6 +744,68 @@ public class ReturnVehicleBookingHandler(IApplicationDbContext db, ICurrentUser } } +// ========================================================================= +// Set workflow (pin quy trình cho draft) — Phase 11 P11-A fix S42. +// Endpoint riêng PUT /{id}/workflow: chỉ set ApprovalWorkflowId trên draft +// Nhap/TraLai (verify ApplicableType). KHÔNG validate field khác (khác UpdateDraft). +// ========================================================================= + +public record SetTravelRequestWorkflowCommand(Guid Id, Guid ApprovalWorkflowId) : IRequest; + +public class SetTravelRequestWorkflowHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock) + : IRequestHandler +{ + public async Task Handle(SetTravelRequestWorkflowCommand req, CancellationToken ct) + { + var p = await db.TravelRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct); + if (p is null) throw new NotFoundException("TravelRequest", req.Id); + var isOwner = p.RequesterUserId == currentUser.UserId; + var isAdmin = currentUser.Roles.Contains("Admin"); + if (!isOwner && !isAdmin) + throw new ForbiddenException("Chỉ người tạo hoặc Admin được chọn quy trình."); + if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai) + throw new ConflictException("Chỉ chọn quy trình khi trạng thái Nháp hoặc Trả lại."); + var wfType = await db.ApprovalWorkflows.AsNoTracking() + .Where(w => w.Id == req.ApprovalWorkflowId) + .Select(w => (int?)w.ApplicableType).FirstOrDefaultAsync(ct); + if (wfType is null) throw new NotFoundException("ApprovalWorkflow", req.ApprovalWorkflowId); + if (wfType.Value != (int)ApprovalWorkflowApplicableType.TravelRequest) + throw new ConflictException("Quy trình duyệt không thuộc loại Đơn công tác."); + p.ApprovalWorkflowId = req.ApprovalWorkflowId; + p.UpdatedAt = clock.UtcNow; + p.UpdatedBy = currentUser.UserId; + await db.SaveChangesAsync(ct); + } +} + +public record SetVehicleBookingWorkflowCommand(Guid Id, Guid ApprovalWorkflowId) : IRequest; + +public class SetVehicleBookingWorkflowHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock) + : IRequestHandler +{ + public async Task Handle(SetVehicleBookingWorkflowCommand req, CancellationToken ct) + { + var p = await db.VehicleBookings.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct); + if (p is null) throw new NotFoundException("VehicleBooking", req.Id); + var isOwner = p.RequesterUserId == currentUser.UserId; + var isAdmin = currentUser.Roles.Contains("Admin"); + if (!isOwner && !isAdmin) + throw new ForbiddenException("Chỉ người tạo hoặc Admin được chọn quy trình."); + if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai) + throw new ConflictException("Chỉ chọn quy trình khi trạng thái Nháp hoặc Trả lại."); + var wfType = await db.ApprovalWorkflows.AsNoTracking() + .Where(w => w.Id == req.ApprovalWorkflowId) + .Select(w => (int?)w.ApplicableType).FirstOrDefaultAsync(ct); + if (wfType is null) throw new NotFoundException("ApprovalWorkflow", req.ApprovalWorkflowId); + if (wfType.Value != (int)ApprovalWorkflowApplicableType.VehicleBooking) + throw new ConflictException("Quy trình duyệt không thuộc loại Đặt xe."); + p.ApprovalWorkflowId = req.ApprovalWorkflowId; + p.UpdatedAt = clock.UtcNow; + p.UpdatedBy = currentUser.UserId; + await db.SaveChangesAsync(ct); + } +} + // ========================================================================= // Shared CodeGen helper — Prefix-keyed WorkflowAppCodeSequences (SERIALIZABLE tx). // Mirror SubmitProposalHandler.GenerateMaDeXuatAsync. Format: {prefix}/{seq:D3}. diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/WorkflowAppApproveV2Tests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/WorkflowAppApproveV2Tests.cs index 8384c2f..3c38559 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Application/WorkflowAppApproveV2Tests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Application/WorkflowAppApproveV2Tests.cs @@ -440,4 +440,83 @@ public class WorkflowAppApproveV2Tests opinions[0].Comment.Should().Be("(duyệt — không ý kiến)"); } } + + // ============ SetWorkflow (P11-A fix S42): pin quy trình cho draft ============ + // Endpoint riêng /workflow — KHÔNG validate field khác (fix FE bug PUT /{id} partial → 400). + + [Fact] + public async Task SetWorkflow_OnDraft_SetsApprovalWorkflowId_NoStatusChange() + { + var (fix, db, clock) = NewCtx(); + using (fix) + { + var requester = await fix.CreateUserAsync("req-sw1@test.local", "Requester", null, Array.Empty()); + var approver = await fix.CreateUserAsync("ap-sw1@test.local", "Approver", null, Array.Empty()); + var (wf, _) = await SeedLeaveWorkflowAsync(db, approver.Id); + + var leave = BuildLeave(requester.Id, workflowId: null, WorkflowAppStatus.Nhap, currentLevel: null); + db.LeaveRequests.Add(leave); + await db.SaveChangesAsync(CancellationToken.None); + + await new SetLeaveRequestWorkflowHandler(db, AsUser(requester), clock) + .Handle(new SetLeaveRequestWorkflowCommand(leave.Id, wf.Id), CancellationToken.None); + + leave.ApprovalWorkflowId.Should().Be(wf.Id); + leave.Status.Should().Be(WorkflowAppStatus.Nhap, "set workflow KHÔNG đổi trạng thái"); + } + } + + [Fact] + public async Task SetWorkflow_WrongApplicableType_ThrowsConflict_DoesNotPin() + { + var (fix, db, clock) = NewCtx(); + using (fix) + { + var requester = await fix.CreateUserAsync("req-sw2@test.local", "Requester", null, Array.Empty()); + + // Workflow loại OtRequest (=6) — KHÔNG khớp LeaveRequest (=5) + var otWf = new ApprovalWorkflow + { + Id = Guid.NewGuid(), + Code = "QT-OT-X", + Version = 1, + Name = "OT workflow", + ApplicableType = ApprovalWorkflowApplicableType.OtRequest, + IsActive = true, + IsUserSelectable = true, + }; + db.ApprovalWorkflows.Add(otWf); + + var leave = BuildLeave(requester.Id, workflowId: null, WorkflowAppStatus.Nhap, currentLevel: null); + db.LeaveRequests.Add(leave); + await db.SaveChangesAsync(CancellationToken.None); + + var act = async () => await new SetLeaveRequestWorkflowHandler(db, AsUser(requester), clock) + .Handle(new SetLeaveRequestWorkflowCommand(leave.Id, otWf.Id), CancellationToken.None); + + await act.Should().ThrowAsync().WithMessage("*Đơn nghỉ phép*"); + leave.ApprovalWorkflowId.Should().BeNull("guard chặn trước khi pin"); + } + } + + [Fact] + public async Task SetWorkflow_WhenAlreadySubmitted_ThrowsConflict() + { + var (fix, db, clock) = NewCtx(); + using (fix) + { + var requester = await fix.CreateUserAsync("req-sw3@test.local", "Requester", null, Array.Empty()); + var approver = await fix.CreateUserAsync("ap-sw3@test.local", "Approver", null, Array.Empty()); + var (wf, _) = await SeedLeaveWorkflowAsync(db, approver.Id); + + var leave = BuildLeave(requester.Id, wf.Id, WorkflowAppStatus.DaGuiDuyet, currentLevel: 1); + db.LeaveRequests.Add(leave); + await db.SaveChangesAsync(CancellationToken.None); + + var act = async () => await new SetLeaveRequestWorkflowHandler(db, AsUser(requester), clock) + .Handle(new SetLeaveRequestWorkflowCommand(leave.Id, wf.Id), CancellationToken.None); + + await act.Should().ThrowAsync().WithMessage("*Nháp hoặc Trả lại*"); + } + } }