Compare commits

..

11 Commits

Author SHA1 Message Date
3e92584238 [CLAUDE] App: Plan B Hotfix Reviewer — CreateContractCommand validate ApplicableType=Contract
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m30s
Reviewer pre-push verify (agentId ace4799) catch MAJOR security gap:
CreateContractCommand thiếu validation guard rằng ApprovalWorkflowId pin
phải có ApplicableType=Contract(3). Attacker forge POST body với V2 PE
workflow ID (ApplicableType=1/2 DuyetNcc) → contract pin sai workflow type
→ Service ApproveV2Async sẽ run pattern PE workflow trên Contract entity
→ behavior nondeterministic + audit log nhầm.

Fix: Mirror PE pattern PurchaseEvaluationFeatures.cs:62-77.

Validation block thêm vào CreateContractCommandHandler.Handle sau activeWfId
query:
1. Load aw entity by Id (throw NotFound nếu invalid Guid)
2. Verify aw.ApplicableType == Contract(3) (throw Conflict nếu mismatch)

Defense-in-depth: FE Workspace dropdown (Chunk D 62b50d1) đã filter
ApplicableType=3 client-side; BE guard chặn request forge.

Verify:
- dotnet build PASS 0 err 2 pre-existing warn
- dotnet test 111/111 PASS — 0 regression
- Mirror PE pattern exact (only switch enum DuyetNcc/PhuongAn → Contract literal)

Smart Friend ROI: Reviewer caught MAJOR before push prod. Cumulative S22 #44
+ S25 #48 + S29 (this Hotfix) — pattern proven 3× Reviewer save UAT 401/403
prod incidents.

Plan B chain COMPLETE 10/10 (9 + 1 hotfix):
- A1 58898e8 / A2 a85e437 / B 138469d / C 26c98d3 / B2 1f199b0
- E1 ef23308 / D 62b50d1 / E2 48f6d22 / E3 14feb69
- Hotfix Rev (this) ApplicableType=Contract guard

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:52:40 +07:00
14feb6955d [CLAUDE] FE-Admin+FE-User: Plan B Chunk E3 — ContractDetailPage Section 5 LevelOpinionsV2 dynamic
Em main solo sau Implementer E3 stuck mid-task. Minimum viable mirror PE
Section 5 LevelOpinionsSectionV2 pattern. V2 contract pin ApprovalWorkflowId
→ render dynamic Section 5 với opinion data UPSERT từ Service ApproveV2Async
(Plan B Chunk B2 1f199b0).

Changes × 2 app:

types/contracts.ts (×2):
- ContractDetail +3 fields V2 (default null backward compat):
  - approvalWorkflowId: string | null
  - currentApprovalLevelOrder: number | null
  - levelOpinions: ContractLevelOpinion[] | null
- NEW type ContractLevelOpinion (12 fields mirror BE DTO)

components/contracts/ContractDetailContent.tsx (×2):
- Add Section 5 sau "Chi tiết HĐ" section
- Conditional render: chỉ khi c.approvalWorkflowId (V2 mode)
- Empty placeholder "Chưa có ý kiến" khi workflow vừa start
- forEach opinion render card:
  - Title: Bước X (StepName) — Cấp Y (LevelName)
  - Comment + signedAt vi-VN format
  - NV duyệt: approverFullName
  - Banner " Admin duyệt thay" khi signedByUserId !== approverUserId
- Style: emerald palette mirror PE Section 5

V1 contract: 3 fields null → Section 5 KHÔNG render (backward compat).

Verify:
- npm run build × 2 app PASS 0 TS err
- Mirror 2 app §3.9 byte-similar (4 file edit cùng pattern)
- BE wire OK: E2 commit 48f6d22 expose approvalWorkflowId + levelOpinions

Plan B COMPLETE 9/9 chunks LOCAL:
- A1 58898e8  Entity +2 fields
- A2 a85e437  Mig 32 + Config + Seed
- B 138469d  Service ApproveV2Async branch
- C 26c98d3  Mig 33 LevelOpinions
- B2 1f199b0  UPSERT block
- E1 ef23308  CreateContractCommand +V2
- D 62b50d1  FE Workspace V2
- E2 48f6d22  ContractDetailDto + populate
- E3 (this)  FE Section 5 LevelOpinionsV2

Implementer E3 spawn stopped mid-task ("check ContractDetail type" judgment
call) → em main solo finish. Pattern lesson: complex FE feature mirror với
type extend + new component → em main solo more reliable than Implementer.

Pending: Reviewer pre-commit Plan B cumulative + push remote + CICD verify
+ docs/STATUS update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:45:33 +07:00
48f6d22b3d [CLAUDE] App: Plan B Chunk E2 — ContractDetailDto +ApprovalWorkflowId + LevelOpinions[] populate
Mirror PE PeDetailBundle pattern. Expose V2 workflow state cho FE Section 5
Chunk E3 (Implementer pending) render dynamic LevelOpinionsSectionV2.

Changes:
- ContractDtos.cs:
  - ContractDetailDto +3 fields (default null backward compat):
    - Guid? ApprovalWorkflowId (V2 pin)
    - int? CurrentApprovalLevelOrder
    - List<ContractLevelOpinionDto>? LevelOpinions
  - NEW record ContractLevelOpinionDto (mirror PE 12 fields)
- ContractFeatures.cs GetContractQueryHandler:
  - Load LevelOpinions via 3-step JOIN (ContractLevelOpinions + ApprovalWorkflowLevels.Include(Step) + Users)
  - Map to ContractLevelOpinionDto với StepOrder/Name + LevelOrder/Name + Approver/SignedBy resolve
  - OrderBy StepOrder + LevelOrder
  - Null fallback Comment "" (CS8604 silence)
  - Empty list khi V2 pin nhưng KHÔNG có opinion (workflow start lúc Drafter trình)
  - Skip load nếu V1 (ApprovalWorkflowId null) → null marker FE detect

FE Chunk E3 sẽ:
- Detect V2 mode qua `bundle.approvalWorkflowId != null`
- Fetch ApprovalFlow shape via existing /api/approval-workflows-v2/{ApprovalWorkflowId}
- Render Section 5 dynamic forEach Step → forEach Level → 1 OpinionBox với opinion data from LevelOpinions[]

Verify:
- dotnet build PASS 0 err, 0 warn (clean)
- dotnet test 111/111 PASS — 0 regression
- V1 legacy contract Detail unchanged (ApprovalWorkflowId=null + LevelOpinions=null)

Plan B chain status (8/9 chunks done):
- A1 58898e8  Entity
- A2 a85e437  Mig 32 + Seed
- B 138469d  Service ApproveV2 branch
- C 26c98d3  Mig 33 LevelOpinions
- B2 1f199b0  UPSERT block
- E1 ef23308  CreateContractCommand +V2
- D 62b50d1  FE Workspace V2
- E2 (this)  ContractDetailDto +V2 + LevelOpinions populate
- E3 FE Section 5 LevelOpinionsV2 (Implementer next)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:38:38 +07:00
62b50d112b [CLAUDE] FE-Admin+FE-User: Plan B Chunk D — ContractCreatePage Workspace V2 Select dropdown
Mirror PE PeWorkspaceCreateView Workspace pattern. Drafter pick V2
workflow IsUserSelectable=true filter ApplicableType=Contract(3).

Changes × 2 app:
- Add useQuery fetch /api/approval-workflows-v2?applicableType=3 + filter
  client-side isUserSelectable=true (mirror PE Mig 25 pattern)
- Add Select dropdown "Quy trình duyệt V2 (tùy chọn)" trong
  ContractHeaderForm (create mode panel 2)
- Wire approvalWorkflowId vào CreateContractCommand POST body
- Conditional UI: blank = V1 fallback auto pick (7 prod contract behavior
  giữ nguyên); user pick V2 → pin ApprovalWorkflowId Mig 32 schema
- Hint khi 0 workflows V2 admin ghim → message rõ V1 fallback

Verify:
- npm run build × 2 app PASS 0 TS err (1.32MB fe-user, 1.40MB fe-admin)
- Mirror 2 app §3.9: +44 LOC mỗi file = +88 LOC total byte-similar
- API endpoint /api/approval-workflows-v2 existing (Mig 25 Plan AA S24)
- BE CreateContractCommand.ApprovalWorkflowId field đã add Chunk E1
  (em main commit prior) — FE wire safe
- Backward compat: V1 contract path unchanged khi user bỏ trống dropdown

Plan B chain (6 chunks):
- A1 58898e8  Entity ApprovalWorkflowId + CurrentApprovalLevelOrder
- A2 a85e437  Mig 32 + Seed sample V2 Contract workflow
- B 138469d  Service ApproveV2 branch (PE pattern mirror)
- C 26c98d3  Mig 33 ContractLevelOpinions
- B2 1f199b0  UPSERT LevelOpinion block (PE Mig 26 mirror)
- D (this)  FE Workspace V2 dropdown
- E FE Section 5 V2 (em main + Implementer split E1+E2 sau)

Pattern 16-bis 4-place mirror check:
- Page file × 2 app: edited (insertion mirror byte-similar)
- App.tsx Routes: N/A (enhance existing /contracts/new route)
- menuKeys.ts: N/A (không thêm menu key mới)
- Layout staticMap: N/A (route unchanged)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:34:00 +07:00
b51fc94ca6 [CLAUDE] Skill: Add MCP RAG tools cho 4 sub-agent definitions
Add mcp__rag-unified__search_memory + mcp__rag-unified__cross_project_search
vào tools list 4 agents (Investigator + Implementer + Reviewer + CICD Monitor).

Tại sao:
- Sub-agent spawn KHÔNG inherit MCP server access từ parent session
- 4 agents previously CHỈ có Read/Grep/Glob/Bash → re-read MD files manually
- Plan B pre-flight Investigator phải Read PE Mig 22-26 thủ công thay vì 1 RAG query
- Plan CA Reviewer Cat 1 wire claim verify KHÔNG retrieve historical gotcha cross-session
- Plan CA Hotfix 1 silent sidebar drop nếu Implementer có RAG → catch Pattern 16-bis trước commit

