Compare commits

...

6 Commits

Author SHA1 Message Date
a532ba6fc3 [CLAUDE] Infra: Chunk D — PE 2-stage dept approval (đóng bug anh Kiệt báo)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m22s
Ràng buộc 3 (Phase 9) scope tối giản: chỉ PE workflow trước. Đóng bug
"NV duyệt được hết phase" anh Kiệt báo trong chat FDC.

Logic 2-stage trong PurchaseEvaluationWorkflowService.TransitionAsync,
chèn sau policy guard, trước phase transition:

1. Detect approving phase với role thuộc phòng ban:
   - decision == Approve
   - target != DangSoanThao && != TuChoi
   - Không reject + không resume + không admin/system
   - actorUserId != null + actor.DepartmentId != null

2. Stage detection:
   - DeptManager (TPB) → Stage=Confirm trực tiếp (TPB tự confirm được)
   - User.CanBypassReview=true → Stage=Confirm + IsBypassed=true (NV bypass)
   - Else (NV thường) → Stage=Review only

3. Upsert PurchaseEvaluationDepartmentApproval row:
   - UNIQUE (PEId, PhaseAtApproval, DepartmentId, Stage) đảm bảo 1 row
   - UPDATE in-place khi user click Duyệt lần 2 (đổi comment)
   - ApproverRoleSnapshot: "TPB" / "NV(bypass)" / "NV" denorm cho audit

4. Check Stage=Confirm tồn tại cho (PEId, fromPhase, deptId):
   - hasConfirm = vừa insert Stage=Confirm OR đã có sẵn
   - !hasConfirm → BLOCK phase transition:
     * Insert PEApproval row (FromPhase=ToPhase=fromPhase, Decision=Approve,
       Comment="[Review NV] ...") để track audit
     * Insert Changelog "NV X đã review phase Y, chờ TPB confirm"
     * Return early — Phase KHÔNG đổi
   - hasConfirm → tiếp tục normal phase transition logic

5. Skip 2-stage hoàn toàn khi:
   - Decision=Reject (smart reject Chunk C đã handle)
   - Resume after reject (target đã pinned)
   - Admin role hoặc System (auto-approve)
   - actorUserId == null hoặc actor.DepartmentId == null

Bug fix verified theo flow anh Kiệt:
- User long.chau (NV.PRO, role=Procurement, DepartmentId=PRO) tạo phiếu
- long.chau click Duyệt phase ChoPurchasing → ChoCCM:
  - actor.DepartmentId=PRO → 2-stage logic active
  - role=Procurement, không có DeptManager → Stage=Review
  - hasConfirm=false → BLOCK transition
  - Insert PEDeptApproval(PE, ChoPurchasing, PRO, Review)
  - Phase giữ nguyên ChoPurchasing
- TPB.PRO (tra.bui có role DeptManager + DeptId=PRO) click Duyệt:
  - role=DeptManager → Stage=Confirm
  - hasConfirm=true (vừa insert) → ALLOW transition
  - Phase chuyển ChoPurchasing → ChoCCM
- NV CCM lặp pattern tương tự ở phase ChoCCM
- Cuối cùng CEO/AuthSigner duyệt ChoCEODuyetNCC → DaDuyet (CEO không thuộc
  dept cụ thể nên bypass 2-stage)

Pending Chunk E:
- TODO notify TPB cùng dept khi NV review (best effort, chưa implement)
- List endpoint GET /api/purchase-evaluations/{id}/department-approvals
  cho FE hiển thị progress 2-stage
- UserManager API PATCH /api/users/{id}/bypass-review
- FE Workflow Panel update + UserManager toggle

HĐ + Budget 2-stage scope sẽ làm sau khi PE verify UAT (per default chốt
trước đó).

Verify:
- Build pass (2 warning DocxRenderer cũ)
- 77 unit test pass — Domain policy chưa đụng

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 12:26:18 +07:00
9747f8cbf5 [CLAUDE] Infra+App: Chunk C — Smart reject + Resume after reject (3 module)
Ràng buộc 2 (Phase 9): khi reject, trả về Drafter (DangSoanThao) + lưu phase
nguồn. Drafter sửa lại + trình lại → quay về phase đã reject (skip phase
trung gian).

Logic flow:

1. Reject (Decision=Reject):
   - entity.RejectedFromPhase = currentPhase  // snapshot phase đang reject
   - targetPhase override = DangSoanThao       // force về Drafter
   - Approval row: FromPhase=X, ToPhase=DangSoanThao, Decision=Reject
   - Notification cho Drafter

2. Resume after reject (Decision=Approve, fromPhase=DangSoanThao,
   RejectedFromPhase != null):
   - targetPhase override = entity.RejectedFromPhase!.Value
   - entity.RejectedFromPhase = null  // clear field
   - Skip policy guard (Drafter có quyền trình lại sau khi sửa)
   - Approval row: FromPhase=DangSoanThao, ToPhase=ResumePhase, Decision=Approve

3. Normal transition (chưa reject hoặc đã clear):
   - Logic cũ giữ nguyên — policy guard check + transition

Pattern unified cho 3 module:
- ContractWorkflowService.TransitionAsync: 2 case detect + override
- PurchaseEvaluationWorkflowService.TransitionAsync: tương tự
- TransitionBudgetCommandHandler.Handle: tương tự (Budget không có service riêng,
  logic ở handler)

Files:
- src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs
- src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs
- src/Backend/SolutionErp.Application/Budgets/BudgetFeatures.cs

Verify:
- Build pass (2 warning DocxRenderer cũ, không liên quan)
- 77 unit test pass — Domain policy không đổi, tests giữ nguyên

Note: Approval history giờ track đầy đủ cycle reject→sửa→resume:
  Approval 1: DangGopY → DangSoanThao, Decision=Reject (CCM reject)
  Approval 2: DangSoanThao → DangGopY, Decision=Approve (Drafter resume)

UI có thể detect "đã từng reject" qua RejectedFromPhase != null hoặc
qua Approval history (Decision=Reject row gần nhất). Hiển thị banner
đỏ "Phiếu đã bị reject từ phase X, lý do: Y" cho Drafter.

Smart reject hoàn tất Ràng buộc 2. Còn Ràng buộc 3 (2-stage dept approval)
ở Chunk D.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 12:20:21 +07:00
14f3c9f817 [CLAUDE] App: Chunk B — Lock edit guards (Phase != DangSoanThao) cho 17 handler
Ràng buộc 1 (Phase 9): khi đã trình duyệt → KHÔNG sửa được Header + Detail
+ Quote nữa. Phải reject để Drafter sửa lại.

Pattern dùng: extend helper EnsureContractType + tạo helper PurchaseEvaluationDraftGuard
mới cho PE + inline guard cho Budget. Single source of truth cho mỗi module.

Handlers added Phase guard (17 total):

Contract module (15) — qua EnsureContractType helper:
- 7 Add*DetailHandler (ThauPhu/GiaoKhoan/NhaCungCap/DichVu/MuaBan/NguyenTacNcc/NguyenTacDv)
- 7 Update*DetailHandler (cùng 7 type)
- DeleteContractDetailHandler (inline guard)

PE module (5) — qua PurchaseEvaluationDraftGuard helper mới:
- AddPurchaseEvaluationDetail
- UpdatePurchaseEvaluationDetail
- DeletePurchaseEvaluationDetail
- UpsertPurchaseEvaluationQuote
- DeletePurchaseEvaluationQuote

Budget module (3) — inline guard:
- AddBudgetDetail
- UpdateBudgetDetail (refactor: load Budget thay vì FirstOrDefault sau Detail
  load → bỏ null check không cần)
- DeleteBudgetDetail (refactor: tương tự)

KHÔNG lock (intentional):
- ContractComment (cần được trong DangGopY phase 3)
- ContractAttachment Upload/Delete (Drafter scan ký ở DangInKy phase 5)
- PE OpinionUpsert (Ý kiến 4 PB là sign-off, có thể nhập sau khi trình)
- PE Attachment (báo giá NCC upload xuyên suốt workflow)

Verify:
- Build pass (2 warning DocxRenderer cũ)
- 77 unit test pass (54 Domain + 23 Infra) — domain policy không đổi

Smart reject (ràng buộc 2) + 2-stage dept approval (ràng buộc 3) làm ở
Chunk C + D. WorkflowService transition guard chưa update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 12:15:07 +07:00
5c200978cb [CLAUDE] Domain+Infra: Migration 16 — 2-stage dept approval + smart reject schema
Chunk A của feature 2-stage department approval (Phase 9). 3 ràng buộc gộp
1 migration để rollback atomic.

Schema changes:

1. Smart reject (3 ALTER):
   - Contracts.RejectedFromPhase int NULL
   - PurchaseEvaluations.RejectedFromPhase int NULL
   - Budgets.RejectedFromPhase int NULL
   Lưu phase nguồn khi reject để Drafter trình lại quay về đúng phase
   (skip phase trung gian đã duyệt) thay vì đi tuần tự từ DangSoanThao.

2. Bypass per-user (1 ALTER):
   - Users.CanBypassReview bit NOT NULL DEFAULT 0
   Khi true → NV được duyệt thay TPB (skip Stage Review, đẩy thẳng
   Stage Confirm). Audit qua DepartmentApproval.IsBypassed=true.

3. 2-stage dept approval (3 CREATE TABLE):
   - ContractDepartmentApprovals
   - PurchaseEvaluationDepartmentApprovals
   - BudgetDepartmentApprovals

   Schema (chung):
   - Id, *Id FK Cascade, PhaseAtApproval int, DepartmentId FK
   - Stage enum (1=Review NV, 2=Confirm TPB)
   - ApproverUserId, ApproverRoleSnapshot, Comment, ApprovedAt
   - IsBypassed bit (mark NV bypass)
   - AuditableEntity (CreatedAt/By/UpdatedAt/By/IsDeleted/...)

   Indexes:
   - UNIQUE (TargetId, PhaseAtApproval, DepartmentId, Stage)
   - Single: TargetId, DepartmentId, ApproverUserId

Files:
- Domain: 4 entity update (Contract/PE/Budget/User add field) + 1 enum mới
  ApprovalStage + 3 entity DepartmentApproval mới
- Infrastructure: 3 EntityConfiguration update + 1 file mới
  DepartmentApprovalsConfiguration với 3 config classes
- IApplicationDbContext: thêm 3 DbSet
- ApplicationDbContext: thêm 3 DbSet
- Migration 16: 3 file (.cs + Designer.cs + Snapshot.cs) — 3-file rule

Verify:
- Build pass (2 warning DocxRenderer cũ, không liên quan)
- 77 unit test pass (54 Domain + 23 Infra) — Domain policy chưa update,
  test pass nguyên không regression

