Compare commits
6 Commits
2abbc1d867
...
a532ba6fc3
| Author | SHA1 | Date | |
|---|---|---|---|
| a532ba6fc3 | |||
| 9747f8cbf5 | |||
| 14f3c9f817 | |||
| 5c200978cb | |||
| dfb43fcbc6 | |||
| 2675a3a674 |
84
docs/_templates/session-prompts.md
vendored
Normal file
84
docs/_templates/session-prompts.md
vendored
Normal 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}` và `{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.
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
13
src/Backend/SolutionErp.Domain/Common/ApprovalStage.cs
Normal file
13
src/Backend/SolutionErp.Domain/Common/ApprovalStage.cs
Normal 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,
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
@ -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)
|
||||
{
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user