Trade-off accepted (anh chốt full 4 agents):
- Token cost spawn cao hơn (~5-10K extra per RAG query)
- Risk noise dilute focus → mitigate by skill-specific prompt focus

Pitfall #1 reinforced (S27 multi-agent setup):
- Session đang chạy KHÔNG hot-reload registry
- Anh restart Claude Code CLI để spawn S30+ pick up MCP RAG tools
- Plan B Chunk D Implementer đang chạy dùng config CŨ (no MCP) — KHÔNG affect

Verify post-restart (Anh):
- Spawn test Investigator → call mcp__rag-unified__search_memory thử
- Pass = MCP tools loaded; Fail = YAML syntax issue (fallback wildcard mcp__rag-unified__*)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:32:58 +07:00
ef2330871d [CLAUDE] App: Plan B Chunk E1 — CreateContractCommand +ApprovalWorkflowId V2 pin field
Mirror PE CreatePurchaseEvaluationCommand pattern. Drafter pick V2 workflow
qua Workspace Select dropdown (Chunk D FE Implementer running parallel) →
ApprovalWorkflowId pin lúc create. Fallback V1 auto activeWfId nếu null
(7 prod contract giữ behavior).

Changes:
- CreateContractCommand record +Guid? ApprovalWorkflowId = null (optional)
- Handler line 96 wire entity.ApprovalWorkflowId = request.ApprovalWorkflowId
- Both V1 + V2 fields persist (Service ApproveV2Async branch dispatch theo V2 first)

Verify:
- dotnet build PASS 0 err
- Backward compat: existing caller (KHÔNG pass ApprovalWorkflowId) → fallback null
- V1 contract path UNCHANGED

Plan B chain status:
- A1 58898e8  Entity
- A2 a85e437  Mig 32 + Seed
- B 138469d  Service ApproveV2 branch
- C 26c98d3  Mig 33 LevelOpinions
- B2 1f199b0  UPSERT block
- E1 (this)  CreateContractCommand +ApprovalWorkflowId
- D FE Workspace V2 (Implementer running parallel)
- E2 ContractDetailDto + GetContractByIdQuery extend (em main pending)
- E3 FE Section 5 LevelOpinionsV2 (Implementer pending sau E2)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:30:06 +07:00
1f199b01a5 [CLAUDE] Infra: Plan B Chunk B2 — UPSERT ContractLevelOpinion + ResolveActorFullName helper
Replace TODO marker trong Chunk B 138469d (line 257-262) bằng UPSERT block
mirror PE Mig 26 line 512-546.

Changes:
- ApproveV2Async: move matchingLevel computation UP (trước UPSERT block)
- +UPSERT ContractLevelOpinion ~25 LOC:
  - Match level theo ApproverUserId (OR-of-N) + fallback first (admin override)
  - Empty comment → "(duyệt — không ý kiến)" placeholder
  - Insert mới hoặc update existing (UPSERT semantic)
  - SignedByUserId + SignedByFullName denormalized cho Section 5 FE
- skipToFinal block reuse matchingLevel (KHÔNG re-compute)
- +ResolveActorFullNameAsync helper (mirror PE line 774-783)

Section 5 FE (Chunk E) sẽ render dynamic theo flow.steps[].levels[] với
opinion data từ table này. Admin override → FE detect SignedByUserId !==
Level.ApproverUserId → banner "Admin duyệt thay".

Verify:
- dotnet build SolutionErp.slnx PASS 0 err, 2 pre-existing DocxRenderer warn
- dotnet test 111/111 PASS — 0 regression
- V1 legacy path UNCHANGED (7 prod contract giữ behavior)

Plan B chain status:
- A1 58898e8  Entity +2 fields
- A2 a85e437  Mig 32 + Config + Seed
- B 138469d  Service ApproveV2Async branch (UPSERT TODO)
- C 26c98d3  Mig 33 ContractLevelOpinions
- B2 (this)  UPSERT block (resolve TODO Chunk B)
- D FE Workspace V2 (Implementer, next)
- E FE Section 5 V2 (Implementer, pending)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:27:46 +07:00
26c98d3c11 [CLAUDE] Domain+App+Infra: Plan B Chunk C — Mig 33 ContractLevelOpinions cookie-cutter mirror PE Mig 26
- Domain/Contracts/ContractLevelOpinion.cs (NEW entity mirror PE — AuditableEntity, 4 field core + 2 nav)
- Domain/Contracts/Contract.cs (+LevelOpinions nav collection)
- Migrations/20260522052240_AddContractLevelOpinions.cs (3-file rule: .cs + .Designer.cs + Snapshot)
- Configurations/ContractLevelOpinionConfiguration.cs (NEW separate file, mirror PE pattern)
- IApplicationDbContext.cs + ApplicationDbContext.cs (+DbSet<ContractLevelOpinion>)

UNIQUE composite (ContractId, ApprovalWorkflowLevelId) — 1 row per HĐ × Level.
FK Cascade Contract + Restrict ApprovalWorkflowLevel.
SignedByUserId KHÔNG nav (denorm SignedByFullName tránh cascade khi xoá user).

Mirror PE Mig 26 pattern (S19 2026-05-09) EXACT — UPSERT row khi Approver duyệt qua
Service ApproveV2Async (Plan B Chunk B em main 138469d đã có TODO marker).
Em main sẽ add UPSERT block sau Chunk C done (Chunk D).

Verify:
- dotnet build PASS 0 err (2 pre-existing warn DocxRenderer unrelated)
- dotnet ef database update PASS (Mig 33 applied SolutionErp_Dev + _Design)
- dotnet test 111/111 PASS (58 Domain + 53 Infra — no regression)

Plan B chain (6 chunks):
- A1 58898e8  ContractApprovalWorkflowV2 entity scaffold
- A2 a85e437  Contract.ApprovalWorkflowId + ContractConfiguration FK
- B 138469d  ContractWorkflowService ApproveV2Async skeleton + TODO LevelOpinion UPSERT
- C (this)  ContractLevelOpinions entity + Mig 33 + config + DbSet
- D FE Workspace V2 (Implementer, pending)
- E FE Section 5 V2 (Implementer, pending)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:24:38 +07:00
138469db4e [CLAUDE] Infra+App: Plan B Chunk B — Service ApproveV2Async branch + gen mã HĐ adapt
Mirror PE PurchaseEvaluationWorkflowService.cs:ApproveV2Async (line 446-634).
V1 legacy giữ behavior cũ — 7 prod contract chạy nhánh này. V2 mới pin
ApprovalWorkflowId chạy ApproveV2Async helper.

Changes:
- ContractWorkflowService.cs:
  - TransitionAsync +skipToFinal=false param F2 (Mig 31 Plan K mirror PE)
  - Drafter trình init CurrentApprovalLevelOrder=1 nếu V2 schema pin
  - APPROVE STEP branch V2/V1 dispatch theo ApprovalWorkflowId
  - +ApproveV2Async helper ~150 LOC (mirror PE pattern):
    - Load AW.Steps.Levels OR-of-N
    - Match approver actor.Id ∈ pendingLevelGroup.ApproverUserId
    - Add ContractApproval row + enrich comment skipPrefix
    - skipToFinal F2: AllowApproverSkipToFinal guard + advance pointer last
    - Advance level/step normal
    - Terminal: gen mã HĐ RG-001 + Phase=DaPhatHanh (khác PE just DaDuyet)
- IContractWorkflowService.cs: TransitionAsync +skipToFinal=false param
- ContractFeatures.cs: caller TransitionAsync use named arg ct: ct (skip optional)

TODO Chunk C: UPSERT ContractLevelOpinion (table chưa tồn tại — Mig 33
sẽ scaffold + entity + EF config). Block UPSERT add ở đây sau Chunk C done.

Verify:
- dotnet build SolutionErp.slnx PASS 0 err, 2 pre-existing DocxRenderer warn
- dotnet test 111/111 PASS (58 Domain + 53 Infra) — 0 regression
- V1 legacy path UNCHANGED (7 prod contract giữ behavior)

Plan B chain (6 chunks):
- A1 58898e8 Contract +2 fields (em main, done)
- A2 a85e437 Mig 32 schema + Config + Seed (Implementer Case 2, done)
- B (this) Service ApproveV2Async branch (em main, done)
- C Mig 33 ContractLevelOpinions (Implementer, next)
- D FE Workspace V2 (Implementer, pending)
- E FE Section 5 V2 (Implementer, pending)

Race condition lesson: em main + Implementer parallel touch BE same plan
→ Implementer stash em main WIP for clean build verify. Solution: SEQUENTIAL
chunks A→B→C, NOT parallel B với A2. Pattern add to Implementer MEMORY.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:18:46 +07:00
a85e437478 [CLAUDE] Infra: Plan B Chunk A2 — Mig 32 Contract V2 schema + Configuration + Seed sample workflow
Cookie-cutter mirror PE Mig 23+24 GỘP thành 1 Mig 32 (ADD 2 column +
FK + IX). Mirror Mig 26 pattern cho FK Restrict.

Files added/modified:
- Migrations/20260522051059_AddApprovalWorkflowToContract.cs (3-file rule )
- Migrations/20260522051059_AddApprovalWorkflowToContract.Designer.cs
- Migrations/ApplicationDbContextModelSnapshot.cs (updated)
- Configurations/ContractConfiguration.cs (+HasIndex + FK Restrict ApprovalWorkflows)
- Persistence/DbInitializer.cs (SeedSampleContractWorkflowV2 idempotent QT-HD-V2-001)

Mig 32 Up():
- ADD COLUMN Contracts.ApprovalWorkflowId Guid? NULL
- ADD COLUMN Contracts.CurrentApprovalLevelOrder int? NULL
- ADD INDEX IX_Contracts_ApprovalWorkflowId (filtered NOT NULL)
- ADD FK FK_Contracts_ApprovalWorkflows_ApprovalWorkflowId Restrict

Seed sample workflow (UAT smoke + admin Designer default):
- Code: QT-HD-V2-001 Name: "Quy trình duyệt HĐ mẫu UAT V2"
- ApplicableType: 3 (Contract) IsActive: true IsUserSelectable: true
- 1 Step "Bước 1 - Phòng CCM" + 1 Level + Approver Lê Văn Bình CCM
- Idempotent: skip nếu Code+Version existing