Note: KHÔNG khác PurchaseEvaluationDepartmentOpinion (Migration 15) —
Opinion là sign-off block "Ý kiến 4 phòng ban" trên header phiếu.
DepartmentApproval mới là 2-stage approval workflow per phase.

Tổng sau Migration 16: 55 bảng (52+3), 16 migration. Chunk B-E sẽ implement
Application + FE + Tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 12:11:53 +07:00
dfb43fcbc6 [CLAUDE] Docs: chốt template session prompts (start + end) — iterate qua 5 AI
User dùng 2 prompt fixed cho mọi session SOLUTION_ERP:
- MỞ ĐẦU: LOAD context (5 mục — đọc MD core, list skill, verify test,
  check audit cadence, sub-rule consolidate)
- KẾT THÚC: UPDATE MD + commit (5 mục — update MD đã đổi, note skill,
  verify test count tăng, add memory entry nếu cần, sub-rule consolidate)

Iterate workflow:
- v1: đề xuất ban đầu (typo + 4 mục giống nhau Mở đầu vs Kết thúc)
- v2: tách action verb LOAD vs WRITE
- v3-v4: refine wording, fix typo, bỏ câu thừa
- v5 final: fix "thei"→"theo", "end từ"→"kết thúc lúc", "vào vào"→"vào"

Bài học: prompt template iterate cross-AI (5 AI cùng review) ra version
robust hơn 1 AI duy nhất. Template lưu 3 chỗ:
1. docs/_templates/session-prompts.md (canonical project)
2. memory/reference_session_prompts.md (cross-session pointer)
3. MEMORY.md index (session sau auto-load nhận diện trigger)

File: docs/_templates/session-prompts.md (NEW, docs-only → CI skip)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 03:04:12 +07:00
2675a3a674 [CLAUDE] Docs: rule §6.5 consolidate KEEP vs CUT + restore narrative migration-todos
Bài học session 6: compact -288 dòng nhanh nhưng paraphrase + collapse mất
narrative tích lũy qua sessions. User feedback: "viết MD gọn lại tý là mất
mẹ luôn tính cách cũ". Docs đọc 6 tháng sau như machine output.

Changes:
1. docs/rules.md §6.5 mới — Consolidate MD đúng cách (KEEP vs CUT):
   - KEEP cấm cắt: narrative, rationale, gotcha context, anecdote, "decision why"
   - CUT được: duplicate cross-ref, list>30 row archive, phase>1 tháng collapse
   - CẤM: paraphrase, summary đoạn có narrative, "đẹp hóa" bằng cắt
   - Decision tree + Validation 3 câu sau consolidate

2. docs/changelog/migration-todos.md restore Phase 6-7 nguyên văn từ b874743:
   - Phase 6 iter 1 (10 task chi tiết: Migration 12, Domain 2 enum, Application
     CQRS ~900 LOC, PurchaseEvaluationWorkflowService, Controller 17 endpoint,
     FE 2 app, Kế thừa HĐ guard, Migration 13 atomic seq, Demo PE seed)
   - Phase 6 iter 2 (8 task UX polish: rename Phương Án→Giải pháp, menu
     inheritance #35, accordion mutex, queryMatches #34, flat layout, per-NCC
     attachment, readOnly mode, email rebrand #38)
   - Domain rebrand 4 task chi tiết (gotcha #30 ASCII-only, 18 file repo,
     CI/CD auto rebuild, "Old fallback chưa remove" rationale)
   - Phase 7 PE feature gap A/B/C/D/E section đầy đủ:
     A. 3 task PE feature gap với file path + Option A/B reasoning
     B. 4 optional polish carry over Phase 9
     C. 6 ops task carry over Phase 9 hard blockers
     D. Budget BE 7 task chi tiết (Migration 14, ~340 LOC, 11 endpoint, 14 demo)
     E. 4 pending migration với rationale "khi nào cần"
   - Tick [x] task đã DONE S5 (PE WF Designer + Ý kiến 4 PB) + giữ [ ] chưa làm

Net change migration-todos: 136 → 217 (+81 dòng narrative)

Files: docs/rules.md + docs/changelog/migration-todos.md (docs-only → CI skip)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 01:56:29 +07:00
25 changed files with 4714 additions and 43 deletions

84
docs/_templates/session-prompts.md vendored Normal file
View File

@ -0,0 +1,84 @@
# Session Prompts Template — SOLUTION_ERP
> Template chuẩn cho prompt **mở đầu** và **kết thúc** session. Dùng cross-AI (Claude/Cursor/Copilot/Gemini/...) để workflow nhất quán. Đã iterate qua 5 lần với 5 AI khác nhau, chốt 2026-04-30.
## Cách dùng
1. Copy đoạn template phù hợp (Mở đầu / Kết thúc).
2. Replace `{timestamp - khi nhận prompt này}``{timestamp - khi nhận bắt đầu session}` bằng giờ thật.
3. Paste vào AI assistant (Claude Code / Cursor / Copilot / Gemini Code Assist / etc.).
## Template — MỞ ĐẦU SESSION
```
Đọc lại để nắm thông tin toàn bộ MD quá khứ, nắm context, để tiếp tục
công việc của Session mới, start từ {timestamp - khi nhận prompt này},
bao gồm:
Thứ 1: Bao gồm các MD liên quan đến rules, architecture, gotcha, skill,
daily, hand-off, DB, luồng DB, và nhiều MD liên quan khác ...
Thứ 2: Liệt kê các skill hiện có. Dùng skill khi task khớp (KHÔNG tự
suy luận lại). Skill staleness audit chỉ chạy theo lịch định kỳ.
Thứ 3: Đồng thời kiểm tra lại unit test cho tính năng mới thêm vào và
bug mới fix.
Thứ 4: Check trạng thái audit định kỳ (đã audit chưa? Kết quả ra sao).
KHÔNG tự chạy audit ngoài khi chưa đến định kỳ audit, trừ khi user yêu
cầu hoặc phát hiện drift nghiêm trọng.
Thứ 5 - Chú ý quan trọng khi Audit MD:
- Thứ 1: Rất quan trọng, đọc kỹ lại quy tắc consolidate đúng cách,
những thứ quan trọng là ko đc cắt, chỉ phân tầng cho gọn lại, và
xóa double. Phân tầng rõ ràng để các session sau đọc lại đúng
chính xác context, không bị over context, rất quan trọng đấy.
- Thứ 2: Nếu MD không có gì cần điều chỉnh thì không cần phải cố
gắng điều chỉnh, điều này cũng rất quan trọng.
```
## Template — KẾT THÚC SESSION
```
Chốt lại toàn bộ thông tin MD của Session đang làm, để cập nhật điều
chỉnh vào MD tổng, start từ {timestamp - khi nhận bắt đầu session} và
kết thúc lúc {timestamp - khi nhận prompt này}, bao gồm:
Thứ 1: UPDATE các MD đã thay đổi: rules, architecture, gotcha, skill,
daily, hand-off, DB, luồng DB, session log và nhiều MD liên quan khác ...
Thứ 2: Note skill mới/refresh vào các thư mục skill, bảng skill list +
đồng thời cập nhật lại MD tương ứng và count.
Thứ 3: Run verify unittest count tăng đúng (ví dụ 77 → 78 nếu thêm 1
test). Update STATUS Recently Done với count mới.
Thứ 4: Add memory entry mới nếu phát hiện rule/gotcha/decision quan
trọng chưa có trong memory hiện tại (KHÔNG rewrite toàn bộ memory).
Thứ 5 - Chú ý quan trọng khi update Memory và Skill và MD:
- Thứ 1: Rất quan trọng, đọc kỹ lại quy tắc consolidate đúng đắn,
những thứ quan trọng là ko đc cắt, chỉ phân tầng cho gọn lại, và
xóa double, phân tầng rõ ràng để các session sau đọc lại đúng
chính xác context, không bị over context, rất quan trọng đấy.
- Thứ 2: Nếu MD không có gì cần điều chỉnh thì không cần phải cố
gắng điều chỉnh, điều này cũng rất quan trọng.
```
## Cross-reference rules
- Rule consolidate đúng cách: [`docs/rules.md §6.5`](../rules.md#65-consolidate-md-đúng-cách--keep-vs-cut)
- Rule audit + compact định kỳ: [`docs/rules.md §6.4`](../rules.md#64-audit--compact-md-định-kỳ)
- Rule timing test: [`docs/rules.md §7`](../rules.md#7-testing-phase-8-active--77-test-pass--ci-gate-live)
- Rule skill governance: [`docs/rules.md §9`](../rules.md#9-skills-claudeskills)
- Rule commit format: [`docs/rules.md §5.2`](../rules.md#52-commit-format)
## Lịch sử iterate (4 vòng review)
1. **v1** — đề xuất ban đầu user (30/04 1h28). 4 mục giống nhau cho cả Mở đầu + Kết thúc, có typo (architech, đúng đắng, dễ truy vẫn, phân lớp tầng), Mở đầu lẫn audit/sửa.
2. **v2** — review #1: tách action verb (Mở đầu = LOAD, Kết thúc = WRITE), thêm sub-rule consolidate.
3. **v3** — review #2: refine wording, thêm "(NOT rewrite all memory)" mix Anh-Việt.
4. **v4** — review #3: chốt typo "phân tầng" thay "phân lớp tầng", thêm "(KHÔNG rewrite toàn bộ memory)", bỏ câu thừa Mục 4 Mở đầu + Mục 2 Kết thúc.
5. **v5 final** — review #4: fix "thei" → "theo", "end từ" → "kết thúc lúc", "vào vào" → "vào".
Bài học rút ra: iterate prompt template **cross-AI** (5 AI cùng review) cho ra phiên bản robust hơn 1 AI duy nhất.

View File

@ -17,22 +17,95 @@
Detail chi tiết: `docs/changelog/sessions/2026-04-21-*.md` + `2026-04-22-0300-tier3-feature-complete.md` + `2026-04-23-*.md`.
## ✅ Phase 6-7 — PE Module + Budget BE — Done (2026-04-23..28)
## ✅ Phase 6 Module Duyệt NCC (tiền-HĐ) — Done
- **Phase 6 iter 1+2**: Module Duyệt NCC tiền-HĐ — Migration 12+13, 10 bảng PE + WorkflowDefinitions, 2 type A/B, 17 endpoint, FE 3-panel cả 2 app, kế thừa HĐ qua `CreateContractFromEvaluationCommand`. UX polish iter 2 (flat layout, per-NCC attachments, readOnly mode Duyệt). Demo seed 4 phiếu varied + 7 role × 9 Pe_* permission defaults.
- **Domain rebrand**: 3 sub `.huypham.vn``.solutions.com.vn` E2E live HTTPS (cert + CORS + FE bundle).
- **Phase 7 — Budget BE**: Migration 14, 4 bảng Budgets + workflow simple 3-step hardcoded `BudgetPolicy.Default`, 11 endpoint, link nullable Contract.BudgetId/PE.BudgetId. **FE chưa làm** (Phase 8 done).
- **Phase 7 — 14 demo Solutions users**: PRO 5 + CCM 7 + ISO 1 + CEO 1, total 30 user (16 sample + 14 thật).
### Iter 1 (2026-04-23)
Session logs: `2026-04-23-2300-purchase-evaluations.md` · `2026-04-24-chot-session-3-pe-polish.md` · `2026-04-28-chot-session-4-budget.md`.
- [x] Migration 12 `AddPurchaseEvaluations` — 10 bảng (Header/Suppliers/Details/Quotes/Approvals/Changelogs/Attachments/WorkflowDefinitions/Steps/StepApprovers)
- [x] Domain — 2 enum (Type A/B, Phase 7-state) + Policy record + Registry + FromDefinition builder
- [x] Seed — 13 menu Pe_*/PeWf_* + 2 WorkflowDefinition v01 (QT-DN-A 3-step, QT-DN-B 5-step)
- [x] Application CQRS ~900 LOC — Create/Update/Transition/List/Inbox/Get/Delete + Supplier CRUD + Detail CRUD + Quote Upsert + SelectWinner + Changelog
- [x] PurchaseEvaluationWorkflowService — policy guard + approval + notification + changelog
- [x] PurchaseEvaluationsController — 17 endpoint REST
- [x] FE 2 app — Types + PurchaseEvaluationsListPage 3-panel + Create page + PeDetailTabs + PeWorkflowPanel + Menu resolver Pe_*
- [x] Kế thừa HĐ — `CreateContractFromEvaluationCommand` (guard DaDuyet + SelectedSupplier + !ContractId) → Contract draft. FE CreateContractDialog pick ContractType.
- [x] **Migration 13** `AddPurchaseEvaluationCodeSequences` — atomic MaPhieu sequence `PE/{YYYY}/{A|B}/{Seq:D3}`
- [x] Demo PE seed — 4 phiếu varied phase (A-001/A-002/A-003/B-001) + Pe_* permission defaults 7 role × 9 menu key
3 task PE feature gap (Workflow Designer / Ý kiến 4 PB / Export PDF) → đã đóng 2/3 ở Phase 8 §C+D. Export PDF carry over Phase 9.
Session log: `2026-04-23-2300-purchase-evaluations.md` + `2026-04-24-1030-pe-polish-demo-maphieu-perms.md`.
### Pending migrations (chưa cần đến)
### Iter 2 — UX polish (2026-04-24)
- [ ] `AddPePaymentTermFields` — nếu chốt UX tách field (JSON → 6 column riêng)
- [ ] `AddBudgetCodeSequences` — atomic SERIALIZABLE khi chốt format MaNganSach (hiện Random.Shared)
- [ ] `AddBudgetVersionedWorkflow` — nếu user cần admin config UI thay hardcoded `BudgetPolicy.Default`
- [x] Rename menu "Phương Án" → "Giải pháp" + backfill DB (zero breaking change)
- [x] Menu tree inheritance extend Pe_*/PeWf_* (`GetMyMenuTreeQuery` + 4 root)
- [x] Accordion mutex Pe_* groups + sidebar w-72 + label nowrap
- [x] NavLink active check query string (queryMatches helper) — fix 2 leaf cùng highlight
- [x] PE detail flat layout: Panel 2 = 4 section (Thông tin/NCC/Hạng mục/**Bảng so sánh**), Panel 3 += Approvals + Changelog
- [x] Upload file đính kèm per-NCC (SupplierAttachmentsCell) + Bảng so sánh tổng (GeneralAttachmentsSection, supplierRowId=null) + enum `ComparisonTable=4`
- [x] readOnly mode menu "Duyệt" (pendingMe=1) — hide Sửa/Xóa/Thêm/Edit/Upload/Delete, giữ download + transition + comment
- [x] Contract: move Lịch sử điều chỉnh Panel 2 → Panel 3 (Chi tiết HĐ full-width)
- [x] Demo email rebrand `@solutionerp.local``@solutions.com.vn` + `BackfillUserEmailDomainAsync` (idempotent rename 4 field Email/NormalizedEmail/UserName/NormalizedUserName)
Session log: `2026-04-24-chot-session-3-pe-polish.md`.
### ✅ Domain rebrand `.huypham.vn` → `.solutions.com.vn` (2026-04-24)
- [x] 18 file repo (FE env + scripts + CI/CD + docs + skill + code comments)
- [x] `scripts/migrate-domains.ps1` (ASCII-only #30) — 3 IIS binding + 3 cert Let's Encrypt + auto HTTPS + redirect
- [x] CI/CD auto rebuild BE CORS + FE bundle VITE_API_BASE_URL
- [x] E2E verified 3 domain live + preflight OK
Sub: `api.solutions.com.vn` · `admin.solutions.com.vn` · `eoffice.solutions.com.vn`. Old `.huypham.vn` vẫn fallback (chưa remove — Phase 9 Ops).
## 📝 Phase 7 — PE feature gap + Budget BE (Session 4 partial done)
### A. PE feature gap (3 task — phần lớn đóng ở Phase 8 S5)
- [x] **PE Workflow admin designer UI** `/system/pe-workflows/:typeCode` — done S5 (`5d94bb4`)
- BE `Application/PurchaseEvaluations/PeWorkflowAdminFeatures.cs` (mirror `WorkflowAdminFeatures.cs`)
- `Api/Controllers/PeWorkflowsController.cs`
- FE `fe-admin/src/pages/system/PeWorkflowsPage.tsx` + `PeWorkflowDesigner.tsx`
- Route `/system/pe-workflows/:typeCode` (menu PeWf_* + resolver đã sẵn)
- [x] **Ý kiến 4 phòng ban** (Phê duyệt / P.CCM / P.MuaHàng / SM-PM) ở tab Thông tin — done S5 (`5d94bb4`, Migration 15)
- Option A: 4 text field + signoff date + UserId vào header
- Option B: dùng `PurchaseEvaluationApprovals` với roleKind extra field
- **Chốt:** dùng Migration 15 `AddPurchaseEvaluationDepartmentOpinions` (separate table UNIQUE PEId+Kind, 4 box sign-off 2x2 grid OpinionBox như Excel mẫu) — tốt hơn Option A/B vì audit qua Changelog + Upsert preserve chữ ký cũ khi text-only edit.
- [ ] **Export phiếu PDF/Excel** — tái dùng `IDocumentConverter` + template `PE-TrinhDuyet.docx` → carry over Phase 9 (user pending — không quan trọng lắm)
### B. Optional polish (carry over Phase 9 — làm khi UAT phát sinh)
- [ ] Auto-map PE Details → Contract per-type Details khi gen HĐ (phức tạp vì 7 schema khác nhau)
- [ ] Payment terms tách field từ JSON → 6 column (Tạm ứng/TT tạm/Quyết toán/Bảo hành/Hạn mức/Đánh giá)
- [ ] Matrix Quotes bulk paste từ Excel
- [ ] fe-user Inbox thêm section "Phiếu Duyệt NCC chờ tôi"
### C. Ops (carry over Phase 9 — Hard blockers)
- [ ] Remove binding cũ `.huypham.vn` sau verify stable: `ssh vietreport-vps ; cd C:\solution-erp\scripts ; .\migrate-domains.ps1 -RemoveOld -SkipCert`
- [ ] win-acme scheduled task fix unhealthy (cert expire 2026-06-18)
- [ ] UAT thật 1 tuần với 2-3 user (30 demo user — 16 sample + 14 Solutions thật)
- [ ] SMTP config → Email outbox
- [ ] Rotate credentials (admin + 30 demo + SA + vrapp + JWT)
- [ ] Schedule SQL backup Task Scheduler
### D. Module Ngân sách (Budget) — Session 4 ✅ partial done
- [x] **Migration 14** `AddBudgets` — 4 bảng (Budgets/BudgetDetails/BudgetApprovals/BudgetChangelogs) + index BudgetId nullable trên Contract & PurchaseEvaluation
- [x] Domain — `Budget` (Header) + `BudgetDetail` (flat row) + `BudgetApproval` + `BudgetChangelog` + enum `BudgetPhase` 5-state + `BudgetEntityType` Header/Detail/Workflow
- [x] `BudgetPolicy.Default` hardcoded simple 3-step (Drafter→CCM→CEO + Reject từ ChoCCM/ChoCEO về DangSoanThao)
- [x] Application CQRS ~340 LOC — Create + UpdateDraft + Transition + List + GetDetail + Delete (only DangSoanThao/TuChoi) + Detail CRUD (auto-recompute TongNganSach) + ListChangelogs
- [x] `BudgetsController` 11 endpoint REST
- [x] Menu seed `Budgets` root + 3 leaf (Bg_List/Bg_Create/Bg_Pending) order=27 icon Wallet
- [x] **14 demo user Solutions thật** — PRO 5 + CCM 7 + ISO 1 + CEO 1 (pwd `User@123456`). Reconcile pattern (gotcha #38 4-field rename). Tổng 30 user (16 sample cũ + 14 Solutions thật mới).
Session log: `2026-04-28-chot-session-4-budget.md`.
### E. Pending migrations
- [ ] `AddPePaymentTermFields` (nếu chốt UX tách field — JSON blob → 6 column)
- [x] **`AddPurchaseEvaluationDepartmentOpinions`** ✅ migration 15 (S5)
- [ ] `AddBudgetCodeSequences` (nếu chốt format MaNganSach atomic — hiện Random.Shared)
- [ ] `AddBudgetVersionedWorkflow` (nếu user cần admin config UI thay vì hardcoded `BudgetPolicy.Default`)
## ✅ Phase 8 — Budget FE + PE/HD integration (Session 5 done)

View File

@ -305,6 +305,66 @@ Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
**Trigger override:** User nói "audit MD", "kiểm tra docs", "định kỳ kiểm tra", "compact docs" → chạy doc audit ngay không đợi cron.
### 6.5 Consolidate MD đúng cách — KEEP vs CUT
> **Bài học session 6 (2026-04-30):** Compact -288 dòng nhanh, paraphrase + cắt narrative để "rõ đẹp" → user phản hồi "viết MD gọn lại tý là mất mẹ luôn tính cách cũ". Docs đọc 6 tháng sau như machine output, không hiểu tại sao chọn approach. Rule §6.5 này chốt để KHÔNG TÁI PHẠM.
**Mục tiêu consolidate:** Giảm độ dài để session sau đọc context không mệt. **KHÔNG = cắt thông tin / paraphrase / "đẹp hóa".**
#### KEEP — CẤM CẮT (asset không tái tạo được)
| Loại | Vì sao bắt buộc giữ NGUYÊN VĂN | Ví dụ |
|---|---|---|
| **Narrative tích lũy qua sessions** | Không tái tạo. Là tài sản chỉ ra "đã đi qua đâu, đã đổi hướng ra sao" | "Ban đầu chọn MediatR 14, fail DI runtime, downgrade 12.4.1" |
| **Rationale (lý do quyết định)** | Quan trọng hơn quyết định. Mất rationale → agent mới revert vì không biết tại sao | "Pin Swashbuckle 6.9.0 vì 10.x conflict OpenApi 2", "Skip E2E Playwright vì brittle cho solo dev" |
| **Gotcha context (incident bối cảnh)** | Gotcha = fix + bối cảnh đau. Mất context → instructions khô không apply được | G-084 "Next.js bind 0.0.0.0 → Gitea fallback IPv6 → IIS ARR resolve IPv4 first → leak homepage" |
| **Anecdote / bài học** | Nuance không có trong code/git, chỉ có trong docs | "Bài học NamGroup: Node latest fail CI Windows" |
| **Decision why over decision what** | Code chỉ có "what", docs phải bù "why" | "PE workflow tách enum riêng vì Phase ≠ ContractPhase" |
#### CUT — XÓA hoặc CHUYỂN CHỖ được
| Loại | Cách xử lý | Ví dụ |
|---|---|---|
| Số liệu lặp giữa nhiều file | Giữ 1 chỗ canonical, các file khác cross-ref | "52 bảng" canonical ở PROJECT-MAP, STATUS/HANDOFF cross-ref |
| Section duplicate copy-paste | Cross-ref thay vì copy nguyên văn | "Versioned WF quick ref" → cross-ref `workflow-contract.md §7bis` |
| List Recently Done > 30 row | Archive sang file riêng `recently-done-archive-{YYYY-MM}.md` (CHUYỂN CHỖ, KHÔNG xóa) | Phase 0-7 cũ |
| Phase đã đóng > 1 tháng | Collapse thành 1 paragraph + link session log đầy đủ | Phase 0-5 → "Phase 0-5 done, [session logs](changelog/sessions/)" |
| Stats lặp | Cumulative table giữ ở STATUS, file khác chỉ con số mới nhất | LOC / endpoint / migration count |
#### CẤM TUYỆT ĐỐI
-**Paraphrase câu chữ original** — dù "rõ ràng hơn", KHÔNG paraphrase. Giữ y nguyên văn cũ.
-**Summary đoạn đã có narrative** — không tóm tắt 5 dòng kể chuyện thành 1 dòng "X done". Mất context.
-**"Đẹp hóa" bằng cách cắt** — đẹp đạt qua format (bảng, anchor link, heading), KHÔNG qua cắt nội dung.
-**Compact với mindset machine-first** — viết cho agent mới đọc 6 tháng sau hiểu, KHÔNG phải tổng hợp ngắn nhất.
#### Decision tree khi gặp đoạn dài
```
Đoạn này có narrative / rationale / gotcha context / anecdote / "tại sao"?
├─ Có → GIỮ NGUYÊN VĂN, kể cả dài (lossless)
└─ Không → kiểm tra:
├─ Duplicate file khác? → cross-ref
├─ Số liệu cũ stale? → archive sang file riêng
├─ Phase đã đóng > 1 tháng? → collapse + link session log
└─ KHÔNG match gì → GIỮ NGUYÊN (default lossless)
```
#### Validation sau consolidate (BẮT BUỘC chạy)
Đọc lại file đã compact, tự hỏi 3 câu:
1. **Agent mới đọc 6 tháng sau có biết "tại sao chọn approach này" không?**
2. **Có còn cảm giác "kể chuyện" tích lũy không, hay chỉ là instructions khô?**
3. **Gotcha context vẫn còn ý nghĩa khi đọc rời rạc không?**
→ Nếu "không" cho **bất kỳ** câu nào → revert đoạn đó về nguyên văn cũ.
#### Trigger áp §6.5
- Audit định kỳ §6.4 (cron đầu tháng)
- User nói "consolidate", "compact", "gọn lại MD", "rõ ràng MD"
- Cuối phase đóng (>1 tháng) khi compact STATUS/HANDOFF/migration-todos
## 7. Testing (Phase 8 active — 77 test pass + CI gate live)
### Stack đã apply

View File

@ -131,24 +131,43 @@ public class TransitionBudgetCommandHandler(
var entity = await db.Budgets.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
?? throw new NotFoundException("Budget", request.Id);
// ===== Smart reject + resume (Phase 9 — Migration 16) =====
var fromPhase = entity.Phase;
var targetPhase = request.TargetPhase;
var isResumingAfterReject = request.Decision == ApprovalDecision.Approve
&& fromPhase == BudgetPhase.DangSoanThao
&& entity.RejectedFromPhase != null;
if (request.Decision == ApprovalDecision.Reject)
{
entity.RejectedFromPhase = fromPhase;
targetPhase = BudgetPhase.DangSoanThao;
}
else if (isResumingAfterReject)
{
targetPhase = entity.RejectedFromPhase!.Value;
entity.RejectedFromPhase = null;
}
var policy = BudgetPolicies.Default;
var isAdmin = currentUser.Roles.Contains(AppRoles.Admin);
if (!isAdmin && !policy.IsTransitionAllowed(entity.Phase, request.TargetPhase, currentUser.Roles))
// Policy guard — bypass khi resume sau reject.
if (!isAdmin && !isResumingAfterReject
&& !policy.IsTransitionAllowed(fromPhase, targetPhase, currentUser.Roles))
throw new ForbiddenException(
$"Role không đủ quyền chuyển {entity.Phase} → {request.TargetPhase}.");
$"Role không đủ quyền chuyển {fromPhase} → {targetPhase}.");
var fromPhase = entity.Phase;
entity.SlaWarningSent = false;
entity.Phase = request.TargetPhase;
var sla = policy.PhaseSla.GetValueOrDefault(request.TargetPhase);
entity.Phase = targetPhase;
var sla = policy.PhaseSla.GetValueOrDefault(targetPhase);
entity.SlaDeadline = sla is null ? null : DateTime.UtcNow.Add(sla.Value);
db.BudgetApprovals.Add(new BudgetApproval
{
BudgetId = entity.Id,
FromPhase = fromPhase,
ToPhase = request.TargetPhase,
ToPhase = targetPhase,
ApproverUserId = currentUser.UserId,
Decision = request.Decision,
Comment = request.Comment,
@ -166,10 +185,10 @@ public class TransitionBudgetCommandHandler(
BudgetId = entity.Id,
EntityType = BudgetEntityType.Workflow,
Action = ChangelogAction.Transition,
PhaseAtChange = request.TargetPhase,
PhaseAtChange = targetPhase,
UserId = currentUser.UserId,
UserName = actorName ?? "Hệ thống",
Summary = $"Chuyển phase {fromPhase} → {request.TargetPhase}",
Summary = $"Chuyển phase {fromPhase} → {targetPhase}",
ContextNote = request.Comment,
});
await db.SaveChangesAsync(ct);
@ -294,6 +313,9 @@ public class AddBudgetDetailCommandHandler(
{
var bg = await db.Budgets.FirstOrDefaultAsync(x => x.Id == request.BudgetId, ct)
?? throw new NotFoundException("Budget", request.BudgetId);
// Lock edit guard (Phase 9 — Migration 16)
if (bg.Phase != BudgetPhase.DangSoanThao)
throw new ConflictException($"Ngân sách đã trình duyệt (Phase={bg.Phase}), không thể thêm hạng mục. Phải reject để Drafter sửa lại.");
var maxOrder = await db.BudgetDetails.Where(d => d.BudgetId == bg.Id)
.Select(d => (int?)d.Order).MaxAsync(ct);
var entity = new BudgetDetail
@ -331,6 +353,11 @@ public class UpdateBudgetDetailCommandHandler(
{
public async Task Handle(UpdateBudgetDetailCommand request, CancellationToken ct)
{
var bg = await db.Budgets.FirstOrDefaultAsync(b => b.Id == request.BudgetId, ct)
?? throw new NotFoundException("Budget", request.BudgetId);
// Lock edit guard (Phase 9 — Migration 16)
if (bg.Phase != BudgetPhase.DangSoanThao)
throw new ConflictException($"Ngân sách đã trình duyệt (Phase={bg.Phase}), không thể sửa hạng mục. Phải reject để Drafter sửa lại.");
var entity = await db.BudgetDetails
.FirstOrDefaultAsync(d => d.Id == request.DetailId && d.BudgetId == request.BudgetId, ct)
?? throw new NotFoundException("BudgetDetail", request.DetailId);
@ -339,10 +366,8 @@ public class UpdateBudgetDetailCommandHandler(
entity.KhoiLuong = request.KhoiLuong; entity.DonGia = request.DonGia; entity.ThanhTien = request.ThanhTien;
entity.GhiChu = request.GhiChu;
var bg = await db.Budgets.FirstOrDefaultAsync(b => b.Id == request.BudgetId, ct);
if (bg != null)
bg.TongNganSach = await db.BudgetDetails.Where(d => d.BudgetId == bg.Id)
.SumAsync(d => d.ThanhTien, ct);
bg.TongNganSach = await db.BudgetDetails.Where(d => d.BudgetId == bg.Id)
.SumAsync(d => d.ThanhTien, ct);
await db.SaveChangesAsync(ct);
}
@ -355,15 +380,18 @@ public class DeleteBudgetDetailCommandHandler(
{
public async Task Handle(DeleteBudgetDetailCommand request, CancellationToken ct)
{
var bg = await db.Budgets.FirstOrDefaultAsync(b => b.Id == request.BudgetId, ct)
?? throw new NotFoundException("Budget", request.BudgetId);
// Lock edit guard (Phase 9 — Migration 16)
if (bg.Phase != BudgetPhase.DangSoanThao)
throw new ConflictException($"Ngân sách đã trình duyệt (Phase={bg.Phase}), không thể xóa hạng mục. Phải reject để Drafter sửa lại.");
var entity = await db.BudgetDetails
.FirstOrDefaultAsync(d => d.Id == request.DetailId && d.BudgetId == request.BudgetId, ct)
?? throw new NotFoundException("BudgetDetail", request.DetailId);
db.BudgetDetails.Remove(entity);
var bg = await db.Budgets.FirstOrDefaultAsync(b => b.Id == request.BudgetId, ct);
if (bg != null)
bg.TongNganSach = await db.BudgetDetails.Where(d => d.BudgetId == bg.Id && d.Id != entity.Id)
.SumAsync(d => d.ThanhTien, ct);
bg.TongNganSach = await db.BudgetDetails.Where(d => d.BudgetId == bg.Id && d.Id != entity.Id)
.SumAsync(d => d.ThanhTien, ct);
await db.SaveChangesAsync(ct);
}

View File

@ -31,6 +31,7 @@ public interface IApplicationDbContext
DbSet<ContractAttachment> ContractAttachments { get; }
DbSet<ContractCodeSequence> ContractCodeSequences { get; }
DbSet<ContractChangelog> ContractChangelogs { get; }
DbSet<ContractDepartmentApproval> ContractDepartmentApprovals { get; }
DbSet<Notification> Notifications { get; }
DbSet<WorkflowTypeAssignment> WorkflowTypeAssignments { get; }
DbSet<WorkflowDefinition> WorkflowDefinitions { get; }
@ -59,12 +60,14 @@ public interface IApplicationDbContext
DbSet<PurchaseEvaluationWorkflowStepApprover> PurchaseEvaluationWorkflowStepApprovers { get; }
DbSet<PurchaseEvaluationCodeSequence> PurchaseEvaluationCodeSequences { get; }
DbSet<PurchaseEvaluationDepartmentOpinion> PurchaseEvaluationDepartmentOpinions { get; }
DbSet<PurchaseEvaluationDepartmentApproval> PurchaseEvaluationDepartmentApprovals { get; }
// Module Ngân sách (Phase 7)
DbSet<Budget> Budgets { get; }
DbSet<BudgetDetail> BudgetDetails { get; }
DbSet<BudgetApproval> BudgetApprovals { get; }
DbSet<BudgetChangelog> BudgetChangelogs { get; }
DbSet<BudgetDepartmentApproval> BudgetDepartmentApprovals { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@ -424,6 +424,9 @@ public class DeleteContractDetailHandler(IApplicationDbContext db, IChangelogSer
{
var contract = await db.Contracts.AsNoTracking().FirstOrDefaultAsync(c => c.Id == cmd.ContractId, ct)
?? throw new NotFoundException("Contract", cmd.ContractId);
// Lock edit guard (Phase 9 — Migration 16): chỉ Phase=DangSoanThao xóa được.
if (contract.Phase != ContractPhase.DangSoanThao)
throw new ConflictException($"HĐ đã trình duyệt (Phase={contract.Phase}), không thể xóa chi tiết. Phải reject để Drafter sửa lại.");
// Dispatch xóa theo Type — tránh load tất cả 7 DbSet
bool removed = false;
@ -477,6 +480,10 @@ internal static class ContractDetailsHelpers
?? throw new NotFoundException("Contract", contractId);
if (contract.Type != expectedType)
throw new ConflictException($"HĐ này thuộc loại {contract.Type}, không thể thêm chi tiết loại {expectedType}.");
// Lock edit guard (Phase 9 — Migration 16): chỉ Phase=DangSoanThao mới
// được CRUD chi tiết. Đã trình duyệt → KHÔNG sửa được thông tin nữa.
if (contract.Phase != ContractPhase.DangSoanThao)
throw new ConflictException($"HĐ đã trình duyệt (Phase={contract.Phase}), không thể chỉnh sửa chi tiết. Phải reject để Drafter sửa lại.");
return contract;
}
}

View File

@ -8,6 +8,21 @@ using SolutionErp.Domain.PurchaseEvaluations;
namespace SolutionErp.Application.PurchaseEvaluations;
// ========== Helper: Lock edit guard (Phase 9 — Migration 16) ==========
// Chỉ Phase=DangSoanThao mới được CRUD chi tiết / báo giá / NCC tham gia.
// Đã trình duyệt → KHÔNG sửa được. Phải reject về DangSoanThao trước.
internal static class PurchaseEvaluationDraftGuard
{
public static async Task<PurchaseEvaluation> EnsureDraftAsync(IApplicationDbContext db, Guid id, CancellationToken ct)
{
var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == id, ct)
?? throw new NotFoundException("PurchaseEvaluation", id);
if (pe.Phase != PurchaseEvaluationPhase.DangSoanThao)
throw new ConflictException($"Phiếu PE đã trình duyệt (Phase={pe.Phase}), không thể chỉnh sửa chi tiết. Phải reject để Drafter sửa lại.");
return pe;
}
}
// ========== Detail (hạng mục + ngân sách) ==========
public record AddPurchaseEvaluationDetailCommand(
@ -40,8 +55,7 @@ public class AddPurchaseEvaluationDetailCommandHandler(
{
public async Task<Guid> Handle(AddPurchaseEvaluationDetailCommand request, CancellationToken ct)
{
var evaluation = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PurchaseEvaluationId, ct)
?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId);
var evaluation = await PurchaseEvaluationDraftGuard.EnsureDraftAsync(db, request.PurchaseEvaluationId, ct);
var maxOrder = await db.PurchaseEvaluationDetails
.Where(d => d.PurchaseEvaluationId == request.PurchaseEvaluationId)
@ -100,6 +114,7 @@ public class UpdatePurchaseEvaluationDetailCommandHandler(
{
public async Task Handle(UpdatePurchaseEvaluationDetailCommand request, CancellationToken ct)
{
await PurchaseEvaluationDraftGuard.EnsureDraftAsync(db, request.PurchaseEvaluationId, ct);
var entity = await db.PurchaseEvaluationDetails
.FirstOrDefaultAsync(x => x.Id == request.DetailId && x.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
?? throw new NotFoundException("PurchaseEvaluationDetail", request.DetailId);
@ -126,6 +141,7 @@ public class DeletePurchaseEvaluationDetailCommandHandler(
{
public async Task Handle(DeletePurchaseEvaluationDetailCommand request, CancellationToken ct)
{
await PurchaseEvaluationDraftGuard.EnsureDraftAsync(db, request.PurchaseEvaluationId, ct);
var entity = await db.PurchaseEvaluationDetails
.FirstOrDefaultAsync(x => x.Id == request.DetailId && x.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
?? throw new NotFoundException("PurchaseEvaluationDetail", request.DetailId);
@ -152,6 +168,7 @@ public class UpsertPurchaseEvaluationQuoteCommandHandler(
{
public async Task<Guid> Handle(UpsertPurchaseEvaluationQuoteCommand request, CancellationToken ct)
{
await PurchaseEvaluationDraftGuard.EnsureDraftAsync(db, request.PurchaseEvaluationId, ct);
// Verify parents exist + same phiếu
var detail = await db.PurchaseEvaluationDetails.FirstOrDefaultAsync(
d => d.Id == request.PurchaseEvaluationDetailId && d.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
@ -199,6 +216,7 @@ public class DeletePurchaseEvaluationQuoteCommandHandler(
{
public async Task Handle(DeletePurchaseEvaluationQuoteCommand request, CancellationToken ct)
{
await PurchaseEvaluationDraftGuard.EnsureDraftAsync(db, request.PurchaseEvaluationId, ct);
var quote = await (
from q in db.PurchaseEvaluationQuotes
join d in db.PurchaseEvaluationDetails on q.PurchaseEvaluationDetailId equals d.Id

View File

@ -23,7 +23,12 @@ public class Budget : AuditableEntity
public DateTime? SlaDeadline { get; set; }
public bool SlaWarningSent { get; set; }
// Smart reject (Phase 9 — Migration 16): Phase nguồn khi reject. Drafter
// sửa lại + trình lại → quay về RejectedFromPhase thay vì DangSoanThao.
public BudgetPhase? RejectedFromPhase { get; set; }
public List<BudgetDetail> Details { get; set; } = new();
public List<BudgetApproval> Approvals { get; set; } = new();
public List<BudgetChangelog> Changelogs { get; set; } = new();
public List<BudgetDepartmentApproval> DepartmentApprovals { get; set; } = new();
}

View File

@ -0,0 +1,20 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Budgets;
// 2-stage department approval cho Budget workflow (Phase 9 — Migration 16).
// Mirror schema ContractDepartmentApproval / PurchaseEvaluationDepartmentApproval.
public class BudgetDepartmentApproval : AuditableEntity
{
public Guid BudgetId { get; set; }
public int PhaseAtApproval { get; set; } // snapshot BudgetPhase int
public Guid DepartmentId { get; set; }
public ApprovalStage Stage { get; set; } // 1=Review (NV), 2=Confirm (TPB)
public Guid ApproverUserId { get; set; }
public string? ApproverRoleSnapshot { get; set; }
public string? Comment { get; set; }
public DateTime ApprovedAt { get; set; }
public bool IsBypassed { get; set; }
public Budget? Budget { get; set; }
}

View File

@ -0,0 +1,13 @@
namespace SolutionErp.Domain.Common;
// 2-stage department approval (Phase 9 — Migration 16).
// Mỗi phòng ban (Department) duyệt 1 phase qua 2 cấp:
// - Review: NV.<Dept> duyệt (cấp 1)
// - Confirm: TPB.<Dept> duyệt (cấp 2)
// Khi user.CanBypassReview=true → NV được skip Review, đẩy thẳng Confirm
// (audit qua field IsBypassed=true trong DepartmentApproval row).
public enum ApprovalStage
{
Review = 1,
Confirm = 2,
}

View File

@ -25,10 +25,16 @@ public class Contract : AuditableEntity
public bool SlaWarningSent { get; set; } // Flag để không gửi warning 2 lần
public Guid? BudgetId { get; set; } // Reference Budget (Phase 7) — đối chiếu chi phí HĐ vs ngân sách
// Smart reject (Phase 9 — Migration 16): Phase nguồn khi reject. Drafter
// sửa lại + trình lại → quay về RejectedFromPhase thay vì DangSoanThao
// tuần tự lại từ đầu. Null khi chưa từng reject hoặc đã trình lại xong.
public ContractPhase? RejectedFromPhase { get; set; }
public List<ContractApproval> Approvals { get; set; } = new();
public List<ContractComment> Comments { get; set; } = new();
public List<ContractAttachment> Attachments { get; set; } = new();
public List<ContractChangelog> Changelogs { get; set; } = new();
public List<ContractDepartmentApproval> DepartmentApprovals { get; set; } = new();
// Per-type details — chỉ 1 collection có data tương ứng với Type. Backend
// logic dispatch theo Contract.Type để load đúng bảng. KHÔNG load eager

View File

@ -0,0 +1,27 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Contracts;
// 2-stage department approval cho HĐ workflow (Phase 9 — Migration 16).
// Mỗi phase × phòng ban có max 2 row: Stage=Review (NV duyệt) + Stage=Confirm
// (TPB duyệt). Workflow service guard: chỉ transition khi đã có Stage=Confirm.
//
// User.CanBypassReview=true → NV insert thẳng Stage=Confirm (IsBypassed=true).
//
// UNIQUE (ContractId, PhaseAtApproval, DepartmentId, Stage) — 1 phase × 1 phòng
// × 1 stage = 1 row duy nhất. UPDATE in-place khi user đổi ý (audit qua
// ContractChangelog).
public class ContractDepartmentApproval : AuditableEntity
{
public Guid ContractId { get; set; }
public int PhaseAtApproval { get; set; } // snapshot ContractPhase int (Phase tại lúc approve)
public Guid DepartmentId { get; set; }
public ApprovalStage Stage { get; set; } // 1=Review (NV), 2=Confirm (TPB)
public Guid ApproverUserId { get; set; }
public string? ApproverRoleSnapshot { get; set; } // VD "NV.CCM" / "TPB.CCM" — denorm cho audit readable
public string? Comment { get; set; }
public DateTime ApprovedAt { get; set; }
public bool IsBypassed { get; set; } // true nếu NV bypass (User.CanBypassReview=true)
public Contract? Contract { get; set; }
}

View File

@ -15,4 +15,9 @@ public class User : IdentityUser<Guid>
// cho admin/system user không thuộc dept cụ thể.
public Guid? DepartmentId { get; set; }
public string? Position { get; set; } // vd "Trưởng phòng CCM", "QS công trường", "Phó GĐ"
// 2-stage department approval (Phase 9 — Migration 16): khi true, NV
// được quyền duyệt thay TPB (skip Stage Review, đẩy thẳng Stage Confirm).
// Mặc định false (an toàn). Admin set ở UserManager UI.
public bool CanBypassReview { get; set; }
}

View File

@ -28,6 +28,10 @@ public class PurchaseEvaluation : AuditableEntity
public Guid? ContractId { get; set; } // FK Contracts — set khi user gen HĐ từ phiếu
public Guid? BudgetId { get; set; } // FK Budget (Phase 7) — đối chiếu báo giá vs ngân sách gói thầu
// Smart reject (Phase 9 — Migration 16): Phase nguồn khi reject. Drafter
// sửa lại + trình lại → quay về RejectedFromPhase thay vì đi tuần tự.
public PurchaseEvaluationPhase? RejectedFromPhase { get; set; }
public List<PurchaseEvaluationSupplier> Suppliers { get; set; } = new();
public List<PurchaseEvaluationDetail> Details { get; set; } = new();
public List<PurchaseEvaluationQuote> Quotes { get; set; } = new();
@ -35,4 +39,5 @@ public class PurchaseEvaluation : AuditableEntity
public List<PurchaseEvaluationChangelog> Changelogs { get; set; } = new();
public List<PurchaseEvaluationAttachment> Attachments { get; set; } = new();
public List<PurchaseEvaluationDepartmentOpinion> DepartmentOpinions { get; set; } = new();
public List<PurchaseEvaluationDepartmentApproval> DepartmentApprovals { get; set; } = new();
}

View File

@ -0,0 +1,26 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.PurchaseEvaluations;
// 2-stage department approval cho PE workflow (Phase 9 — Migration 16).
// Mirror schema ContractDepartmentApproval — pattern thống nhất cho cả 3
// module (HĐ / PE / Budget).
//
// LƯU Ý: KHÁC `PurchaseEvaluationDepartmentOpinion` (Migration 15) — Opinion
// là sign-off block "Ý kiến 4 phòng ban" (Phê duyệt/CCM/MuaHàng/SM-PM) trên
// header phiếu. DepartmentApproval là 2-stage approval per phase trong
// workflow chính.
public class PurchaseEvaluationDepartmentApproval : AuditableEntity
{
public Guid PurchaseEvaluationId { get; set; }
public int PhaseAtApproval { get; set; } // snapshot PurchaseEvaluationPhase int
public Guid DepartmentId { get; set; }
public ApprovalStage Stage { get; set; } // 1=Review (NV), 2=Confirm (TPB)
public Guid ApproverUserId { get; set; }
public string? ApproverRoleSnapshot { get; set; }
public string? Comment { get; set; }
public DateTime ApprovedAt { get; set; }
public bool IsBypassed { get; set; }
public PurchaseEvaluation? PurchaseEvaluation { get; set; }
}

View File

@ -35,6 +35,7 @@ public class ApplicationDbContext
public DbSet<ContractAttachment> ContractAttachments => Set<ContractAttachment>();
public DbSet<ContractCodeSequence> ContractCodeSequences => Set<ContractCodeSequence>();
public DbSet<ContractChangelog> ContractChangelogs => Set<ContractChangelog>();
public DbSet<ContractDepartmentApproval> ContractDepartmentApprovals => Set<ContractDepartmentApproval>();
public DbSet<Notification> Notifications => Set<Notification>();
public DbSet<WorkflowTypeAssignment> WorkflowTypeAssignments => Set<WorkflowTypeAssignment>();
public DbSet<WorkflowDefinition> WorkflowDefinitions => Set<WorkflowDefinition>();
@ -60,12 +61,14 @@ public class ApplicationDbContext
public DbSet<PurchaseEvaluationWorkflowStepApprover> PurchaseEvaluationWorkflowStepApprovers => Set<PurchaseEvaluationWorkflowStepApprover>();
public DbSet<PurchaseEvaluationCodeSequence> PurchaseEvaluationCodeSequences => Set<PurchaseEvaluationCodeSequence>();
public DbSet<PurchaseEvaluationDepartmentOpinion> PurchaseEvaluationDepartmentOpinions => Set<PurchaseEvaluationDepartmentOpinion>();
public DbSet<PurchaseEvaluationDepartmentApproval> PurchaseEvaluationDepartmentApprovals => Set<PurchaseEvaluationDepartmentApproval>();
// Module Ngân sách (Phase 7) — 4 bảng: Budget header + Details + Approvals + Changelogs.
public DbSet<Budget> Budgets => Set<Budget>();
public DbSet<BudgetDetail> BudgetDetails => Set<BudgetDetail>();
public DbSet<BudgetApproval> BudgetApprovals => Set<BudgetApproval>();
public DbSet<BudgetChangelog> BudgetChangelogs => Set<BudgetChangelog>();
public DbSet<BudgetDepartmentApproval> BudgetDepartmentApprovals => Set<BudgetDepartmentApproval>();
protected override void OnModelCreating(ModelBuilder builder)
{

View File

@ -15,6 +15,7 @@ public class BudgetConfiguration : IEntityTypeConfiguration<Budget>
b.Property(x => x.TenNganSach).HasMaxLength(500).IsRequired();
b.Property(x => x.Description).HasMaxLength(2000);
b.Property(x => x.Phase).HasConversion<int>();
b.Property(x => x.RejectedFromPhase).HasConversion<int?>();
b.Property(x => x.TongNganSach).HasPrecision(18, 2);
b.HasIndex(x => x.MaNganSach).IsUnique().HasFilter("[MaNganSach] IS NOT NULL");

View File

@ -14,6 +14,7 @@ public class ContractConfiguration : IEntityTypeConfiguration<Contract>
b.Property(x => x.MaHopDong).HasMaxLength(100);
b.Property(x => x.Type).HasConversion<int>();
b.Property(x => x.Phase).HasConversion<int>();
b.Property(x => x.RejectedFromPhase).HasConversion<int?>();
b.Property(x => x.GiaTri).HasPrecision(18, 2);
b.Property(x => x.TenHopDong).HasMaxLength(500);
b.Property(x => x.NoiDung).HasMaxLength(2000);

View File

@ -0,0 +1,94 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.Budgets;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.PurchaseEvaluations;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
// 3 bảng *DepartmentApprovals (Phase 9 — Migration 16) — 2-stage approval per
// phase × phòng ban cho HĐ / PE / Budget.
//
// UNIQUE (TargetId, PhaseAtApproval, DepartmentId, Stage) — 1 phase × 1 phòng
// × 1 stage = 1 row duy nhất. UPDATE in-place khi user đổi ý → audit qua
// *Changelog.
//
// FK Cascade theo target (xóa HĐ/PE/Budget → xóa approval rows).
public class ContractDepartmentApprovalConfiguration
: IEntityTypeConfiguration<ContractDepartmentApproval>
{
public void Configure(EntityTypeBuilder<ContractDepartmentApproval> b)
{
b.ToTable("ContractDepartmentApprovals");
b.HasKey(x => x.Id);
b.Property(x => x.Stage).HasConversion<int>();
b.Property(x => x.ApproverRoleSnapshot).HasMaxLength(100);
b.Property(x => x.Comment).HasMaxLength(1000);
b.HasIndex(x => new { x.ContractId, x.PhaseAtApproval, x.DepartmentId, x.Stage })
.IsUnique()
.HasDatabaseName("UX_ContractDeptApprovals_Contract_Phase_Dept_Stage");
b.HasIndex(x => x.ContractId);
b.HasIndex(x => x.DepartmentId);
b.HasIndex(x => x.ApproverUserId);
b.HasOne(x => x.Contract)
.WithMany(c => c.DepartmentApprovals)
.HasForeignKey(x => x.ContractId)
.OnDelete(DeleteBehavior.Cascade);
}
}
public class PurchaseEvaluationDepartmentApprovalConfiguration
: IEntityTypeConfiguration<PurchaseEvaluationDepartmentApproval>
{
public void Configure(EntityTypeBuilder<PurchaseEvaluationDepartmentApproval> b)
{
b.ToTable("PurchaseEvaluationDepartmentApprovals");
b.HasKey(x => x.Id);
b.Property(x => x.Stage).HasConversion<int>();
b.Property(x => x.ApproverRoleSnapshot).HasMaxLength(100);
b.Property(x => x.Comment).HasMaxLength(1000);
b.HasIndex(x => new { x.PurchaseEvaluationId, x.PhaseAtApproval, x.DepartmentId, x.Stage })
.IsUnique()
.HasDatabaseName("UX_PEDeptApprovals_PE_Phase_Dept_Stage");
b.HasIndex(x => x.PurchaseEvaluationId);
b.HasIndex(x => x.DepartmentId);
b.HasIndex(x => x.ApproverUserId);
b.HasOne(x => x.PurchaseEvaluation)
.WithMany(c => c.DepartmentApprovals)
.HasForeignKey(x => x.PurchaseEvaluationId)
.OnDelete(DeleteBehavior.Cascade);
}
}
public class BudgetDepartmentApprovalConfiguration
: IEntityTypeConfiguration<BudgetDepartmentApproval>
{
public void Configure(EntityTypeBuilder<BudgetDepartmentApproval> b)
{
b.ToTable("BudgetDepartmentApprovals");
b.HasKey(x => x.Id);
b.Property(x => x.Stage).HasConversion<int>();
b.Property(x => x.ApproverRoleSnapshot).HasMaxLength(100);
b.Property(x => x.Comment).HasMaxLength(1000);
b.HasIndex(x => new { x.BudgetId, x.PhaseAtApproval, x.DepartmentId, x.Stage })
.IsUnique()
.HasDatabaseName("UX_BudgetDeptApprovals_Budget_Phase_Dept_Stage");
b.HasIndex(x => x.BudgetId);
b.HasIndex(x => x.DepartmentId);
b.HasIndex(x => x.ApproverUserId);
b.HasOne(x => x.Budget)
.WithMany(c => c.DepartmentApprovals)
.HasForeignKey(x => x.BudgetId)
.OnDelete(DeleteBehavior.Cascade);
}
}

View File

@ -14,6 +14,7 @@ public class PurchaseEvaluationConfiguration : IEntityTypeConfiguration<Purchase
b.Property(x => x.MaPhieu).HasMaxLength(100);
b.Property(x => x.Type).HasConversion<int>();
b.Property(x => x.Phase).HasConversion<int>();
b.Property(x => x.RejectedFromPhase).HasConversion<int?>();
b.Property(x => x.TenGoiThau).HasMaxLength(500).IsRequired();
b.Property(x => x.DiaDiem).HasMaxLength(500);
b.Property(x => x.MoTa).HasMaxLength(2000);

View File

@ -0,0 +1,231 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddTwoStageDeptApprovalAndSmartReject : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "CanBypassReview",
table: "Users",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<int>(
name: "RejectedFromPhase",
table: "PurchaseEvaluations",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "RejectedFromPhase",
table: "Contracts",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "RejectedFromPhase",
table: "Budgets",
type: "int",
nullable: true);
migrationBuilder.CreateTable(
name: "BudgetDepartmentApprovals",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
BudgetId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
PhaseAtApproval = table.Column<int>(type: "int", nullable: false),
DepartmentId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Stage = table.Column<int>(type: "int", nullable: false),
ApproverUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ApproverRoleSnapshot = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
Comment = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
ApprovedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
IsBypassed = table.Column<bool>(type: "bit", 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_BudgetDepartmentApprovals", x => x.Id);
table.ForeignKey(
name: "FK_BudgetDepartmentApprovals_Budgets_BudgetId",
column: x => x.BudgetId,
principalTable: "Budgets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ContractDepartmentApprovals",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ContractId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
PhaseAtApproval = table.Column<int>(type: "int", nullable: false),
DepartmentId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Stage = table.Column<int>(type: "int", nullable: false),
ApproverUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ApproverRoleSnapshot = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
Comment = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
ApprovedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
IsBypassed = table.Column<bool>(type: "bit", 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_ContractDepartmentApprovals", x => x.Id);
table.ForeignKey(
name: "FK_ContractDepartmentApprovals_Contracts_ContractId",
column: x => x.ContractId,
principalTable: "Contracts",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "PurchaseEvaluationDepartmentApprovals",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
PurchaseEvaluationId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
PhaseAtApproval = table.Column<int>(type: "int", nullable: false),
DepartmentId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Stage = table.Column<int>(type: "int", nullable: false),
ApproverUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ApproverRoleSnapshot = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
Comment = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
ApprovedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
IsBypassed = table.Column<bool>(type: "bit", 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_PurchaseEvaluationDepartmentApprovals", x => x.Id);
table.ForeignKey(
name: "FK_PurchaseEvaluationDepartmentApprovals_PurchaseEvaluations_PurchaseEvaluationId",
column: x => x.PurchaseEvaluationId,
principalTable: "PurchaseEvaluations",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_BudgetDepartmentApprovals_ApproverUserId",
table: "BudgetDepartmentApprovals",
column: "ApproverUserId");
migrationBuilder.CreateIndex(
name: "IX_BudgetDepartmentApprovals_BudgetId",
table: "BudgetDepartmentApprovals",
column: "BudgetId");
migrationBuilder.CreateIndex(
name: "IX_BudgetDepartmentApprovals_DepartmentId",
table: "BudgetDepartmentApprovals",
column: "DepartmentId");
migrationBuilder.CreateIndex(
name: "UX_BudgetDeptApprovals_Budget_Phase_Dept_Stage",
table: "BudgetDepartmentApprovals",
columns: new[] { "BudgetId", "PhaseAtApproval", "DepartmentId", "Stage" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ContractDepartmentApprovals_ApproverUserId",
table: "ContractDepartmentApprovals",
column: "ApproverUserId");
migrationBuilder.CreateIndex(
name: "IX_ContractDepartmentApprovals_ContractId",
table: "ContractDepartmentApprovals",
column: "ContractId");
migrationBuilder.CreateIndex(
name: "IX_ContractDepartmentApprovals_DepartmentId",
table: "ContractDepartmentApprovals",
column: "DepartmentId");
migrationBuilder.CreateIndex(
name: "UX_ContractDeptApprovals_Contract_Phase_Dept_Stage",
table: "ContractDepartmentApprovals",
columns: new[] { "ContractId", "PhaseAtApproval", "DepartmentId", "Stage" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluationDepartmentApprovals_ApproverUserId",
table: "PurchaseEvaluationDepartmentApprovals",
column: "ApproverUserId");
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluationDepartmentApprovals_DepartmentId",
table: "PurchaseEvaluationDepartmentApprovals",
column: "DepartmentId");
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluationDepartmentApprovals_PurchaseEvaluationId",
table: "PurchaseEvaluationDepartmentApprovals",
column: "PurchaseEvaluationId");
migrationBuilder.CreateIndex(
name: "UX_PEDeptApprovals_PE_Phase_Dept_Stage",
table: "PurchaseEvaluationDepartmentApprovals",
columns: new[] { "PurchaseEvaluationId", "PhaseAtApproval", "DepartmentId", "Stage" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "BudgetDepartmentApprovals");
migrationBuilder.DropTable(
name: "ContractDepartmentApprovals");
migrationBuilder.DropTable(
name: "PurchaseEvaluationDepartmentApprovals");
migrationBuilder.DropColumn(
name: "CanBypassReview",
table: "Users");
migrationBuilder.DropColumn(
name: "RejectedFromPhase",
table: "PurchaseEvaluations");
migrationBuilder.DropColumn(
name: "RejectedFromPhase",
table: "Contracts");
migrationBuilder.DropColumn(
name: "RejectedFromPhase",
table: "Budgets");
}
}
}

View File

@ -169,6 +169,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<Guid>("ProjectId")
.HasColumnType("uniqueidentifier");
b.Property<int?>("RejectedFromPhase")
.HasColumnType("int");
b.Property<DateTime?>("SlaDeadline")
.HasColumnType("datetime2");
@ -314,6 +317,77 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("BudgetChangelogs", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetDepartmentApproval", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("ApprovedAt")
.HasColumnType("datetime2");
b.Property<string>("ApproverRoleSnapshot")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<Guid>("ApproverUserId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("BudgetId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Comment")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
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<Guid>("DepartmentId")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsBypassed")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<int>("PhaseAtApproval")
.HasColumnType("int");
b.Property<int>("Stage")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ApproverUserId");
b.HasIndex("BudgetId");
b.HasIndex("DepartmentId");
b.HasIndex("BudgetId", "PhaseAtApproval", "DepartmentId", "Stage")
.IsUnique()
.HasDatabaseName("UX_BudgetDeptApprovals_Budget_Phase_Dept_Stage");
b.ToTable("BudgetDepartmentApprovals", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetDetail", b =>
{
b.Property<Guid>("Id")
@ -438,6 +512,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<Guid>("ProjectId")
.HasColumnType("uniqueidentifier");
b.Property<int?>("RejectedFromPhase")
.HasColumnType("int");
b.Property<DateTime?>("SlaDeadline")
.HasColumnType("datetime2");
@ -701,6 +778,77 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("ContractComments", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractDepartmentApproval", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("ApprovedAt")
.HasColumnType("datetime2");
b.Property<string>("ApproverRoleSnapshot")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<Guid>("ApproverUserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Comment")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
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<Guid>("DepartmentId")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsBypassed")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<int>("PhaseAtApproval")
.HasColumnType("int");
b.Property<int>("Stage")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ApproverUserId");
b.HasIndex("ContractId");
b.HasIndex("DepartmentId");
b.HasIndex("ContractId", "PhaseAtApproval", "DepartmentId", "Stage")
.IsUnique()
.HasDatabaseName("UX_ContractDeptApprovals_Contract_Phase_Dept_Stage");
b.ToTable("ContractDepartmentApprovals", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.DichVuDetail", b =>
{
b.Property<Guid>("Id")
@ -1598,6 +1746,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<int>("AccessFailedCount")
.HasColumnType("int");
b.Property<bool>("CanBypassReview")
.HasColumnType("bit");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
@ -2222,6 +2373,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<Guid>("ProjectId")
.HasColumnType("uniqueidentifier");
b.Property<int?>("RejectedFromPhase")
.HasColumnType("int");
b.Property<Guid?>("SelectedSupplierId")
.HasColumnType("uniqueidentifier");
@ -2451,6 +2605,77 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("PurchaseEvaluationCodeSequences", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDepartmentApproval", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("ApprovedAt")
.HasColumnType("datetime2");
b.Property<string>("ApproverRoleSnapshot")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<Guid>("ApproverUserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Comment")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
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<Guid>("DepartmentId")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsBypassed")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<int>("PhaseAtApproval")
.HasColumnType("int");
b.Property<Guid>("PurchaseEvaluationId")
.HasColumnType("uniqueidentifier");
b.Property<int>("Stage")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ApproverUserId");
b.HasIndex("DepartmentId");
b.HasIndex("PurchaseEvaluationId");
b.HasIndex("PurchaseEvaluationId", "PhaseAtApproval", "DepartmentId", "Stage")
.IsUnique()
.HasDatabaseName("UX_PEDeptApprovals_PE_Phase_Dept_Stage");
b.ToTable("PurchaseEvaluationDepartmentApprovals", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDepartmentOpinion", b =>
{
b.Property<Guid>("Id")
@ -2904,6 +3129,17 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("Budget");
});
modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetDepartmentApproval", b =>
{
b.HasOne("SolutionErp.Domain.Budgets.Budget", "Budget")
.WithMany("DepartmentApprovals")
.HasForeignKey("BudgetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Budget");
});
modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetDetail", b =>
{
b.HasOne("SolutionErp.Domain.Budgets.Budget", "Budget")
@ -2959,6 +3195,17 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("Contract");
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractDepartmentApproval", b =>
{
b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract")
.WithMany("DepartmentApprovals")
.HasForeignKey("ContractId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Contract");
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.DichVuDetail", b =>
{
b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract")
@ -3128,6 +3375,17 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("PurchaseEvaluation");
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDepartmentApproval", b =>
{
b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation")
.WithMany("DepartmentApprovals")
.HasForeignKey("PurchaseEvaluationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("PurchaseEvaluation");
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDepartmentOpinion", b =>
{
b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation")
@ -3212,6 +3470,8 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("Changelogs");
b.Navigation("DepartmentApprovals");
b.Navigation("Details");
});
@ -3225,6 +3485,8 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("Comments");
b.Navigation("DepartmentApprovals");
b.Navigation("DichVuDetails");
b.Navigation("GiaoKhoanDetails");
@ -3265,6 +3527,8 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("Changelogs");
b.Navigation("DepartmentApprovals");
b.Navigation("DepartmentOpinions");
b.Navigation("Details");

View File

@ -37,6 +37,26 @@ public class ContractWorkflowService(
if (contract.Phase == targetPhase)
throw new ConflictException("HĐ đã ở phase đích.");
// ===== Smart reject + resume (Phase 9 — Migration 16) =====
// Reject: override target = DangSoanThao + lưu phase gốc → Drafter sửa.
// Resume sau reject: Drafter trình từ DangSoanThao + RejectedFromPhase
// != null → jump straight tới phase đã reject, bypass phase trung gian.
var fromPhase = contract.Phase;
var isResumingAfterReject = decision == ApprovalDecision.Approve
&& fromPhase == ContractPhase.DangSoanThao
&& contract.RejectedFromPhase != null;
if (decision == ApprovalDecision.Reject)
{
contract.RejectedFromPhase = fromPhase;
targetPhase = ContractPhase.DangSoanThao;
}
else if (isResumingAfterReject)
{
targetPhase = contract.RejectedFromPhase!.Value;
contract.RejectedFromPhase = null;
}
// Resolve the workflow: prefer the pinned WorkflowDefinition (new
// versioned system), else fall back to the static/override registry
// (legacy path for contracts created before versioning rolled out).
@ -60,31 +80,31 @@ public class ContractWorkflowService(
var isAdmin = actorRoles.Contains(AppRoles.Admin);
var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove;
if (!isAdmin && !isSystem)
// Policy guard — bypass cho resume (Drafter có quyền trình lại sau khi
// sửa, không cần policy check vì target đã pinned bởi RejectedFromPhase).
if (!isAdmin && !isSystem && !isResumingAfterReject)
{
if (!policy.Transitions.TryGetValue((contract.Phase, targetPhase), out var allowedRoles))
if (!policy.Transitions.TryGetValue((fromPhase, targetPhase), out var allowedRoles))
throw new ForbiddenException(
$"Policy '{policy.Name}' không cho phép {contract.Phase} → {targetPhase}. " +
$"Policy '{policy.Name}' không cho phép {fromPhase} → {targetPhase}. " +
$"Kiểm tra ContractType hoặc BypassProcurementAndCCM.");
// Sử dụng IsTransitionAllowed — check Role + User-kind fallback.
// User-kind chỉ áp dụng khi WorkflowDefinition pinned có
// WorkflowStepApprover Kind=User cho step này.
if (!policy.IsTransitionAllowed(contract.Phase, targetPhase, actorRoles, actorUserId))
if (!policy.IsTransitionAllowed(fromPhase, targetPhase, actorRoles, actorUserId))
{
var userExtra = policy.UserTransitions is not null
&& policy.UserTransitions.TryGetValue((contract.Phase, targetPhase), out var userIds)
&& policy.UserTransitions.TryGetValue((fromPhase, targetPhase), out var userIds)
&& userIds.Length > 0
? $" hoặc {userIds.Length} user explicit"
: "";
throw new ForbiddenException(
$"Role ({string.Join(",", actorRoles)}) không đủ quyền chuyển {contract.Phase} → {targetPhase}. " +
$"Role ({string.Join(",", actorRoles)}) không đủ quyền chuyển {fromPhase} → {targetPhase}. " +
$"Policy '{policy.Name}' yêu cầu: {string.Join(",", allowedRoles)}{userExtra}.");
}
}
var fromPhase = contract.Phase;
// Defensive — gen mã HĐ nếu chưa có khi chuyển sang DangDongDau.
// Nominal flow (sau user feedback): mã đã gen sẵn từ CreateContract → skip.
// Fallback chỉ trigger cho HĐ legacy chưa qua backfill, hoặc HĐ tạo bằng

View File

@ -4,6 +4,7 @@ using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Notifications;
using SolutionErp.Application.PurchaseEvaluations.Services;
using SolutionErp.Domain.Common;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Notifications;
@ -34,6 +35,23 @@ public class PurchaseEvaluationWorkflowService(
if (evaluation.Phase == targetPhase)
throw new ConflictException("Phiếu đã ở phase đích.");
// ===== Smart reject + resume (Phase 9 — Migration 16) =====
var fromPhase = evaluation.Phase;
var isResumingAfterReject = decision == ApprovalDecision.Approve
&& fromPhase == PurchaseEvaluationPhase.DangSoanThao
&& evaluation.RejectedFromPhase != null;
if (decision == ApprovalDecision.Reject)
{
evaluation.RejectedFromPhase = fromPhase;
targetPhase = PurchaseEvaluationPhase.DangSoanThao;
}
else if (isResumingAfterReject)
{
targetPhase = evaluation.RejectedFromPhase!.Value;
evaluation.RejectedFromPhase = null;
}
PurchaseEvaluationPolicy policy;
if (evaluation.WorkflowDefinitionId is Guid wfId)
{
@ -53,21 +71,118 @@ public class PurchaseEvaluationWorkflowService(
var isAdmin = actorRoles.Contains(AppRoles.Admin);
var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove;
if (!isAdmin && !isSystem)
// Policy guard — bypass khi resume sau reject (target đã pinned).
if (!isAdmin && !isSystem && !isResumingAfterReject)
{
if (!policy.Transitions.TryGetValue((evaluation.Phase, targetPhase), out var allowedRoles))
if (!policy.Transitions.TryGetValue((fromPhase, targetPhase), out var allowedRoles))
throw new ForbiddenException(
$"Policy '{policy.Name}' không cho phép {evaluation.Phase} → {targetPhase}.");
$"Policy '{policy.Name}' không cho phép {fromPhase} → {targetPhase}.");
if (!policy.IsTransitionAllowed(evaluation.Phase, targetPhase, actorRoles, actorUserId))
if (!policy.IsTransitionAllowed(fromPhase, targetPhase, actorRoles, actorUserId))
{
throw new ForbiddenException(
$"Role ({string.Join(",", actorRoles)}) không đủ quyền chuyển {evaluation.Phase} → {targetPhase}. " +
$"Role ({string.Join(",", actorRoles)}) không đủ quyền chuyển {fromPhase} → {targetPhase}. " +
$"Policy '{policy.Name}' yêu cầu: {string.Join(",", allowedRoles)}.");
}
}
var fromPhase = evaluation.Phase;
// ===== 2-stage department approval (Phase 9 — Migration 16) =====
// Bug fix anh Kiệt: NV duyệt được hết phase. Logic mới:
// - User.DepartmentId != null + KHÔNG admin/system + KHÔNG resume:
// - DeptManager (TPB) → Stage=Confirm trực tiếp
// - CanBypassReview=true → Stage=Confirm + IsBypassed=true
// - Else (NV) → Stage=Review only, BLOCK phase transition cho đến khi TPB confirm
// - Skip với reject + resume + admin + system + actor không thuộc dept.
if (decision == ApprovalDecision.Approve
&& targetPhase != PurchaseEvaluationPhase.DangSoanThao
&& targetPhase != PurchaseEvaluationPhase.TuChoi
&& !isResumingAfterReject
&& !isAdmin && !isSystem
&& actorUserId is Guid actorUid)
{
var actor = await userManager.FindByIdAsync(actorUid.ToString());
if (actor?.DepartmentId is Guid deptId)
{
var isManager = actorRoles.Contains(AppRoles.DeptManager);
var canBypass = actor.CanBypassReview;
var stage = (isManager || canBypass) ? ApprovalStage.Confirm : ApprovalStage.Review;
var isBypassed = !isManager && canBypass;
var roleSnapshot = isManager ? "TPB" : (canBypass ? "NV(bypass)" : "NV");
// Upsert: 1 row mỗi (PEId, phase, dept, stage). UNIQUE index enforce.
var existing = await db.PurchaseEvaluationDepartmentApprovals
.FirstOrDefaultAsync(a =>
a.PurchaseEvaluationId == evaluation.Id
&& a.PhaseAtApproval == (int)fromPhase
&& a.DepartmentId == deptId
&& a.Stage == stage, ct);
if (existing is null)
{
db.PurchaseEvaluationDepartmentApprovals.Add(new PurchaseEvaluationDepartmentApproval
{
PurchaseEvaluationId = evaluation.Id,
PhaseAtApproval = (int)fromPhase,
DepartmentId = deptId,
Stage = stage,
ApproverUserId = actorUid,
ApproverRoleSnapshot = roleSnapshot,
Comment = comment,
ApprovedAt = dateTime.UtcNow,
IsBypassed = isBypassed,
});
}
else
{
existing.ApproverUserId = actorUid;
existing.ApproverRoleSnapshot = roleSnapshot;
existing.Comment = comment;
existing.ApprovedAt = dateTime.UtcNow;
existing.IsBypassed = isBypassed;
}
// Check Stage=Confirm tồn tại cho (PEId, fromPhase, deptId)
var hasConfirm = stage == ApprovalStage.Confirm
|| await db.PurchaseEvaluationDepartmentApprovals.AnyAsync(a =>
a.PurchaseEvaluationId == evaluation.Id
&& a.PhaseAtApproval == (int)fromPhase
&& a.DepartmentId == deptId
&& a.Stage == ApprovalStage.Confirm, ct);
if (!hasConfirm)
{
// NV review xong, chưa có TPB confirm → BLOCK phase transition.
// Log Approval + Changelog "đã review" để audit. Phase giữ nguyên.
db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval
{
PurchaseEvaluationId = evaluation.Id,
FromPhase = fromPhase,
ToPhase = fromPhase, // không đổi phase
ApproverUserId = actorUid,
Decision = ApprovalDecision.Approve,
Comment = $"[Review NV] {comment ?? ""}",
ApprovedAt = dateTime.UtcNow,
});
string? reviewerName = (actor.FullName ?? actor.Email);
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
PurchaseEvaluationId = evaluation.Id,
EntityType = PurchaseEvaluationEntityType.Workflow,
Action = ChangelogAction.Transition,
PhaseAtChange = fromPhase,
UserId = actorUid,
UserName = reviewerName ?? "Hệ thống",
Summary = $"{reviewerName} (NV) đã review phase {fromPhase}, chờ TPB confirm",
ContextNote = comment,
});
// TODO Chunk E: notify TPB cùng dept để confirm.
await db.SaveChangesAsync(ct);
return;
}
}
}
evaluation.SlaWarningSent = false;
evaluation.Phase = targetPhase;