V1 coexist: 7 prod contract giữ WorkflowDefinitionId; V2 mới pin
ApprovalWorkflowId. Service ApproveV2Async (Chunk B em main) sẽ branch.

Verify (Implementer):
- dotnet build SolutionErp.slnx PASS 0 err (em main WIP stashed for verify)
- dotnet ef database update Dev PASS (Mig 32 applied)
- 3-file rule Mig: mig.cs + Designer.cs + Snapshot.cs

Plan B chain (6 chunks):
- A1 58898e8 Contract +2 fields (em main, done)
- A2 (this) Mig 32 schema + Config + Seed (Implementer Case 2, done)
- B Service ApproveV2Async branch (em main, in progress)
- C Mig 33 ContractLevelOpinions (Implementer, pending)
- D FE Workspace V2 (Implementer, pending)
- E FE Section 5 V2 (Implementer, pending)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:15:11 +07:00
58898e8fbe [CLAUDE] Domain: Plan B Chunk A1 — Contract +2 fields V2 (ApprovalWorkflowId + CurrentApprovalLevelOrder)
Mirror PE Mig 22-24 pattern. V1+V2 coexist (7 V1 contract giữ
WorkflowDefinitionId, V2 mới pin ApprovalWorkflowId).

Fields added:
- ApprovalWorkflowId Guid? — pin schema mới ApprovalWorkflowsV2
- CurrentApprovalLevelOrder int? — Cấp đang chờ duyệt (1/2/3) trong Step

Service ApproveV2Async branch (Chunk B) sẽ dispatch:
- if (contract.ApprovalWorkflowId is Guid awId) ApproveV2Async
- else ApproveV1Legacy (giữ behavior 7 V1 contract)

Verify:
- dotnet build SolutionErp.slnx PASS 0 err, 2 pre-existing DocxRenderer warn
- No migration (Chunk A2 sẽ scaffold Mig 32)

Plan B chain (6 chunks):
- A1 (this) Entity +2 fields (em main)
- A2 Mig 32 schema (Implementer Case 2 cookie-cutter)
- B Service ApproveV2Async branch (em main critical ~200 LOC)
- C Mig 33 ContractLevelOpinions (Implementer)
- D FE Workspace V2 (Implementer)
- E FE Section 5 LevelOpinionsV2 (Implementer)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:07:01 +07:00
26 changed files with 8927 additions and 17 deletions

View File

@ -2,9 +2,8 @@
name: cicd-monitor
description: |
CI/CD pipeline + post-deploy verification specialist for SOLUTION_ERP. Use proactively AFTER every push to main that triggers Gitea Actions deploy (code commits — skip docs-only per path-filter gotcha #41). Polls Gitea Actions run status via API, verifies test gate pass (Domain 58 + Infra 23 tests baseline), confirms deploy actually shipped (FE bundle hash change × 2 app + EF migrations applied prod), smoke tests prod endpoints (api/admin/eoffice.solutions.com.vn). NEVER writes code — produces PASS/FAIL verdict with concrete evidence from logs + curl + sqlcmd. Catches deploy fail tự động không phụ thuộc em main nhớ verify.
model: claude-opus-4-7
effort: max
tools: [Read, Grep, Glob, Bash, WebFetch]
model: inherit
tools: [Read, Grep, Glob, Bash, WebFetch, mcp__rag-unified__search_memory, mcp__rag-unified__cross_project_search]
skills:
- iis-deploy-runbook
- dependency-audit-erp

View File

@ -2,9 +2,8 @@
name: implementer
description: |
Code execution specialist for SOLUTION_ERP. Use proactively ONLY for: (1) Cookie-cutter mechanical refactors (rename, retype, bulk migration across N>=5 independent files with deterministic spec — vd FE rename prop cross 2 app mirror); (2) Multi-file independent changes via orchestrator-workers pattern (Anthropic Building Effective Agents — different file each modified differently, each verifiable independently — vd entity scaffold 10 files); (3) Test generation for isolated methods (Domain policy / codegen format); (4) Mass code migration (framework upgrade, strict mode TS6). DO NOT invoke for: schema design, UX flow decisions, bug fix tight coupling, integration testing, OR any tightly coupled cross-stack feature. Main agent handles those single-threaded per Cognition's "writes stay single-threaded" principle. Implementer auto-refuses out-of-scope tasks.
model: claude-opus-4-7
effort: max
tools: [Read, Edit, Write, Bash, Skill, Grep, Glob]
model: inherit
tools: [Read, Edit, Write, Bash, Skill, Grep, Glob, mcp__rag-unified__search_memory, mcp__rag-unified__cross_project_search]
skills:
- ef-core-migration
- permission-matrix

View File

@ -2,9 +2,8 @@
name: investigator
description: |
Read-only research and audit specialist for SOLUTION_ERP codebase. Use proactively when main agent needs to scan >5 files for patterns, audit controllers/endpoints, research external sources (Anthropic docs, community blogs), pre-flight reconnaissance before implementation, smoke test endpoints, search V1/V2 workflow schema or sys.triggers, gather reference implementations from similar features (PE → Contract V2 mirror), audit memory entries cross-reference. NEVER writes code — only returns concise structured findings.
model: claude-opus-4-7
effort: max
tools: [Read, Grep, Glob, Bash, WebFetch, WebSearch]
model: inherit
tools: [Read, Grep, Glob, Bash, WebFetch, WebSearch, mcp__rag-unified__search_memory, mcp__rag-unified__cross_project_search]
skills:
- contract-workflow
- permission-matrix

View File

@ -2,9 +2,8 @@
name: reviewer
description: |
Adversarial code review specialist for SOLUTION_ERP. Use proactively BEFORE every commit involving: wire BE claim (especially CRUD endpoints with POST/PUT/DELETE), schema migration, cross-stack feature, security-sensitive diff, or any change > 50 LOC. Provides independent verification that main agent's implementation matches spec, catches blind spots from self-review bias (gotcha #44 silent 403 type issues), and runs live verification on prod UAT environment for deploy claims. NEVER writes code — produces PASS/FAIL verdict with concrete issues file:line.
model: claude-opus-4-7
effort: max
tools: [Read, Grep, Glob, Bash]
model: inherit
tools: [Read, Grep, Glob, Bash, mcp__rag-unified__search_memory, mcp__rag-unified__cross_project_search]
skills:
- dependency-audit-erp
- iis-deploy-runbook

View File

@ -182,6 +182,47 @@ export function ContractDetailContent({
<ContractDetailsTab contract={c} />
</section>
{/* [Plan B S29 2026-05-22 Chunk E3] Section 5 — Ý kiến cấp duyệt V2 dynamic.
Mirror PE LevelOpinionsSectionV2 pattern. Chỉ render khi V2 pin
(approvalWorkflowId set). V1 legacy contract KHÔNG hiển thị. */}
{c.approvalWorkflowId && (
<section className="rounded-lg border border-emerald-200 bg-emerald-50/40 p-5">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-emerald-800">
<ListChecks className="h-4 w-4" />
Ý kiến cấp duyệt (Quy trình V2)
</h2>
{(c.levelOpinions?.length ?? 0) === 0 ? (
<p className="text-sm text-slate-500">Chưa ý kiến workflow vừa bắt đu hoặc chưa ai duyệt.</p>
) : (
<ul className="space-y-3">
{c.levelOpinions!.map(o => {
const adminProxy = o.signedByUserId !== o.approverUserId
return (
<li key={o.id} className="rounded-md border border-emerald-200 bg-white p-3">
<div className="mb-1 flex items-center justify-between gap-2 text-xs">
<span className="font-medium text-emerald-900">
Bước {o.stepOrder} {o.stepName ? `(${o.stepName})` : ''} Cấp {o.levelOrder}
{o.levelName ? ` (${o.levelName})` : ''}
</span>
<span className="text-slate-500">{new Date(o.signedAt).toLocaleString('vi-VN')}</span>
</div>
<p className="mb-1 text-sm text-slate-800">{o.comment}</p>
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-600">
<span>NV duyệt: <strong>{o.approverFullName ?? o.approverUserId.slice(0, 8)}</strong></span>
{adminProxy && (
<span className="rounded bg-amber-100 px-2 py-0.5 text-amber-800">
Admin duyệt thay ({o.signedByFullName ?? o.signedByUserId.slice(0, 8)})
</span>
)}
</div>
</li>
)
})}
</ul>
)}
</section>
)}
<Dialog
open={actionOpen}
onClose={() => setActionOpen(false)}

View File

@ -309,6 +309,11 @@ function ContractHeaderForm({
const [budgetManual, setBudgetManual] = useState(false)
const [budgetManualName, setBudgetManualName] = useState('')
const [budgetManualAmount, setBudgetManualAmount] = useState(0)
// [Plan B S29 Chunk D 2026-05-22 Mig 32] V2 workflow pin lúc create — mirror
// PE PeWorkspaceCreateView pattern. Drafter pick V2 workflow IsUserSelectable
// filter ApplicableType=Contract(3). Nếu blank → BE fallback V1 auto pick
// (7 prod contract giữ behavior cũ).
const [approvalWorkflowId, setApprovalWorkflowId] = useState('')
// Reset type về default khi typeFilter (parent prop) thay đổi
useEffect(() => { setType(defaultType) }, [defaultType])
@ -327,6 +332,19 @@ function ContractHeaderForm({
queryKey: ['templates-by-type', type],
queryFn: async () => (await api.get<ContractTemplate[]>('/forms/templates', { params: { type } })).data,
})
// [Plan B S29 Chunk D Mig 32] V2 workflows ApplicableType=Contract(3) filter
// IsUserSelectable=true (admin ghim cho user pick). Mirror PE Mig 25 pattern.
const approvalWorkflows = useQuery({
queryKey: ['approval-workflows-v2-contract'],
queryFn: async () => {
const res = await api.get<{ types: { applicableType: number; history: { id: string; code: string; version: number; name: string; isActive: boolean; isUserSelectable: boolean }[] }[] }>(
'/approval-workflows-v2',
{ params: { applicableType: 3 } },
)
const typeBucket = res.data.types.find(t => t.applicableType === 3)
return (typeBucket?.history ?? []).filter(w => w.isUserSelectable)
},
})
// Eligible Budgets: cùng Project + Phase=DaDuyet (BE-side filter).
const eligibleBudgets = useQuery({
queryKey: ['eligible-budgets', projectId],
@ -357,6 +375,9 @@ function ContractHeaderForm({
bypassProcurementAndCCM: bypass,
draftData: null,
...budgetPayload,
// [Plan B S29 Chunk D Mig 32] Pin V2 workflow nếu user chọn; null →
// BE fallback V1 auto pick.
approvalWorkflowId: approvalWorkflowId || null,
})
return res.data.id
},
@ -395,6 +416,29 @@ function ContractHeaderForm({
templates={templates.data ?? []}
typeReadonly={false}
/>
{/* [Plan B S29 Chunk D Mig 32] V2 workflow picker — mutually exclusive
với V1. Blank → BE fallback V1 auto pick (7 prod contract behavior).
Mirror PE PeWorkspaceCreateView pattern. */}
<div className="mt-4 space-y-1.5">
<Label>Quy trình duyệt V2 <span className="text-[10px] font-normal text-slate-400">(tùy chọn bỏ trống = dùng V1 mặc đnh)</span></Label>
<Select
value={approvalWorkflowId}
onChange={e => setApprovalWorkflowId(e.target.value)}
>
<option value=""> V1 mặc đnh (auto pick) </option>
{approvalWorkflows.data?.map(w => (
<option key={w.id} value={w.id}>
{w.code} v{String(w.version).padStart(2, '0')} {w.name}
{w.isActive ? ' (đang áp dụng)' : ''}
</option>
))}
</Select>
{approvalWorkflows.data && approvalWorkflows.data.length === 0 && (
<p className="text-[11px] text-slate-500">
Chưa quy trình duyệt V2 nào đưc admin ghim cho sẽ chạy theo V1 mặc đnh.
</p>
)}
</div>
<div className="mt-4 space-y-1.5">
<div className="flex items-center justify-between">
<Label className="mb-0">Ngân sách (đi chiếu chi phí)</Label>

View File

@ -171,4 +171,28 @@ export type ContractDetail = {
comments: ContractComment[]
attachments: ContractAttachment[]
workflow: WorkflowSummary
// [Plan B S29 2026-05-22 Chunk E3] V2 workflow state — mirror PE pattern.
// Khi pin V2 (approvalWorkflowId set) → render Section 5 dynamic.
// V1 contract: 3 fields = null.
approvalWorkflowId: string | null
currentApprovalLevelOrder: number | null
levelOpinions: ContractLevelOpinion[] | null
}
// [Plan B S29 2026-05-22 Chunk E3] Mirror BE ContractLevelOpinionDto 12 fields.
// Service ApproveV2Async UPSERT (Plan B Chunk B2). Comment empty → "(duyệt —
// không ý kiến)". signedByUserId !== approverUserId → banner "Admin duyệt thay".
export type ContractLevelOpinion = {
id: string
approvalWorkflowLevelId: string
stepOrder: number
stepName: string
levelOrder: number
levelName: string | null
approverUserId: string
approverFullName: string | null
comment: string
signedAt: string
signedByUserId: string
signedByFullName: string | null
}

View File

@ -182,6 +182,47 @@ export function ContractDetailContent({
<ContractDetailsTab contract={c} />
</section>
{/* [Plan B S29 2026-05-22 Chunk E3] Section 5 — Ý kiến cấp duyệt V2 dynamic.
Mirror PE LevelOpinionsSectionV2 pattern. Chỉ render khi V2 pin
(approvalWorkflowId set). V1 legacy contract KHÔNG hiển thị. */}
{c.approvalWorkflowId && (
<section className="rounded-lg border border-emerald-200 bg-emerald-50/40 p-5">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-emerald-800">
<ListChecks className="h-4 w-4" />
Ý kiến cấp duyệt (Quy trình V2)
</h2>
{(c.levelOpinions?.length ?? 0) === 0 ? (
<p className="text-sm text-slate-500">Chưa ý kiến workflow vừa bắt đu hoặc chưa ai duyệt.</p>
) : (
<ul className="space-y-3">
{c.levelOpinions!.map(o => {
const adminProxy = o.signedByUserId !== o.approverUserId
return (
<li key={o.id} className="rounded-md border border-emerald-200 bg-white p-3">
<div className="mb-1 flex items-center justify-between gap-2 text-xs">
<span className="font-medium text-emerald-900">
Bước {o.stepOrder} {o.stepName ? `(${o.stepName})` : ''} Cấp {o.levelOrder}
{o.levelName ? ` (${o.levelName})` : ''}
</span>
<span className="text-slate-500">{new Date(o.signedAt).toLocaleString('vi-VN')}</span>
</div>
<p className="mb-1 text-sm text-slate-800">{o.comment}</p>
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-600">
<span>NV duyệt: <strong>{o.approverFullName ?? o.approverUserId.slice(0, 8)}</strong></span>
{adminProxy && (
<span className="rounded bg-amber-100 px-2 py-0.5 text-amber-800">
Admin duyệt thay ({o.signedByFullName ?? o.signedByUserId.slice(0, 8)})
</span>
)}
</div>
</li>
)
})}
</ul>
)}
</section>
)}
<Dialog
open={actionOpen}
onClose={() => setActionOpen(false)}

View File

@ -309,6 +309,11 @@ function ContractHeaderForm({
const [budgetManual, setBudgetManual] = useState(false)
const [budgetManualName, setBudgetManualName] = useState('')
const [budgetManualAmount, setBudgetManualAmount] = useState(0)
// [Plan B S29 Chunk D 2026-05-22 Mig 32] V2 workflow pin lúc create — mirror
// PE PeWorkspaceCreateView pattern. Drafter pick V2 workflow IsUserSelectable
// filter ApplicableType=Contract(3). Nếu blank → BE fallback V1 auto pick
// (7 prod contract giữ behavior cũ).
const [approvalWorkflowId, setApprovalWorkflowId] = useState('')
// Reset type về default khi typeFilter (parent prop) thay đổi
useEffect(() => { setType(defaultType) }, [defaultType])
@ -327,6 +332,19 @@ function ContractHeaderForm({
queryKey: ['templates-by-type', type],
queryFn: async () => (await api.get<ContractTemplate[]>('/forms/templates', { params: { type } })).data,
})
// [Plan B S29 Chunk D Mig 32] V2 workflows ApplicableType=Contract(3) filter
// IsUserSelectable=true (admin ghim cho user pick). Mirror PE Mig 25 pattern.
const approvalWorkflows = useQuery({
queryKey: ['approval-workflows-v2-contract'],
queryFn: async () => {
const res = await api.get<{ types: { applicableType: number; history: { id: string; code: string; version: number; name: string; isActive: boolean; isUserSelectable: boolean }[] }[] }>(
'/approval-workflows-v2',
{ params: { applicableType: 3 } },
)
const typeBucket = res.data.types.find(t => t.applicableType === 3)
return (typeBucket?.history ?? []).filter(w => w.isUserSelectable)
},
})
// Eligible Budgets: cùng Project + Phase=DaDuyet (BE-side filter).
const eligibleBudgets = useQuery({
queryKey: ['eligible-budgets', projectId],
@ -357,6 +375,9 @@ function ContractHeaderForm({
bypassProcurementAndCCM: bypass,
draftData: null,
...budgetPayload,
// [Plan B S29 Chunk D Mig 32] Pin V2 workflow nếu user chọn; null →
// BE fallback V1 auto pick.
approvalWorkflowId: approvalWorkflowId || null,
})
return res.data.id
},
@ -395,6 +416,29 @@ function ContractHeaderForm({
templates={templates.data ?? []}
typeReadonly={false}
/>
{/* [Plan B S29 Chunk D Mig 32] V2 workflow picker — mutually exclusive
với V1. Blank → BE fallback V1 auto pick (7 prod contract behavior).
Mirror PE PeWorkspaceCreateView pattern. */}
<div className="mt-4 space-y-1.5">
<Label>Quy trình duyệt V2 <span className="text-[10px] font-normal text-slate-400">(tùy chọn bỏ trống = dùng V1 mặc đnh)</span></Label>
<Select
value={approvalWorkflowId}
onChange={e => setApprovalWorkflowId(e.target.value)}
>
<option value=""> V1 mặc đnh (auto pick) </option>
{approvalWorkflows.data?.map(w => (
<option key={w.id} value={w.id}>
{w.code} v{String(w.version).padStart(2, '0')} {w.name}
{w.isActive ? ' (đang áp dụng)' : ''}
</option>
))}
</Select>
{approvalWorkflows.data && approvalWorkflows.data.length === 0 && (
<p className="text-[11px] text-slate-500">
Chưa quy trình duyệt V2 nào đưc admin ghim cho sẽ chạy theo V1 mặc đnh.
</p>
)}
</div>
<div className="mt-4 space-y-1.5">
<div className="flex items-center justify-between">
<Label className="mb-0">Ngân sách (đi chiếu chi phí)</Label>

View File

@ -171,4 +171,28 @@ export type ContractDetail = {
comments: ContractComment[]
attachments: ContractAttachment[]
workflow: WorkflowSummary
// [Plan B S29 2026-05-22 Chunk E3] V2 workflow state — mirror PE pattern.
// Khi pin V2 (approvalWorkflowId set) → render Section 5 dynamic.
// V1 contract: 3 fields = null.
approvalWorkflowId: string | null
currentApprovalLevelOrder: number | null
levelOpinions: ContractLevelOpinion[] | null
}
// [Plan B S29 2026-05-22 Chunk E3] Mirror BE ContractLevelOpinionDto 12 fields.
// Service ApproveV2Async UPSERT (Plan B Chunk B2). Comment empty → "(duyệt —
// không ý kiến)". signedByUserId !== approverUserId → banner "Admin duyệt thay".
export type ContractLevelOpinion = {
id: string
approvalWorkflowLevelId: string
stepOrder: number
stepName: string
levelOrder: number
levelName: string | null
approverUserId: string
approverFullName: string | null
comment: string
signedAt: string
signedByUserId: string
signedByFullName: string | null
}

View File

@ -35,6 +35,9 @@ public interface IApplicationDbContext
DbSet<ContractCodeSequence> ContractCodeSequences { get; }
DbSet<ContractChangelog> ContractChangelogs { get; }
DbSet<ContractDepartmentApproval> ContractDepartmentApprovals { get; }
// Plan B Chunk C (S29 — Mig 33) — Ý kiến cấp duyệt V2 dynamic cho HĐ.
// Cookie-cutter mirror PE Mig 26 PurchaseEvaluationLevelOpinions.
DbSet<ContractLevelOpinion> ContractLevelOpinions { get; }
DbSet<Notification> Notifications { get; }
DbSet<WorkflowTypeAssignment> WorkflowTypeAssignments { get; }
DbSet<WorkflowDefinition> WorkflowDefinitions { get; }

View File

@ -27,7 +27,13 @@ public record CreateContractCommand(
string? DraftData,
Guid? BudgetId,
string? BudgetManualName,
decimal? BudgetManualAmount) : IRequest<Guid>;
decimal? BudgetManualAmount,
// [Plan B S29 2026-05-22 Chunk E1] Drafter pick V2 workflow lúc create —
// mirror PE pattern Workspace Select dropdown. Nếu null → fallback V1 auto
// pick activeWfId (7 prod contract giữ behavior). Mutually exclusive với
// V1: pin V2 + V1 cùng lúc OK schema (cả 2 nullable) — Service ApproveV2Async
// branch ưu tiên V2 nếu cả 2 set.
Guid? ApprovalWorkflowId = null) : IRequest<Guid>;
public class CreateContractCommandValidator : AbstractValidator<CreateContractCommand>
{
@ -64,6 +70,21 @@ public class CreateContractCommandHandler(
.Select(w => (Guid?)w.Id)
.FirstOrDefaultAsync(ct);
// [Plan B S29 2026-05-22 Hotfix Reviewer] Validate ApprovalWorkflowId V2
// (Mig 32) — User chọn lúc create. Phải tồn tại + ApplicableType=Contract(3).
// Mirror PE pattern PurchaseEvaluationFeatures.cs:62-77. Defense-in-depth:
// FE Workspace dropdown đã filter ApplicableType=3 server-side; BE guard
// chặn attacker forge POST với PE workflow ID (ApplicableType=1/2).
if (request.ApprovalWorkflowId is Guid awId)
{
var aw = await db.ApprovalWorkflows.AsNoTracking()
.FirstOrDefaultAsync(w => w.Id == awId, ct)
?? throw new NotFoundException("ApprovalWorkflow", awId);
if (aw.ApplicableType != Domain.ApprovalWorkflowsV2.ApprovalWorkflowApplicableType.Contract)
throw new ConflictException(
$"Quy trình {aw.Code} áp dụng cho {aw.ApplicableType}, không khớp với HĐ (cần ApplicableType=Contract).");
}
// Validate Budget link nếu có: cùng Project + Phase=DaDuyet.
if (request.BudgetId is Guid bid)
{
@ -94,6 +115,10 @@ public class CreateContractCommandHandler(
BudgetManualName = request.BudgetManualName,
BudgetManualAmount = request.BudgetManualAmount,
WorkflowDefinitionId = activeWfId,
// [Plan B S29 2026-05-22 Chunk E1] Pin V2 workflow nếu Drafter pick
// qua Workspace Select dropdown (Chunk D FE). Cả 2 set ok — Service
// ApproveV2Async branch dispatch theo ApprovalWorkflowId trước.
ApprovalWorkflowId = request.ApprovalWorkflowId,
SlaDeadline = DateTime.UtcNow.Add(workflow.GetPhaseSla(ContractPhase.DangSoanThao) ?? TimeSpan.FromDays(7)),
};
@ -235,7 +260,7 @@ public class TransitionContractCommandHandler(
currentUser.Roles,
request.Decision,
request.Comment,
ct);
ct: ct);
}
}
@ -467,6 +492,54 @@ public class GetContractQueryHandler(
workflowPolicy = WorkflowPolicyRegistry.ForContractWithOverrides(c, workflowOverrides);
}
// [Plan B S29 2026-05-22 Chunk E2] Load V2 LevelOpinions nếu pin ApprovalWorkflowId.
// JOIN ApprovalWorkflowLevel + Step + Approver User để build dynamic DTO
// cho FE Section 5 render dynamic theo flow shape. V1 legacy → empty list.
List<ContractLevelOpinionDto>? levelOpinionsDto = null;
if (c.ApprovalWorkflowId is Guid awIdLoad)
{
var opinions = await db.ContractLevelOpinions.AsNoTracking()
.Where(o => o.ContractId == c.Id)
.ToListAsync(ct);
if (opinions.Count > 0)
{
var levelIds = opinions.Select(o => o.ApprovalWorkflowLevelId).ToHashSet();
var levels = await db.ApprovalWorkflowLevels.AsNoTracking()
.Include(l => l.Step)
.Where(l => levelIds.Contains(l.Id))
.ToListAsync(ct);
var approverUserIds = levels.Select(l => l.ApproverUserId).ToHashSet();
var approverNames = await userManager.Users.AsNoTracking()
.Where(u => approverUserIds.Contains(u.Id))
.ToDictionaryAsync(u => u.Id, u => u.FullName, ct);
levelOpinionsDto = opinions
.Select(o =>
{
var level = levels.FirstOrDefault(l => l.Id == o.ApprovalWorkflowLevelId);
var step = level?.Step;
return new ContractLevelOpinionDto(
o.Id,
o.ApprovalWorkflowLevelId,
StepOrder: step?.Order ?? 0,
StepName: step?.Name ?? "",
LevelOrder: level?.Order ?? 0,
LevelName: level?.Name,
ApproverUserId: level?.ApproverUserId ?? Guid.Empty,
ApproverFullName: level != null && approverNames.TryGetValue(level.ApproverUserId, out var afn) ? afn : null,
o.Comment ?? "",
o.SignedAt,
o.SignedByUserId,
o.SignedByFullName);
})
.OrderBy(d => d.StepOrder).ThenBy(d => d.LevelOrder)
.ToList();
}
else
{
levelOpinionsDto = new List<ContractLevelOpinionDto>();
}
}
// Resolve user names
var userIds = new HashSet<Guid>();
if (c.DrafterUserId is Guid did) userIds.Add(did);
@ -506,7 +579,11 @@ public class GetContractQueryHandler(
att.Id, att.FileName, att.StoragePath, att.FileSize,
att.ContentType, att.Purpose, att.Note, att.CreatedAt))
.ToList(),
BuildWorkflowSummary(c, workflowPolicy));
BuildWorkflowSummary(c, workflowPolicy),
// [Plan B S29 2026-05-22 Chunk E2] V2 fields
ApprovalWorkflowId: c.ApprovalWorkflowId,
CurrentApprovalLevelOrder: c.CurrentApprovalLevelOrder,
LevelOpinions: levelOpinionsDto);
}
// FE uses this to render next-phase buttons dynamically — no more hardcoded

View File

@ -46,7 +46,34 @@ public record ContractDetailDto(
List<ContractApprovalDto> Approvals,
List<ContractCommentDto> Comments,
List<ContractAttachmentDto> Attachments,
WorkflowSummaryDto Workflow);
WorkflowSummaryDto Workflow,
// [Plan B S29 2026-05-22 Chunk E2] V2 workflow fields — mirror PE pattern.
// ApprovalWorkflowId pin lúc create (Mig 32). FE Section 5 detect V2 mode
// qua field này: nếu Guid → render dynamic LevelOpinionsSectionV2;
// nếu null → V1 legacy KHÔNG Section 5 V2.
// FE fetch ApprovalFlow shape via /api/approval-workflows-v2/{ApprovalWorkflowId}.
Guid? ApprovalWorkflowId = null,
int? CurrentApprovalLevelOrder = null,
List<ContractLevelOpinionDto>? LevelOpinions = null);
// [Plan B S29 2026-05-22 Chunk E2] Ý kiến cấp duyệt V2 dynamic theo
// ApprovalWorkflowLevel. Mirror PE PurchaseEvaluationLevelOpinionDto pattern.
// Service ApproveV2Async UPSERT (Plan B Chunk B2 1f199b0). Comment empty
// fallback "(duyệt — không ý kiến)". SignedByUserId !== Level.ApproverUserId
// → FE show banner "Admin duyệt thay".
public record ContractLevelOpinionDto(
Guid Id,
Guid ApprovalWorkflowLevelId,
int StepOrder,
string StepName,
int LevelOrder,
string? LevelName,
Guid ApproverUserId,
string? ApproverFullName,
string Comment,
DateTime SignedAt,
Guid SignedByUserId,
string? SignedByFullName);
// Policy snapshot for the FE — lets UI render next-phase buttons dynamically
// without hardcoding the transition map (single source of truth in BE).

View File

@ -6,6 +6,9 @@ public interface IContractWorkflowService
{
// Kiểm tra + thực hiện transition. Throw ForbiddenException nếu không hợp lệ.
// Tự tạo ContractApproval row + update Phase + SlaDeadline + gen mã HĐ nếu cần.
// [Plan B S29 2026-05-22] +skipToFinal param F2 (Mig 31): Approver scope
// ChoDuyet skip thẳng Cấp cuối. V2 only, V1 legacy throw nếu non-admin.
// Default false để KHÔNG break existing caller (ContractsController).
Task TransitionAsync(
Contract contract,
ContractPhase targetPhase,
@ -13,6 +16,7 @@ public interface IContractWorkflowService
IReadOnlyList<string> actorRoles,
ApprovalDecision decision,
string? comment,
bool skipToFinal = false,
CancellationToken ct = default);
// SLA còn bao lâu ở phase hiện tại (seconds). Null nếu không có SLA.

View File

@ -20,6 +20,7 @@ public class Contract : AuditableEntity
public string? NoiDung { get; set; }
public bool BypassProcurementAndCCM { get; set; } // HĐ Chủ đầu tư → skip CCM
public Guid? WorkflowDefinitionId { get; set; } // Pinned at creation — HĐ cũ chạy version cũ ngay cả khi admin active version mới
public Guid? ApprovalWorkflowId { get; set; } // [Plan B S29 2026-05-22 Mig 32] Pin schema mới ApprovalWorkflowsV2 — mirror PE Mig 23. V1+V2 coexist: 7 V1 contract giữ WorkflowDefinitionId; V2 mới pin ApprovalWorkflowId. Service ApproveV2Async branch theo field này.
public DateTime? SlaDeadline { get; set; } // Hết hạn phase hiện tại
public string? DraftData { get; set; } // JSON field values (render template)
public bool SlaWarningSent { get; set; } // Flag để không gửi warning 2 lần
@ -39,9 +40,20 @@ public class Contract : AuditableEntity
public int? CurrentWorkflowStepIndex { get; set; }
public int? RejectedAtStepIndex { get; set; }
// [Plan B S29 2026-05-22 Mig 32] V2 workflow tracking — mirror PE Mig 24.
// CurrentApprovalLevelOrder: Cấp đang chờ duyệt (1/2/3) trong Step hiện tại
// khi pin ApprovalWorkflowId. Null khi V1 legacy hoặc V2 terminal (DaPhatHanh).
public int? CurrentApprovalLevelOrder { get; set; }
public List<ContractApproval> Approvals { get; set; } = new();
public List<ContractComment> Comments { get; set; } = new();
public List<ContractAttachment> Attachments { get; set; } = new();
// Plan B Chunk C (S29 — Mig 33, 2026-05-22) — Ý kiến cấp duyệt V2 dynamic
// cookie-cutter mirror PE Mig 26. UPSERT auto từ ApproveV2Async (Plan B
// Chunk D em main wire). Section 5 FE render dynamic theo flow.steps[].levels[].
// HĐ V1 (WorkflowDefinitionId) KHÔNG dùng.
public List<ContractLevelOpinion> LevelOpinions { get; set; } = new();
public List<ContractChangelog> Changelogs { get; set; } = new();
public List<ContractDepartmentApproval> DepartmentApprovals { get; set; } = new();

View File

@ -0,0 +1,36 @@
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Contracts;
// "Ý kiến cấp duyệt" V2 cho HĐ — sign-off DYNAMIC theo workflow ApprovalWorkflowV2
// (Mig 22-25 + Plan B Mig 32). Cookie-cutter mirror PE Mig 26
// `PurchaseEvaluationLevelOpinion` (S19 2026-05-09).
//
// Mỗi row = 1 (Contract × ApprovalWorkflowLevel). Service `ApproveV2Async` sau khi
// approve thành công Cấp hiện tại sẽ UPSERT row này (Plan B Chunk D em main wire):
// Comment = approval.Comment ?? "(duyệt — không ý kiến)"
// SignedAt = clock.UtcNow
// SignedByUserId = actor.Id (NV chính chủ HOẶC Admin override)
// SignedByFullName = actor.FullName (denorm — tránh user bị xóa/đổi tên)
//
// Reject (Trả lại / Từ chối) KHÔNG sync (vì không phải sign-off của level đó).
// Khi user resubmit từ TraLai → workflow chạy lại từ Cấp 1, opinion cũ bị
// OVERWRITE bằng UPSERT mới (latest-write-wins).
//
// Section 5 FE detect V2 qua `contract.approvalWorkflowId != null` → render dynamic
// theo flow.steps[].levels[]. HĐ V1 legacy (WorkflowDefinitionId set) → fallback
// không có ý kiến dynamic (giống PE V1 behavior).
public class ContractLevelOpinion : AuditableEntity
{
public Guid ContractId { get; set; }
public Guid ApprovalWorkflowLevelId { get; set; }
public string? Comment { get; set; } // ý kiến (max 2000) hoặc placeholder "(duyệt — không ý kiến)"
public DateTime SignedAt { get; set; } // luôn có khi UPSERT (Service set khi Approve)
public Guid SignedByUserId { get; set; } // người ký thực sự (có thể là Admin thay NV)
public string SignedByFullName { get; set; } = string.Empty; // snapshot tên — denorm
public Contract? Contract { get; set; }
public ApprovalWorkflowLevel? Level { get; set; }
}

View File

@ -37,6 +37,8 @@ public class ApplicationDbContext
public DbSet<ContractCodeSequence> ContractCodeSequences => Set<ContractCodeSequence>();
public DbSet<ContractChangelog> ContractChangelogs => Set<ContractChangelog>();
public DbSet<ContractDepartmentApproval> ContractDepartmentApprovals => Set<ContractDepartmentApproval>();
// Plan B Chunk C (S29 — Mig 33) — Ý kiến cấp duyệt V2 cho HĐ. Mirror PE Mig 26.
public DbSet<ContractLevelOpinion> ContractLevelOpinions => Set<ContractLevelOpinion>();
public DbSet<Notification> Notifications => Set<Notification>();
public DbSet<WorkflowTypeAssignment> WorkflowTypeAssignments => Set<WorkflowTypeAssignment>();
public DbSet<WorkflowDefinition> WorkflowDefinitions => Set<WorkflowDefinition>();

View File

@ -28,6 +28,15 @@ public class ContractConfiguration : IEntityTypeConfiguration<Contract>
b.HasIndex(x => x.ProjectId);
b.HasIndex(x => x.SlaDeadline);
b.HasIndex(x => x.BudgetId);
b.HasIndex(x => x.ApprovalWorkflowId);
// FK ApprovalWorkflowId Restrict (Plan B Chunk A2 — Mig 32 mirror PE Mig 23)
// ApprovalWorkflowsV2 pin lúc create HĐ V2. Restrict để KHÔNG xóa workflow
// nếu còn HĐ pin.
b.HasOne<SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow>()
.WithMany()
.HasForeignKey(x => x.ApprovalWorkflowId)
.OnDelete(DeleteBehavior.Restrict);
b.HasMany(x => x.Approvals).WithOne(a => a.Contract).HasForeignKey(a => a.ContractId).OnDelete(DeleteBehavior.Cascade);
b.HasMany(x => x.Comments).WithOne(c => c.Contract).HasForeignKey(c => c.ContractId).OnDelete(DeleteBehavior.Cascade);

View File

@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
// Plan B Chunk C (S29 — Mig 33, 2026-05-22) — cookie-cutter mirror
// PurchaseEvaluationLevelOpinionConfiguration (Mig 26 S19).
//
// UPSERT auto sync từ ContractWorkflowService.ApproveV2Async (Plan B Chunk D em
// main wire). UNIQUE (ContractId, ApprovalWorkflowLevelId) đảm bảo 1 row/level/HĐ.
// FK Cascade Contract (xoá HĐ → xoá opinions),
// FK Restrict Level (admin xoá Level chặn nếu opinion tồn tại — bảo vệ data).
// SignedByUserId KHÔNG nav (tránh cascade khi xoá user; denorm SignedByFullName).
public class ContractLevelOpinionConfiguration : IEntityTypeConfiguration<ContractLevelOpinion>
{
public void Configure(EntityTypeBuilder<ContractLevelOpinion> e)
{
e.ToTable("ContractLevelOpinions");
e.Property(x => x.Comment).HasMaxLength(2000);
e.Property(x => x.SignedByFullName).HasMaxLength(200).IsRequired();
e.HasOne(x => x.Contract)
.WithMany(c => c.LevelOpinions)
.HasForeignKey(x => x.ContractId)
.OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.Level)
.WithMany()
.HasForeignKey(x => x.ApprovalWorkflowLevelId)
.OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.ContractId, x.ApprovalWorkflowLevelId }).IsUnique();
e.HasIndex(x => x.ApprovalWorkflowLevelId);
}
}

View File

@ -68,6 +68,7 @@ public static class DbInitializer
// - SeedDemoContractsAsync ([DEMO] HĐ 7-type sample)
// - SeedDemoPurchaseEvaluationsAsync ([DEMO] PE 4 sample)
// - SeedSampleApprovalWorkflowsV2Async (V2 sample mẫu UAT cho type B)
// - SeedSampleContractWorkflowV2Async (V2 sample mẫu UAT cho Contract — Mig 32 Plan B Chunk A2)
// GIỮ: SeedRoles, SeedAdmin, SeedDepartments, SeedDemoUsers (30 user UAT),
// SeedMenuTree, SeedAdminPermissions, SeedDemoMasterData (Supplier/Project
// master), SeedContractTemplates (file template), SeedCatalogs, backfill
@ -76,7 +77,7 @@ public static class DbInitializer
var config = sp.GetRequiredService<IConfiguration>();
var demoSeedDisabled = config.GetValue<bool>("DemoSeed:Disabled");
if (demoSeedDisabled)
logger.LogInformation("DemoSeed:Disabled=true — skip workflow + contracts + PE + sample V2 seed (Plan T S23 t10)");
logger.LogInformation("DemoSeed:Disabled=true — skip workflow + contracts + PE + sample V2 seed (Plan T S23 t10 + Plan B Chunk A2 Contract V2)");
await SeedRolesAsync(roleManager, logger);
// Phase 6 rebrand: rename user email @solutionerp.local → @solutions.com.vn
@ -106,6 +107,7 @@ public static class DbInitializer
await SeedDemoContractsAsync(db, userManager, codeGen, logger);
await SeedDemoPurchaseEvaluationsAsync(db, userManager, logger);
await SeedSampleApprovalWorkflowsV2Async(db, userManager, logger);
await SeedSampleContractWorkflowV2Async(db, userManager, logger);
}
await WarnDefaultAdminPasswordAsync(userManager, logger);
@ -163,6 +165,58 @@ public static class DbInitializer
logger.LogInformation("Seeded sample ApprovalWorkflow V2 for DuyetNccPhuongAn: QT-DN-PA-V2-001 v01");
}
// [Plan B S29 2026-05-22 Chunk A2] Seed sample workflow V2 cho ApplicableType=Contract,
// giúp UAT HĐ V2 nhanh không cần admin tạo qua Designer trước. Idempotent — skip
// nếu đã có ANY workflow Contract (admin đã tạo) HOẶC nếu thiếu user CCM seed.
// Mirror SeedSampleApprovalWorkflowsV2Async pattern (DuyetNccPhuongAn).
private static async Task SeedSampleContractWorkflowV2Async(
ApplicationDbContext db, UserManager<User> userManager, ILogger logger)
{
var hasAnyContract = await db.ApprovalWorkflows
.AnyAsync(w => w.ApplicableType == ApprovalWorkflowApplicableType.Contract);
if (hasAnyContract) return;
var approver = await userManager.FindByEmailAsync("binh.le@solutions.com.vn");
if (approver is null)
{
logger.LogWarning("SeedSampleContractWorkflowV2Async: skip — approver binh.le@solutions.com.vn (Lê Văn Bình CCM) not found");
return;
}
var ccmDept = await db.Departments.FirstOrDefaultAsync(d => d.Code == "CCM");
var wf = new ApprovalWorkflow
{
Code = "QT-HD-V2-001",
Version = 1,
ApplicableType = ApprovalWorkflowApplicableType.Contract,
Name = "Quy trình duyệt HĐ mẫu UAT V2",
Description = "Sample seed cho UAT HĐ V2 — 1 Bước Phòng CCM × 1 Cấp (Lê Văn Bình). Admin có thể clone tạo version mới qua Designer.",
IsActive = true,
IsUserSelectable = true, // Mig 25 — user pick qua Workspace dropdown
ActivatedAt = DateTime.UtcNow,
};
var step = new ApprovalWorkflowStep
{
ApprovalWorkflow = wf,
Order = 1,
Name = "Bước 1 - Phòng CCM",
DepartmentId = ccmDept?.Id,
};
var level = new ApprovalWorkflowLevel
{
Step = step,
Order = 1,
Name = "Cấp 1",
ApproverUserId = approver.Id,
};
wf.Steps.Add(step);
step.Levels.Add(level);
db.ApprovalWorkflows.Add(wf);
await db.SaveChangesAsync();
logger.LogInformation("Seeded sample ApprovalWorkflow V2 for Contract: QT-HD-V2-001 v01");
}
// Seed 4 master catalogs với defaults cho user nhập liệu Details. Idempotent:
// skip per-table nếu đã có row (admin có thể đã thêm/sửa — không clobber).
private static async Task SeedCatalogsAsync(ApplicationDbContext db, ILogger logger)

View File

@ -0,0 +1,60 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddApprovalWorkflowToContract : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "ApprovalWorkflowId",
table: "Contracts",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "CurrentApprovalLevelOrder",
table: "Contracts",
type: "int",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Contracts_ApprovalWorkflowId",
table: "Contracts",
column: "ApprovalWorkflowId");
migrationBuilder.AddForeignKey(
name: "FK_Contracts_ApprovalWorkflows_ApprovalWorkflowId",
table: "Contracts",
column: "ApprovalWorkflowId",
principalTable: "ApprovalWorkflows",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Contracts_ApprovalWorkflows_ApprovalWorkflowId",
table: "Contracts");
migrationBuilder.DropIndex(
name: "IX_Contracts_ApprovalWorkflowId",
table: "Contracts");
migrationBuilder.DropColumn(
name: "ApprovalWorkflowId",
table: "Contracts");
migrationBuilder.DropColumn(
name: "CurrentApprovalLevelOrder",
table: "Contracts");
}
}
}

View File

@ -0,0 +1,69 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddContractLevelOpinions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ContractLevelOpinions",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ContractId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ApprovalWorkflowLevelId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Comment = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
SignedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
SignedByUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
SignedByFullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ContractLevelOpinions", x => x.Id);
table.ForeignKey(
name: "FK_ContractLevelOpinions_ApprovalWorkflowLevels_ApprovalWorkflowLevelId",
column: x => x.ApprovalWorkflowLevelId,
principalTable: "ApprovalWorkflowLevels",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ContractLevelOpinions_Contracts_ContractId",
column: x => x.ContractId,
principalTable: "Contracts",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ContractLevelOpinions_ApprovalWorkflowLevelId",
table: "ContractLevelOpinions",
column: "ApprovalWorkflowLevelId");
migrationBuilder.CreateIndex(
name: "IX_ContractLevelOpinions_ContractId_ApprovalWorkflowLevelId",
table: "ContractLevelOpinions",
columns: new[] { "ContractId", "ApprovalWorkflowLevelId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ContractLevelOpinions");
}
}
}

View File

@ -637,6 +637,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("ApprovalWorkflowId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("BudgetId")
.HasColumnType("uniqueidentifier");
@ -657,6 +660,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<int?>("CurrentApprovalLevelOrder")
.HasColumnType("int");
b.Property<int?>("CurrentWorkflowStepIndex")
.HasColumnType("int");
@ -732,6 +738,8 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.HasKey("Id");
b.HasIndex("ApprovalWorkflowId");
b.HasIndex("BudgetId");
b.HasIndex("MaHopDong")
@ -1036,6 +1044,64 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("ContractDepartmentApprovals", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractLevelOpinion", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("ApprovalWorkflowLevelId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Comment")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<Guid>("ContractId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime>("SignedAt")
.HasColumnType("datetime2");
b.Property<string>("SignedByFullName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<Guid>("SignedByUserId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ApprovalWorkflowLevelId");
b.HasIndex("ContractId", "ApprovalWorkflowLevelId")
.IsUnique();
b.ToTable("ContractLevelOpinions", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.DichVuDetail", b =>
{
b.Property<Guid>("Id")
@ -3479,6 +3545,14 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("Budget");
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.Contract", b =>
{
b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow", null)
.WithMany()
.HasForeignKey("ApprovalWorkflowId")
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractApproval", b =>
{
b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract")
@ -3534,6 +3608,25 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("Contract");
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractLevelOpinion", b =>
{
b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", "Level")
.WithMany()
.HasForeignKey("ApprovalWorkflowLevelId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract")
.WithMany("LevelOpinions")
.HasForeignKey("ContractId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Contract");
b.Navigation("Level");
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.DichVuDetail", b =>
{
b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract")
@ -3866,6 +3959,8 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("GiaoKhoanDetails");
b.Navigation("LevelOpinions");
b.Navigation("MuaBanDetails");
b.Navigation("NguyenTacDvDetails");

View File

@ -38,6 +38,7 @@ public class ContractWorkflowService(
IReadOnlyList<string> actorRoles,
ApprovalDecision decision,
string? comment,
bool skipToFinal = false,
CancellationToken ct = default)
{
var fromPhase = contract.Phase;
@ -78,6 +79,9 @@ public class ContractWorkflowService(
}
contract.Phase = ContractPhase.ChoDuyet;
contract.CurrentWorkflowStepIndex = 0;
// [Plan B S29 2026-05-22] V2 pointer init — mirror PE line 153.
// Chỉ init levelOrder=1 nếu pin schema V2 (ApprovalWorkflowId set).
contract.CurrentApprovalLevelOrder = contract.ApprovalWorkflowId is not null ? 1 : null;
contract.SlaDeadline = dateTime.UtcNow.AddDays(7);
await LogTransitionAsync(contract, fromPhase, ContractPhase.ChoDuyet, actorUserId, decision, comment, ct);
await db.SaveChangesAsync(ct);
@ -87,6 +91,20 @@ public class ContractWorkflowService(
// ===== APPROVE STEP =====
if (fromPhase == ContractPhase.ChoDuyet && decision == ApprovalDecision.Approve)
{
// [Plan B S29 2026-05-22] Branch V2 schema mới (ApprovalWorkflowId pin)
// vs V1 legacy (WorkflowDefinitionId pin Mig 21). Mirror PE
// PurchaseEvaluationWorkflowService.cs line 161-180 pattern.
// V1 legacy giữ behavior cũ — 7 prod contract chạy nhánh này.
if (contract.ApprovalWorkflowId is Guid awId)
{
await ApproveV2Async(contract, awId, actorUserId, actorRoles, isAdmin, isSystem, comment, skipToFinal, ct);
await db.SaveChangesAsync(ct);
return;
}
if (skipToFinal && !isAdmin && !isSystem)
throw new ConflictException(
"skipToFinal chỉ hỗ trợ HĐ V2 (ApprovalWorkflowsV2). HĐ V1 legacy không có per-Approver-slot flag.");
var def = contract.WorkflowDefinitionId is Guid wfId
? await db.WorkflowDefinitions.AsNoTracking()
.Include(d => d.Steps.OrderBy(s => s.Order))
@ -183,6 +201,198 @@ public class ContractWorkflowService(
throw new ConflictException($"Transition {fromPhase} → {targetPhase} không hỗ trợ.");
}
// ===== V2 APPROVE (Mig 32+33 — Plan B S29 2026-05-22) =====
// Mirror PurchaseEvaluationWorkflowService.cs:ApproveV2Async (line 446-634).
// Khác PE: terminal hoàn tất → gen mã HĐ + Phase=DaPhatHanh (PE chỉ
// Phase=DaDuyet, không gen mã). V1 legacy giữ behavior cũ.
//
// skipToFinal F2 (Mig 31 Plan K S23): Approver tick "Duyệt thẳng Cấp cuối"
// + admin opt-in per slot tại matchingLevel.AllowApproverSkipToFinal → bỏ
// qua mọi Bước/Cấp trung gian, advance pointer tới Bước cuối + Cấp cuối.
// Phase giữ ChoDuyet — NV cuối vẫn duyệt thật để tiến DaPhatHanh.
//
// TODO Chunk C: UPSERT ContractLevelOpinion (table chưa tồn tại — Mig 33
// sẽ scaffold + entity + EF config). Sau Chunk C done, em main add block
// UPSERT mirror PE line 512-546.
private async Task ApproveV2Async(
Contract contract,
Guid awId,
Guid? actorUserId,
IReadOnlyList<string> actorRoles,
bool isAdmin,
bool isSystem,
string? comment,
bool skipToFinal,
CancellationToken ct)
{
var aw = await db.ApprovalWorkflows.AsNoTracking()
.Include(w => w.Steps.OrderBy(s => s.Order))
.ThenInclude(s => s.Levels.OrderBy(l => l.Order))
.FirstOrDefaultAsync(w => w.Id == awId, ct)
?? throw new ConflictException($"ApprovalWorkflow {awId} không tồn tại.");
var steps = aw.Steps.OrderBy(s => s.Order).ToList();
if (steps.Count == 0)
throw new ConflictException("Quy trình chưa có bước nào.");
var currentIdx = contract.CurrentWorkflowStepIndex ?? 0;
if (currentIdx < 0 || currentIdx >= steps.Count)
throw new ConflictException($"CurrentWorkflowStepIndex={currentIdx} không hợp lệ (max={steps.Count - 1}).");
var currentLevelOrder = contract.CurrentApprovalLevelOrder ?? 1;
var currentStep = steps[currentIdx];
// Group levels by Order = Cấp. Mỗi Cấp có N approvers (OR-of-N).
var levelGroups = currentStep.Levels.OrderBy(l => l.Order).GroupBy(l => l.Order).ToList();
var maxLevelOrder = levelGroups.Count == 0 ? 0 : levelGroups.Max(g => g.Key);
if (currentLevelOrder < 1 || currentLevelOrder > maxLevelOrder)
throw new ConflictException($"CurrentApprovalLevelOrder={currentLevelOrder} không hợp lệ (max={maxLevelOrder}).");
var pendingLevelGroup = levelGroups.FirstOrDefault(g => g.Key == currentLevelOrder)
?? throw new ConflictException($"Bước {currentIdx + 1} không có cấp {currentLevelOrder}.");
// Match approver: actor.Id ∈ pendingLevelGroup.ApproverUserId. Admin bypass.
if (!isAdmin && !isSystem)
{
if (actorUserId is null)
throw new ForbiddenException("Không xác định được approver.");
var allowedUserIds = pendingLevelGroup.Select(l => l.ApproverUserId).ToHashSet();
if (!allowedUserIds.Contains(actorUserId.Value))
{
var names = string.Join(", ", allowedUserIds);
throw new ForbiddenException(
$"Bước {currentIdx + 1} ({currentStep.Name}) — Cấp {currentLevelOrder}: bạn không có trong danh sách NV duyệt ({names}).");
}
}
// Log approval. Enrich comment với prefix "[Duyệt vượt cấp]" khi
// skipToFinal=true (mirror PE Plan AC S25 Bug 3b).
var skipPrefix = skipToFinal ? "[Duyệt vượt cấp tới Cấp cuối] " : "";
db.ContractApprovals.Add(new ContractApproval
{
ContractId = contract.Id,
FromPhase = contract.Phase,
ToPhase = contract.Phase,
ApproverUserId = actorUserId,
Decision = ApprovalDecision.Approve,
Comment = $"{skipPrefix}[Bước {currentIdx + 1} — Cấp {currentLevelOrder}] {comment ?? ""}".Trim(),
ApprovedAt = dateTime.UtcNow,
});
// [Plan B Chunk B2 S29 2026-05-22] UPSERT ContractLevelOpinion vào row
// Level chính chủ (mirror PE Mig 26 line 512-546). Section 5 FE render
// dynamic theo flow.steps[].levels[]. Comment khi duyệt auto sync sang
// Section 5 (read-only summary). Empty comment → "(duyệt — không ý kiến)"
// placeholder. Multi-NV cùng Cấp (OR-of-N): match level theo
// ApproverUserId. Admin override → fallback first level group; FE detect
// SignedByUserId !== Level.ApproverUserId → banner "Admin duyệt thay".
var matchingLevel = pendingLevelGroup.FirstOrDefault(l => actorUserId.HasValue && l.ApproverUserId == actorUserId.Value)
?? pendingLevelGroup.First();
var actorFullName = await ResolveActorFullNameAsync(actorUserId, isSystem, ct);
var existingOpinion = await db.ContractLevelOpinions
.FirstOrDefaultAsync(o => o.ContractId == contract.Id
&& o.ApprovalWorkflowLevelId == matchingLevel.Id, ct);
var normalizedComment = string.IsNullOrWhiteSpace(comment)
? "(duyệt — không ý kiến)"
: comment.Trim();
if (existingOpinion is null)
{
db.ContractLevelOpinions.Add(new ContractLevelOpinion
{
ContractId = contract.Id,
ApprovalWorkflowLevelId = matchingLevel.Id,
Comment = normalizedComment,
SignedAt = dateTime.UtcNow,
SignedByUserId = actorUserId ?? Guid.Empty,
SignedByFullName = actorFullName,
});
}
else
{
existingOpinion.Comment = normalizedComment;
existingOpinion.SignedAt = dateTime.UtcNow;
existingOpinion.SignedByUserId = actorUserId ?? Guid.Empty;
existingOpinion.SignedByFullName = actorFullName;
}
// skipToFinal F2 (Mig 31 Plan K S23) — Approver scope ChoDuyet skip
// thẳng Cấp cuối. Admin opt-in per slot tại AllowApproverSkipToFinal.
if (skipToFinal)
{
if (!isAdmin && !isSystem && !matchingLevel.AllowApproverSkipToFinal)
{
throw new ConflictException(
$"Cấp Approver hiện tại (Bước {currentIdx + 1} Cấp {currentLevelOrder}) " +
"chưa được phép duyệt thẳng Cấp cuối. Admin phải tick checkbox " +
"'Duyệt thẳng Cấp cuối' trong Workflow Designer cho slot này.");
}
var lastStepIdx = steps.Count - 1;
var lastStep = steps[lastStepIdx];
var lastLevelGroups = lastStep.Levels.OrderBy(l => l.Order).GroupBy(l => l.Order).ToList();
var lastLevelMaxOrder = lastLevelGroups.Count == 0 ? 1 : lastLevelGroups.Max(g => g.Key);
// Guard: actor đã ở Cấp cuối Bước cuối → fall through normal advance
// (sẽ hit branch nextIdx >= steps.Count → DaPhatHanh đúng).
if (!(currentIdx == lastStepIdx && currentLevelOrder == lastLevelMaxOrder))
{
contract.CurrentWorkflowStepIndex = lastStepIdx;
contract.CurrentApprovalLevelOrder = lastLevelMaxOrder;
contract.SlaDeadline = dateTime.UtcNow.AddDays(7);
await LogTransitionAsync(
contract,
ContractPhase.ChoDuyet,
ContractPhase.ChoDuyet,
actorUserId,
ApprovalDecision.Approve,
$"[Approver skip thẳng tới Bước {lastStepIdx + 1} Cấp {lastLevelMaxOrder} (NV cuối) — bỏ qua các Bước/Cấp trung gian] {comment ?? ""}".Trim(),
ct);
return;
}
}
// Advance: nếu còn cấp tiếp trong Step → levelOrder++; else → next Step + level 1
if (currentLevelOrder < maxLevelOrder)
{
contract.CurrentApprovalLevelOrder = currentLevelOrder + 1;
contract.SlaDeadline = dateTime.UtcNow.AddDays(7);
await LogTransitionAsync(contract, contract.Phase, contract.Phase, actorUserId, ApprovalDecision.Approve,
$"Hoàn tất Cấp {currentLevelOrder}, sang Cấp {currentLevelOrder + 1} cùng Bước {currentIdx + 1}", ct);
return;
}
// Hết cấp trong Step — sang Step kế (Cấp 1)
var nextIdx = currentIdx + 1;
if (nextIdx >= steps.Count)
{
// All Steps done — terminal DaPhatHanh. Khác PE: phải gen mã HĐ
// theo RG-001 (mirror V1 line 148-155). FE sau khi nhận DaPhatHanh
// sẽ refresh hiển thị MaHopDong.
if (string.IsNullOrEmpty(contract.MaHopDong))
{
var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == contract.SupplierId, ct)
?? throw new NotFoundException("Supplier", contract.SupplierId);
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == contract.ProjectId, ct)
?? throw new NotFoundException("Project", contract.ProjectId);
contract.MaHopDong = await codeGenerator.GenerateAsync(contract, project.Code, supplier.Code, ct);
}
contract.Phase = ContractPhase.DaPhatHanh;
contract.CurrentWorkflowStepIndex = null;
contract.CurrentApprovalLevelOrder = null;
contract.SlaDeadline = null;
await LogTransitionAsync(contract, ContractPhase.ChoDuyet, ContractPhase.DaPhatHanh,
actorUserId, ApprovalDecision.Approve, comment, ct);
}
else
{
contract.CurrentWorkflowStepIndex = nextIdx;
contract.CurrentApprovalLevelOrder = 1;
contract.SlaDeadline = dateTime.UtcNow.AddDays(7);
await LogTransitionAsync(contract, contract.Phase, contract.Phase, actorUserId, ApprovalDecision.Approve,
$"Hoàn tất Bước {currentIdx + 1}/{steps.Count}, sang Bước {nextIdx + 1} (Cấp 1)", ct);
}
}
private async Task LogTransitionAsync(
Contract contract,
ContractPhase fromPhase,
@ -216,4 +426,18 @@ public class ContractWorkflowService(
ct: ct);
}
}
// [Plan B Chunk B2 S29 2026-05-22] Resolve actor full name for
// ContractLevelOpinion.SignedByFullName denormalized field (Section 5 FE
// display). Mirror PE PurchaseEvaluationWorkflowService.cs:774-783.
private async Task<string> ResolveActorFullNameAsync(Guid? actorUserId, bool isSystem, CancellationToken ct)
{
if (isSystem || actorUserId is null) return "(System)";
var user = await db.Users.AsNoTracking()
.Where(u => u.Id == actorUserId.Value)
.Select(u => new { u.FullName, u.UserName })
.FirstOrDefaultAsync(ct);
if (user is null) return "(unknown)";
return !string.IsNullOrWhiteSpace(user.FullName) ? user.FullName : (user.UserName ?? "(unknown)");
}
}