Compare commits
4 Commits
540098347d
...
48a99e14e7
| Author | SHA1 | Date | |
|---|---|---|---|
| 48a99e14e7 | |||
| 0605f19f57 | |||
| b3444a3448 | |||
| 1bc6b708fc |
@ -174,6 +174,8 @@ Bug latency observed when miss points 9-10: 2-3 days prod silent (Mig 28-29 depl
|
||||
|
||||
## 📅 Recent runs (FIFO — slim post-curate 2026-05-22)
|
||||
|
||||
- **2026-05-26 (S33 startup health-check — em main spawn read-only verify, VERDICT=HEALTHY):** Snapshot post-S32 wrap. 0 unpushed (HEAD=`5400983`). **Last 5 Runs all SUCCESS** via Gitea API unauth (token empty OK): #235 `1e1c9a2` 3m38s + #234 `b223466` 3m52s today (S31 RAG docs+eval/*.json — triggered because `eval/**` NOT in paths-ignore current filter) + #233 `e199603` + #232 `38f1c4d` + #231 `3e92584` Plan B Contract V2 deploy chain 2026-05-22 ~3m30s avg. S32 commits `b832f43..5400983` (4 docs+memory commits) CORRECTLY SKIPPED per gotcha #41 — all match `**/*.md`. **3 prod endpoint smoke ALL 200 OK** (api/health/live 0.23s + admin 0.29s + eoffice 0.31s; `/healthz` 404 N/A — `/health/live` canonical). **Mig prod TOP 5 DESC** sqlcmd Windows-auth via `ssh vietreport-vps "powershell ... '.\\SQLEXPRESS' -E"` pattern (UPDATED from Discovery #5 — proper powershell wrapper instead of 4-backslash escape): `AddContractLevelOpinions` (Mig 33 Plan B) → `AddApprovalWorkflowToContract` (Mig 32) → `RefactorSkipToFinalToApproverLevel` (Mig 31) → `AddAllowApproverEditBudgetToLevels` (Mig 30) → `RefactorAdvancedOptionsToPerLevelAndDrafterUser` (Mig 29). Mig 33 head MATCHES Run #232 deploy baseline, NO drift. **Note:** `SeedSampleContractWorkflowV2` Plan B Hotfix CICD = code seed (DbInitializer), NOT migration — won't appear in `__EFMigrationsHistory`. **Cert api.solutions.com.vn notAfter `Jul 23 01:58:16 2026 GMT`** (matches HANDOFF expected ~2026-07-23, ~58 days lead, auto-renew ~2026-06-23 win-acme 30d window). **Bundle hash snapshot post-Run #235:** fe-admin=`index-BUTKoqRP.js`, fe-user=`index-CMHv2GS4.js` — baseline for future deploy compare. **DISCOVERY #7 NEW:** path filter `paths-ignore` MISSING `eval/**` → S31 RAG eval JSON commits triggered ~3m30s deploy wastefully (no code change). Consider em main weigh adding `'eval/**'` to filter if RAG telemetry commit frequency growing. **Pending S33 deploy triggers:** (i) Phase 10.1 G-H1 Hồ sơ NS first push — Mig 34 verify; (ii) Plan B-Wrap BW1-BW7 first commit — test gate baseline 111 → 118+ delta; (iii) Phase 9 UAT smoke V2 contract end-to-end. Token cost ~12K (Read MEMORY + 8 Bash curl/ssh/git/python parse).
|
||||
|
||||
- **2026-05-26 (S32 wrap — em main proxy curate + Phase 9 stabilize done + Phase 10 deploy ahead):** Session 32 đóng clean. Em chủ trì spawn em 1 lần S32 startup verify (a505a02d84fc1fabe alive, MEMORY 27→24.2KB post curate Run #231 PARTIAL detail archived q2). **NO Run triggered S32** — 5 commits S32 cumulative tất cả docs-only CI skip per #41 (b832f43 Phase 10 backlog + cce0963 stabilize batch + abcc1ed STATUS+HANDOFF wrap + 2 earlier S31). 0 prod deploy event. 3 endpoint smoke (api/admin/eoffice) still 200 OK post-S29 deploy. cert api.solutions.com.vn notAfter `2026-07-23` (verified via openssl s_client) — auto-renew ~2026-06-23, NOT urgent. **Plan G 11 module backlog DOCUMENTED migration-todos** + Plan B-Wrap test bundle BW1-BW7 spec ready. **Pending S33 deploy triggers em main spawn em:** (a) **Phase 10.1 G-H1 Hồ sơ NS Run verify** — first code commit Phase 10 Mig 34 + entity scaffold + CQRS + FE 2 app → poll Run + bundle hash rotate ×2 verify + Mig 34 applied prod sqlcmd verify + 5-stage checklist Stage 4 smoke endpoint (POST /api/employees create + GET /api/employees list). (b) **Plan B-Wrap test bundle post-push verify** — first commit BW1-BW7 codegen → 5-stage Stage 3 test gate baseline 111 → ~118+ test count delta report (UAT mode exception suspended cho Plan B-Wrap explicit test-after). (c) **Phase 9 UAT smoke V2 contract sample workflow create flow** — sqlcmd verify QT-HD-V2-001 ApplicableType=3 exists prod + Drafter create V2 contract end-to-end → CCM binh.le approve → terminal Phase=DaPhatHanh + gen mã HĐ + ContractLevelOpinions UPSERT. **Foundation entries CONFIRMED preserved:** 10-surface-point per-NV checklist + gotcha #48 SQLite tie-break + Discovery #6 INFRASTRUCTURE vs DEMO seed Stage 4.6. Token cost wrap ~3K. Tag: `[wrap, phase-9-to-phase-10, cicd]`.
|
||||
|
||||
- **2026-05-26 (S32 startup verify — no CI poll, only foundation freshness + 3 endpoint smoke health):** NO Run triggered S30-S32 (last code deploy Run #232 sha=`38f1c4d` 2026-05-22 ~3 days ago). Last push `f938bf5` S31 docs patch cicd-monitor.md stale numbers (test/mig refresh) — docs-only → skip CI per gotcha #41 path filter (expected). 0 unpushed `git log origin/main..HEAD`. **Verify state:** (a) MEMORY size **24.9KB / 221 lines** approaching 25KB threshold — recommend curate next session OR slim Run #231 verbose entry (lines 179-183 = ~5KB alone) since superseded by Run #232 wrap entry above. (b) MCP RAG tools PRESENT — `search_memory` returns 3 results query "Run 232 Plan B Hotfix CICD SeedSampleContractWorkflowV2" rerank_score=**0.906** top (MEMORY.md self-hit) + 0.828 (gotcha #51 docs) + 0.816 (HANDOFF.md S29 final wrap) — RAG indexing healthy 2949 chunks. (c) Foundation entries CONFIRMED retained: 10-surface-point per-NV checklist (lines 142-160) + gotcha #48 SQLite tie-break (lines 56-61) + gotcha #51 NEW INFRASTRUCTURE vs DEMO seed (verified via RAG hit on `docs/gotchas.md` heading "51. INFRASTRUCTURE seed vs DEMO seed phân biệt — DemoSeed:Disabled flag gate trap"). (d) 3 prod endpoint smoke **all 200 OK** (api.solutions.com.vn/health/live + admin.solutions.com.vn + eoffice.solutions.com.vn) — prod stable post-S29 deploy. **Pending future spawn triggers (em main SendMessage):** (i) push code BE/FE/Mig commits Plan B-Wrap test bundle BW1-BW7 → poll CI + verify Run PASS + bundle hash rotate if FE in scope; (ii) Phase 9 UAT smoke production batch (3 endpoint health + Mig 33 sqlcmd verify + bundle hash check) — periodic heavy session ~30 min interval; (iii) any prod issue report ("không thấy V2", "Drafter dropdown empty" etc — gotcha #51 first suspect). Token cost spawn ~10K (no poll, no log fetch, no sqlcmd — only Read + Bash curl × 3 + RAG × 1).
|
||||
|
||||
@ -129,6 +129,10 @@ State machine 5 trạng thái phiếu PE: Nháp / Đã gửi duyệt / **Trả l
|
||||
|
||||
## 📅 Recent activity (last 10 FIFO)
|
||||
|
||||
- **2026-05-26 (S33 t1 — Plan G-H1 pre-flight NamGroup TblNhanVien* audit):** Em spawn task A+B+C audit `NAMGROUP.Server\Data\Entities\` ~10K token. **Inventory 10 TblNhanVien* bảng** (NOT 8 anh main estimate): 1 main `TblNhanVien` (105 cols!) + 9 satellite (QtCongTac/QtDaoTao/QuanHeThanNhan/KyNangViTinh/KyNangNgoaiNgu/KyNangKhac/QtHopDong/QtCongTacIn/QtPhulucHd). PK `long Id` (NOT Guid) — port phải convert. Soft delete `IsDelete int?` OR `bool?` (legacy inconsistent). Audit fields KHÔNG có ở satellite (chỉ `QtCongTacIn` có NhanVienTaoId/SuaId/NgayTao/NgaySua) — port phải fill từ BaseEntity. **Main `TblNhanVien` 105 cols PERSONAL HEAVY:** identity (CMND/HoChieu/MaSoThue/SoBhxh) + diachi (6 FK Tinh/Quan/Phuong x HKTT/TamTru + freetext fallback) + bank (SoTK/NganHang/ChiNhanh) + physical (ChieuCao/CanNang/NhomMau) + salary (LuongTN/LuongCB/PhepTrongNam/PhepTon/NghiBu/PhepThamNien) + BHXH (NgayThamGia/NoiDkkCb) + political (IsDangVien/IsHcmDoan/IsCongDoan + 3 Ngay*) + theme cols (BgmenuColor/MenuColor — SKIP UX-only) + 4 contact ng liên hệ + 14 FK catalog (DanToc/TonGiao/TinhTrangHonNhan/QuocTich/GioiTinh/TrinhDo/...). **Drift discovered s49 Plan C** 8 FK DiaChi cascade ADDED Sep 2025 but `ZERO populated 1675 NV` — entity drift expose. **Tip:** SOL Mig 34 nên design FK DiaChi NULLABLE + freetext fallback nvarchar(500) cùng tồn tại từ ngày đầu. **Satellite simple:** QtCongTac (12 cols work history external), QtDaoTao (16 cols + 4 FK catalog), QuanHeThanNhan (8 cols + FK), KyNangViTinh (3 cols MINIMAL — chỉ TenPhanMem string!), KyNangNgoaiNgu (4 cols + FK NgoaiNgu), KyNangKhac (4 cols freetext). **Satellite contract HEAVY:** QtHopDong (28 cols HĐLĐ — defer Plan H2), QtCongTacIn (16 cols internal position change — defer), QtPhulucHd (10 cols phụ lục HĐ — defer). **DbInitializer GLOB NO MATCH** — NamGroup KHÔNG seed demo data via DbInitializer pattern. **SOL User existing đã có:** FullName + DepartmentId + Position + PositionLevel + Email + IsActive. Skip 5 duplicate fields. **Patterns proven NEW:** (a) **Skip-list aggressive cookie-cutter audit** — Mig 34 chỉ port 5 satellite defer 4. (b) **FK + freetext fallback dual-write pattern** từ Plan C drift lesson. (c) **MaNhanVien `NV/YYYY/NNNN` mirror PE CodeGen** atomic Serializable. (d) **Polymorphic Skill table** — gộp 3 KyNang* thành 1 với Kind enum giảm 2 bảng. (e) **30 demo seed pattern reuse** mirror SOL DbInitializer existing 30 user 1-1 link User.Id. **Surprise:** Theme cols `BgmenuColor/MenuColor` ở entity hồ sơ NS! NamGroup mix UX preference với business data — SOL SKIP, dùng UserPreferences riêng. Token cost ~10K.
|
||||
|
||||
- **2026-05-26 (S33 startup — em main spawn em đầu S33 audit 4 sub-agent MEMORY + RAG hit rate verify):** Em spawn 1 lần đầu S33 task A+B song song readonly. **Task A audit 4 MEMORY file sizes:** Investigator 20.2KB OK / CICD 25.5KB borderline > 25KB triggers curate flag / Reviewer 25.9KB borderline / Implementer 28.6KB OVER triggers curate priority. **Cross-agent learnings ≥ 2 agent:** (a) **Smart Friend pattern 4× cumulative** noted BOTH Reviewer (foundation 4 catches S22 #44 + S25 #48 + S29 Plan B ApplicableType + S29 DemoSeed gate) + CICD (Discovery #6 INFRASTRUCTURE vs DEMO seed catch Run #232 Hotfix `SeedSampleContractWorkflowV2` out of DemoSeed gate) — pattern strong candidate Layer B promote OR add to `docs/rules.md` §10 review process. (b) **Per-NV admin opt-in flag wire 10-surface-point** noted BOTH Implementer Pattern 7 (4 flag cumulative Mig 29-31 + Mig 30 F4) + CICD MEMORY 10-point checklist foundation (line 142-160) + Reviewer Per-NV scope split + RAG memory user-level cross-ref. (c) **Pattern 12-bis cross-module entity cookie-cutter mirror** PE→Contract noted BOTH Implementer (line 178-200) + Reviewer Cross-module security validation mirror (S29 Plan B). (d) **gotcha #48 SQLite tie-break** noted BOTH CICD (line 40-45 detail) + Reviewer (Cat 5 lesson catch from CICD pre-deploy). **Drift detected:** (1) CICD MEMORY line 124-125 cite "Mig 31 latest" but **actual repo Mig 33 prod (Plan B Contract V2 Mig 32+33)** per Investigator narrative S29 + RAG hit STATUS. CICD recent activity correctly mentions Mig 32+33 deployed Run #232 but baseline header line 124-125 stale. (2) Reviewer line 130 cite "Migrations: 31 latest" but actual 33 — same baseline header stale post-S29 Plan B. (3) Test baseline 111 unchanged ALL 4 agents consistent OK. (4) Endpoints ~146 (Reviewer line 132) vs ~148 (Investigator line 137) — minor 2 endpoint delta drift. **Curate recommend next session:** PRIORITY Implementer (28.6KB > 25KB hard, archive 2-3 verbose entries q2) + CICD (25.5KB borderline, drop 1 oldest entry 2026-05-12 setup since baseline preserved foundation section) + Reviewer (25.9KB borderline, drop 1 oldest S25 entry duplicated in S25 wrap below). Investigator self OK 20.2KB headroom. **Task B RAG verify 3/3 PASS** all rerank > 0.7: Q1 `Plan G NamGroup port phase 10` top hit HANDOFF.md rerank **0.848** PASS · Q2 `gotcha 52 qdrant client search removed` top hit investigator MEMORY S32 startup entry rerank **0.910** PASS (also gotcha doc 0.875 + memory user-level `feedback_rag_bootstrap` 0.863) · Q3 `per-NV admin opt-in F1 F2 F3 F4 wire 10 surface points` top hit HANDOFF.md S23 t4 rerank **0.684** BELOW 0.7 threshold WARN (semantic match correct, distance score boundary — RAG indexing for narrative cumulative entries reaches diminishing returns at 5+ session reinforcement). **Recommendation forward em main S33:** (a) Spawn Investigator pre-flight Plan G-H1 NamGroup TblNhanVien* 8 bảng audit per Phase 10.1 kick off plan; (b) Schedule dedicated curate session 3 sub-agents (Implementer priority) before next heavy plan kick off; (c) Update Reviewer + CICD baseline header Mig 31→33 + endpoint ~146→148 in next curate. Token cost spawn này ~10K (4 Read + 3 RAG query + 1 Bash + Edit MEMORY + final report).
|
||||
|
||||
- **2026-05-26 (S32 wrap — em main proxy update final state):** Session 32 đóng clean. Em chủ trì spawn em 2 lần S32: (1) startup verify ~10K (afaf6d52a6a59a844 alive) + (2) NamGroup audit pre-flight ~30K (a533c3e8ed4e03bfe hit limit 157K/72 tool_uses fallback em main solo audit). **Em main solo audit NamGroup directly** thay vì re-spawn: discovered NAMGROUP.Server\Data\Entities có 8+ TblNhanVien* (QtCongTac/QtDaoTao/QuanHeThanNhan/KyNangViTinh) + 4 bảng org chart (SoDoToChuc/SoDoKhoi/ChucDanh/ViTri) + Announcement/InternalDocument/TblMenu. **Plan G 11 module port DOCUMENTED migration-todos** với 4 quyết định chốt anh main (FULL 11 module + dbo single + Workflow V2 enum +5 + chunk per-module Plan riêng). **S33 priority spawn em pre-flight Plan G-H1 Hồ sơ NS** audit NamGroup TblNhanVien* 8 bảng + map fields → SOL EmployeeProfile schema (Mig 34 design 1 main + 5 satellite WorkHistory/Education/FamilyRelation/Skill/Document) — scope narrower 30 phút mục tiêu. **Curate Plan A3:** MEMORY archived 4 verbose entries q2 (S25 t1 5Q audit Bug Changelog + S26 Plan AG 5Q + S26 Plan AI RAG research + Plan B Contract V2 Q1-Q5 audit detail) → 27.7→19KB. **Pending tasks anh main S33 SendMessage gọi em:** (a) Plan G-H1 pre-flight NamGroup TblNhanVien* audit (PRIORITY HIGH first thing S33); (b) Plan B-Wrap test bundle pre-flight verify ContractWorkflowService ApproveV2Async test scenarios spec D-Bis trong migration-todos; (c) Phase 9 UAT smoke verify V2 contract create flow post-S29 deploy `QT-HD-V2-001` workflow. Token cost wrap ~5K. Tag: `[wrap, phase-9-to-phase-10, infra]`.
|
||||
|
||||
- **2026-05-26 (S32 startup — em main proxy verify context + S31 RAG v1.3 baseline awareness):** S31 đóng ~1.5h ngày 2026-05-26 với RAG v1.3 baseline PASS recall@5=1.000 (11/11 queries) + avg_rerank=0.847. **Root cause S31 fix:** `AI_INFRA/claude-rag/lib/retrieval.py` xài API cũ `qdrant.search()` đã bị qdrant-client 1.18 xóa → đổi sang `query_points().points` API mới. **Gotcha #52 NEW** add vào `docs/gotchas.md` (Qdrant search removed). **Downstream impact:** MCP tools `mcp__rag-unified__search_memory` + `mcp__rag-unified__cross_project_search` live PASS post-CLI restart — test query "gotcha 52 qdrant search removed" top_k=3 trả 3 chunks rerank scores 0.515 / 0.479 / 0.461 (all dưới threshold 0.7 cao mong đợi vì query về symbol `qdrant.search` chỉ match doc rag-setup-plan.md historical, KHÔNG match gotcha #52 doc — doc chưa indexed lần re-index gần nhất hoặc chưa ingest gotchas folder). **Memory size 25.8 KB ĐÃ VƯỢT curate trigger 25KB** — em append entry này + flag em main curate cuối S32 (archive S25 + S26 verbose entries → `archive/2026-05-q2.md`). **Pending tasks anh có thể spawn em qua SendMessage:** (a) Plan B-Wrap test bundle BW1-BW7 (regression ApproveV2Async Contract V2 + ApplicableType=3 validate), (b) Phase 9 UAT audit Contract V2 wire prod usage (V1 7 contract + V2 sample `QT-HD-V2-001` smoke verify), (c) gotcha #52 doc verify trong RAG index (cross-check `docs/gotchas.md` đã re-ingest sau S31 fix chưa). **State delta S29 → S32:** 30 mig → 33 mig (Plan B Contract V2 Mig 32+33 + Plan CA role CatalogManager) · 59 → 60 tables · ~146 → ~148 endpoints · gotcha 47/48 → 52 (+4 #49 Plan B + #50 Plan CA INFRASTRUCTURE seed gate + #51 + #52 Qdrant) · 111 test PASS UNCHANGED (UAT defer test-after) · 23 → 25 memory user-level. Token cost spawn này ~10K.
|
||||
|
||||
@ -154,18 +154,21 @@ Flag commit nếu thấy `<PackageReference Include="MediatR" Version="14...` ho
|
||||
|
||||
## 📅 Recent activity (last 10 FIFO)
|
||||
|
||||
- **2026-05-26 (S33 Plan C B-Wrap test bundle pre-commit — PASS, INDEPENDENT VERIFY 9/9 tests):** Em main spawn em adversarial Plan C B-Wrap Contract V2 test bundle review. 4 file mới: TestCurrentUser (31 LOC stub), ContractWorkflowServiceApproveV2Tests (BW1-4+7, 5 [Fact]), CreateContractCommandApplicableTypeTests (BW5, 1 [Fact]), ContractV2SchemaPersistenceTests (BW6 split 3 [Fact]). Total 9 [Fact]. **Independent verify ran `dotnet test --filter` → 9/9 PASS local trong 4.7s** (em main claim 120/120 baseline tăng từ 111 — verified). Spec mapping verify Cat 1: BW1 ContextNote `"Hoàn tất Cấp 1, sang Cấp 2 cùng Bước 1"` ✓ match service line 360, BW2 mã HĐ `"FLOCK01/HĐTP/SOL&BTBM/01"` ✓ match ContractCodeGenerator HĐTP format line 21, BW3 ContextNote `"Approver skip thẳng tới Bước 3 Cấp 2"` ✓ match service line 348 (lastStepIdx=2, lastLevelMaxOrder=2, prefix `[Approver skip thẳng tới Bước 3 Cấp 2 (NV cuối) — bỏ qua các Bước/Cấp trung gian]`), BW4 ForbiddenException `"Bước 1...Cấp 1: bạn không có"` ✓ match service line 263-264, BW5 ConflictException `"ApplicableType=Contract"` ✓ match handler line 84-85 (test correctly handles spec→actual exception type discrepancy: spec says ValidationException, actual ConflictException — em main inline comment line 84 docs the discrepancy), BW7 ConflictException `"skipToFinal chỉ hỗ trợ HĐ V2"` ✓ match service line 105-106. Schema verify Cat 2: Mig `AddContractLevelOpinions` (timestamp 20260522052240) actual position 33 by filename sort confirmed via Glob (33 mig + 33 designer + 1 snapshot in /Persistence/Migrations folder), spec ref "Mig 33" matches. UNIQUE composite `(ContractId, ApprovalWorkflowLevelId)` confirmed `ContractLevelOpinionConfiguration.cs:34`. FK Cascade Contract + FK Restrict Level confirmed line 27+32 + migration FK line 41+47. Test quality Cat 5: assertions specific (`Should().Be(2)` for levelOrder, `Should().Contain(...)` for substring match, `Should().ThrowAsync<X>()` with WithMessage wildcard), each [Fact] sets up fresh `IdentityFixture` + `using` disposal pattern (BW1-4+7 verbose using-block, BW6 `using var fix` shorthand — minor style inconsistency but functional same). Smart Friend independence note: **5 lần cumulative Smart Friend catches — em main + Implementer làm TỐT lần này, KHÔNG có catch MAJOR**. (1) S22 #44, (2) S25 #48, (3) S29 ApplicableType, (4) S29 DemoSeed, (5) S33 BW = clean. Implementer + em main spec mapping accurate, exact string match between assertions and service strings. **Minor (3, defer):** (a) `CreateService` helper method `ContractWorkflowServiceApproveV2Tests.cs:27-44` unused dead code — 5 [Fact] manually recreate inline (cleaner but DRY violation). Cleanup recommend Plan C-Hotfix or next test bundle. (b) `TestCurrentUser.Roles` constructor params `string[] roles` allows null → defensive `roles ?? Array.Empty<string>()` line 27 OK but C# warning shadow. (c) BW6 split 3 [Fact] cleanly separated DUPLICATE + UPSERT + Cascade per spec — 9 tests OK not over-engineer (each invariant tested isolated). **Defer noted**: ApproveV2Async still ~150 LOC, BW1-4+7 cover happy + terminal + skip + outsider + V1 fallback — Plan B-Wrap roadmap mentions BW Bonus future test: OR-of-N multi-NV (3 NV cùng Cấp 1, only 1 needs approve), idempotent UPSERT (Cấp 1 approve → reject → approve lại same row Comment update), Mig 32 seed idempotent guard. Not blocker for current bundle commit. Token cost spawn ~22K. Verdict: **PASS proceed commit** (9 [Fact] all pass independent verify, spec match service exact, schema 3 invariant tested, 0 critical/major, 3 minor cosmetic). Recommendation: commit 4 file as proposed, baseline tăng 111→120. Tag: `[adversarial-pass, test-bundle, contract-v2, smart-friend-5x-clean]`.
|
||||
|
||||
- **2026-05-26 (S33 startup — drift audit readonly, NONE actual review):** Em main spawn em S33 drift assessment 3 area (gotchas / CLAUDE.md root+docs / 4 sub-agent .md). **Verdict overall: MODERATE drift accumulated S19→S32 chưa patch** — late but not severe. Findings: (a) `docs/gotchas.md` actual count = **52 entries** (grep `^### \d+\.` confirm), claim S32 = 52 → MATCH NONE drift. #50/#51/#52 detail entries present line 883/839/924 (out-of-order numbering but content full). (b) CLAUDE.md (root) **SEVERE stale**: line 87 "Hiện có 26 migration → 59 bảng" (actual 33 mig + 60 table per S32 wrap), line 66+87 "81 test pass" (actual 111), line 133 "26 bẫy đã gặp" (actual 52). docs/CLAUDE.md line 65 "38 pitfall" stale similar. (c) 4 sub-agent .md drift mixed: cicd-monitor.md line 232-233 already patched S29 "111/111 PASS (58 Domain + 53 Infra)" + "Migrations: 33" CORRECT no drift; investigator.md line 77 "44 gotchas hiện tại" STALE +8; reviewer.md line 92 "44 active gotchas" STALE +8; implementer.md line 123+141 "baseline 81 preserve" STALE +30. **Critical drift requiring immediate patch**: CLAUDE.md root 3 stale fact (mig 26→33, test 81→111, gotcha 26→52) — agent context first-load file, drift mislead future spawns. **Optional defer 2026-06-01 audit**: 3 sub-agent .md gotcha+test count stale (cosmetic, no functional impact). Migrations folder discovery: actual path `src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/` not `src/Backend/SolutionErp.Infrastructure/Migrations/` — `cicd-monitor.md` line 149 path hint correct, but agents/runbooks may have stale absolute path. Token cost ~7K. Tag: `[startup-audit, drift-moderate, claude-md-severe]`. Recommendation: bro decide patch CLAUDE.md ngay (3 line edit) hoặc defer cycle audit ngày 2026-06-01.
|
||||
|
||||
- **2026-05-26 (S32 wrap — em main proxy update + Plan B-Wrap + Phase 10 pre-commit scope ahead):** Session 32 đóng clean. Em chủ trì spawn em 1 lần S32 startup verify (a0aa13093d14f3bca alive, MEMORY 24.39KB self-curated S32 dropped S27 retrospective). Smart Friend 4× cumulative preserved (S22 #44 + S25 #48 + S29 ×2 ApplicableType + DemoSeed gate). **Plan G 11 module backlog DOCUMENTED migration-todos** + Plan B-Wrap test bundle BW1-BW7 spec ready (D-Bis section). **Pending tasks em main S33 SendMessage gọi em adversarial pre-commit:** (a) **Plan B-Wrap test bundle review** — verify 7 test scenario coverage (BW1 happy path advance, BW2 terminal gen mã HĐ, BW3 skipToFinal F2 admin opt-in, BW4 ForbiddenException outsider, BW5 ApplicableType=Contract validation Cat 3 cross-module mirror, BW6 Mig 32+33 schema persistence UNIQUE composite, BW7 V1 fallback ConflictException). Smart Friend mindset: catch test scenario gap (e.g., NV skipToFinal=true but currentStepIndex already at final = silent no-op? verify guard line 337-352 ContractWorkflowService). (b) **Plan G-H1 Hồ sơ NS pre-commit review** — Mig 34 schema (1 main + 5 satellite) FK strategy + nullable validation + soft-delete pattern verify mirror PE AuditableEntity inheritance. (c) **Phase 9 UAT audit hard blocker checklist** — SMTP config Production secrets exposed? Rotate creds cycle plan token leak risk? cert expire 2026-07-23 auto-renew verify schedule task `Get-ScheduledTask -TaskName 'win-acme*'`. Token cost wrap ~3K. Tag: `[wrap, phase-9-to-phase-10, security+infra]`.
|
||||
|
||||
- **2026-05-26 (S32 startup verify — adversarial mindset ready, 0 actual review):** Em main spawn em standby cho S32. Self-verify context: MEMORY 22.50KB (23042 bytes — approaching 25KB threshold, chưa curate cần nhưng cảnh báo entry mới sẽ trigger soon), last entry 2026-05-22 13:28 S29 wrap khớp. MCP RAG `search_memory` + `cross_project_search` PRESENT cả 2 — test query "ApplicableType validation" trả rerank 0.867 (high precision match Cross-module security entry line 41-49). **Awareness S31 fixes (between sessions, em không spawn):** RAG v1.3 baseline PASS recall@5=1.000 (11/11) + retrieval.py fix, gotcha #52 NEW added — KHÔNG impact Reviewer adversarial logic (infra ops fix, không phải application code). **Awareness S29 deployed prod**: Plan CA + Plan B Contract V2 wire push successful, gotcha #51 NEW added (INFRASTRUCTURE vs DEMO seed gate — `SeedSampleContractWorkflowV2` OUT of `if (settings.DemoSeed)` gate là correct pattern infrastructure data luôn seed regardless of demo mode). **Smart Friend pattern 4× cumulative VERIFIED preserved**: (1) S22 #44 silent 403, (2) S25 #48 SQLite tie-break, (3) S29 Plan CA password ≥12 chars, (4) S29 Plan B ApplicableType cross-module. **Adversarial mindset retained pre-commit gate active**: forward cho 3 pending task em main có thể spawn em qua SendMessage — (a) Plan B-Wrap BW1-BW7 test bundle review (ApproveV2Async coverage ~150 LOC 0 unit test gap + ApplicableType validate regression test — gotcha #48 lesson SQLite tie-break apply when add Changelog rows in test setup, cần discriminator EntityType + Summary keyword); (b) ContractWorkflowMatrixView review pre-commit khi Implementer wire xong (anticipate Mirror §3.9 fe-admin + fe-user 2 file sync check, V1/V2 dual schema branch verify, permission menuKey populate sync BE+FE); (c) Phase 9 UAT hard blocker audit (SMTP outbox table + sender flow / rotate creds 5 item / SQL backup schedule daily 03:00 / win-acme cert renewal 3 cert 60d). **Token cost spawn standby ~6K**. Patterns NEW noted reinforce S32: pre-spawn checklist verify MEMORY size + freshness + MCP tools first-call before any review action (standard hygiene).
|
||||
|
||||
- **2026-05-22 (S29 wrap — Plan CA Reviewer 2 spawn MAJOR password fix + Plan B Reviewer 2 spawn MAJOR ApplicableType fix — Smart Friend 4× cumulative):** Em main wrap S29 sau 2 big plans Plan CA (Move Cấu hình danh mục admin→eoffice, 7 commits) + Plan B (Contract V2 wire mirror PE Mig 22-26, 11 commits). **Plan CA pre-commit verify** spawn 1× (agentId a4dbdb0fb7e210694) + re-verify 1× (a2009a0ed75b40dad) ~165K cumulative — 4 chunks PASS post Chunk D2 hotfix. **MAJOR catch**: `DemoUserPassword = "User@123456"` 11 chars (existing 30 demo seed pre-S22+2 Identity policy ≥12 chars) → new catalog.manager seed CreateAsync FAIL prod. Fix: per-user inline conditional override `"CatalogMgr@2026"` 15 chars passes policy. Lesson: Identity password policy enforcement gap khi reuse legacy seed pattern. **Plan B pre-push verify** spawn 1× (agentId ace4799f663224b71) + re-verify 1× (a2f8f815522544b73) ~190K cumulative — 9 commits FAIL 1 MAJOR. **MAJOR catch**: `CreateContractCommand` thiếu validation `aw.ApplicableType == ApprovalWorkflowApplicableType.Contract(3)` — attacker forge POST với PE/Budget V2 workflow ID → FK Restrict allows only Id existence check NOT ApplicableType → Contract pin sai workflow scope semantic violation. Mirror PE pattern `PurchaseEvaluationFeatures.cs:62-77` exact. Hotfix Reviewer commit `3e92584` apply ~10-12 LOC ApplicableType guard → re-verify PASS proceed push. **Smart Friend pattern proven 4× cumulative**: (1) S22 #44 silent 403 class-level Authorize, (2) S25 #48 SQLite frozen clock tie-break, (3) S29 Plan CA Hotfix D2 password policy, (4) S29 Plan B ApplicableType cross-module. **Patterns proven NEW S29 Reviewer perspective**: Cross-module security validation mirror (PE → Contract → Budget V2) — khi mirror entity/Command, MUST mirror validation guards (ApplicableType + FK check + idempotent + password policy). Easy miss vì em main solo focus on data shape NOT security. **Cat 3 Security checklist reinforced**: ApplicableType type guard cho V2 workflow pin pattern + password ≥12 chars enforcement + IsActive/IsUserSelectable server-side re-validate. **Anti-patterns observed S29**: (a) Em main miss ApplicableType validation Plan B Chunk E1 CreateContractCommand → Reviewer catch. (b) Em main miss password ≥12 chars Plan CA Chunk D → Reviewer catch. (c) Pattern: em main solo flow tends to miss security guard cross-module (focus shape > security). **Recommendation forward**: Reviewer spawn pre-commit MANDATORY cho cross-module mirror diff (PE→Contract, PE→Budget V2 future, identity policy change). Em main solo OK cho UI polish iteration (S26 Plan AG2-AG6 pattern proven). Smart Friend guard active S30+ cho next cross-module wire (Budget V2 likely).
|
||||
- **2026-05-22 (S29 wrap — Smart Friend 4× cumulative):** Plan CA (admin→eoffice 7 commits) + Plan B (Contract V2 11 commits) — 2 MAJOR catches Reviewer spawn. **CA MAJOR**: `DemoUserPassword = "User@123456"` 11 chars vs Identity policy ≥12 chars → new catalog.manager seed CreateAsync FAIL prod. Fix per-user inline conditional override `"CatalogMgr@2026"` 15 chars. **B MAJOR**: see above S29 Plan B entry. Smart Friend cumulative S22 #44 + S25 #48 + S29 CA password + S29 B ApplicableType. **Cat 3 Security checklist reinforced**: ApplicableType type guard V2 + password ≥12 chars + IsActive/IsUserSelectable re-validate. **Recommendation forward**: Reviewer spawn MANDATORY cho cross-module mirror diff (PE→Contract, PE→Budget V2 future, identity policy change). UI polish iteration em main solo OK.
|
||||
|
||||
- **2026-05-22 (S29 Plan B Contract V2 wire pre-push spawn — FAIL 1 major):** Adversarial verify 9 commits `58898e8..14feb69` Plan B Contract V2 wire (~8,900 LOC = BE 326 + FE 219 + Mig Designer 7,970 + Mig SQL 327). Spawn ~17K. **Verdict FAIL — 1 MAJOR Cat 3 security/data integrity, 0 critical, 3 minor.** Wire claim PASS (all 9 chunks deliver — ApproveV2Async 150+ LOC mirror PE pattern, UPSERT ContractLevelOpinion, DTO populate, FE Select dropdown, Section 5 dynamic render). Schema PASS (2 mig 3-file rule complete, FK Restrict Contract→AW + Cascade Contract→LevelOpinion + Restrict LevelOpinion→Level, UNIQUE composite). Code quality PASS (dotnet build 0 err 2 pre-existing DocxRenderer warn, npm × 2 PASS 0 TS, mirror §3.9 SHA256 IDENTICAL × 3 files: ContractDetailContent.tsx + ContractCreatePage.tsx + types/contracts.ts). Test PASS 111/111 baseline preserved. Authority PASS explicit mandate. **MAJOR FOUND**: `CreateContractCommandHandler` (`ContractFeatures.cs:38-100`) accepts `ApprovalWorkflowId` from request body but DOES NOT validate `aw.ApplicableType == ApprovalWorkflowApplicableType.Contract`. PE pattern at `PurchaseEvaluationFeatures.cs:62-77` explicitly validates `aw.ApplicableType == expectedType` and throws `ConflictException`. Plan B Chunk E1 omits this guard. **Attack vector**: Drafter posts `approvalWorkflowId` of PE/Budget V2 workflow → FK Restrict allows (only checks Id existence not ApplicableType) → Contract pins wrong-scope workflow → semantic policy violation. **Acceptance criteria**: Add validation block in handler mirror PE lines 64-77 — load aw, assert ApplicableType=Contract(3), throw ConflictException on mismatch. Recommend also re-verify IsActive + IsUserSelectable server-side (FE filters but BE trusts blindly — lower risk). **Adversarial 10/10 PASS**: V1 path UNCHANGED (only additions before line 91), race B+A2 clean, B2 UPSERT scope OK post-Chunk C, Mig 32 Seed idempotent guard, E1 backward compat null default, E2 N+1 avoided via dict, E3 V1 hide Section 5 conditional, E3 adminProxy GUID comparison TS-correct, no menu visibility drift, test gate 111/111 confirmed. **Recommendation**: HOLD push. Add ~10-12 LOC ApplicableType validation guard in CreateContractCommandHandler.Handle before entity instantiation. Re-run build+test. Then PROCEED push 9 commits + 1 fix commit (10th). **Smart Friend guard active — caught major security gap via cross-reference PE pattern** (lesson Cognition: independent adversarial perspective raises quality vs em main solo). **Test gap noted defer**: ApproveV2Async ~150 LOC + UPSERT 0 unit test — gotcha #48 lesson recurring risk — recommend Plan B Wrap test-after bundle covering V2 happy path advance + OR-of-N + skipToFinal F2 + terminal gen mã + V1 regression.
|
||||
- **2026-05-22 (S29 Plan B Contract V2 wire pre-push — FAIL 1 MAJOR):** 9 commits `58898e8..14feb69` ~8.9K LOC. **MAJOR FOUND**: `CreateContractCommandHandler` accepts `ApprovalWorkflowId` from body but DOES NOT validate `aw.ApplicableType == Contract` — Drafter forge POST với PE/Budget V2 workflow ID → FK Restrict allows only Id existence → Contract pins wrong-scope workflow. Mirror PE pattern `PurchaseEvaluationFeatures.cs:62-77`. Hotfix ~10-12 LOC add validation guard, recommended HOLD push until fixed. Test gap deferred: ApproveV2Async ~150 LOC 0 unit test → Plan B-Wrap test bundle (S33 BW1-BW7 cover happy + terminal + skip F2 + outsider + V1 fallback + UNIQUE + UPSERT + Cascade). Detail archive `archive/2026-05-q1.md`.
|
||||
|
||||
- **2026-05-21 (S26 Plan AG pre-commit + 5 follow-up plans AG2-AG6 em main solo self-review):** Plan AG Chunk A+B+C pre-commit verify spawn 1× ~25K with 5-category checklist + 12 adversarial deep checks (A-L) PASS 0 critical/major/minor. Commit `0bf6c7e` 2 file +346/-116 LOC mirror IDENTICAL hash `21001E90...`. Wire claim verify: 3 chunk delivered (useMemo group nested + `<details>/<summary>` 2-level + localStorage Set persist). Schema 0 mig, 0 BE, 0 entity. Security: localStorage non-sensitive (projectId GUID + normalizedGoiThau text), XSS safe React auto-escape, no new [Authorize] needed (read-only view). Code quality: npm build × 2 PASS 0 TS err, anti-fiddle 0% drift, Mirror §3.9 byte-identical 21,521 bytes. Test coverage: Phase 9 UAT exception accept. Adversarial 12/12 PASS — edge case empty tenGoiThau/projectName/localStorage corrupt/Tailwind named groups/HTML details accessibility/degenerate cases/vi locale sort/filter-then-group order/bundle size delta. **Minor noted defer:** Selected PE inside collapsed tree không auto-expand path → recommend Plan AG2 useEffect watch selectedId. Recommendation: PASS proceed push. **Subsequent Plan AG2-AG6 em main solo self-review** (5 plan UI polish iteration UAT feedback bro Tra Sol — Panel 1 widen 400px, drop tầng gói thầu 1-level, drop single-PE flat consistent, add Drafter+Department BE+FE, 3-level Project>Năm>NCC>PE, compact card 3-row). Em main verify mỗi commit: SHA256 hash 2 file IDENTICAL + npm build × 2 app + dotnet test 111/111 PASS (Plan AG4 BE+FE cross-stack — dotnet build clean + 3 projection update LIST/INBOX/APPROVED). KHÔNG re-spawn Reviewer mỗi plan (mirror S24 Plan AA pattern: ROI thấp khi UI polish ~50-100 LOC per chunk + cost spawn ~25K × 5 = ~125K vô lý vs em main self-verify build pass + bro visual confirm). **Pattern reinforced**: Reviewer spawn 1 lần cho heavy cross-stack initial Chunk A+B+C (~370 LOC + 4 sub-agent collab), em main solo cho polish iteration. Cumulative S26 6 commits `0bf6c7e..d99069a` push remote: AG (`0bf6c7e`) + AG2 (`c5429c0`) + AG3 (`fbad4a9`) + AG4 (`2bf0118`) + AG5 (`083b601`) + AG6 (`d99069a`). 0 prod regression observed, test baseline 111 preserved. Smart Friend guard still active for next session feature spawn.
|
||||
- **2026-05-21 (S26 Plan AG pre-commit + AG2-AG6 em main solo):** Plan AG Chunk A+B+C verify spawn ~25K, 12 adversarial deep check PASS 0 issue. Commit `0bf6c7e` 2 file +346/-116 LOC mirror IDENTICAL `21001E90...`. Wire: useMemo group nested + `<details>/<summary>` 2-level + localStorage Set persist. Schema 0 mig. AG2-AG6 (5 follow-up polish UAT feedback bro Tra Sol) em main solo verify (SHA256 IDENTICAL + npm build × 2 + dotnet test 111/111) — KHÔNG re-spawn Reviewer (ROI thấp UI polish 50-100 LOC). **Pattern reinforced**: Reviewer spawn cho heavy cross-stack (A+B+C ~370 LOC + 4 sub-agent collab), em main solo cho polish iteration. Cumulative S26: 6 commits, 0 prod regression, baseline 111 preserved.
|
||||
|
||||
- **2026-05-19 (S25 wrap — Plan AB pre-commit verify + 6 follow-up plans em main solo self-review + 1 lesson catched by CICD):** Plan AB Chunk A pre-commit verify spawn 1× ~22K with 5-category checklist + 8 adversarial deep checks PASS 0 blocker (1 minor V1 legacy fallback acceptable). Recommended PROCEED push cdfd542. **MISSED gotcha #48**: Multi-Changelog.Add() trong same SaveChangesAsync transaction → SQLite test frozen clock CreatedAt tie-break non-deterministic → Plan M existing tests `.OrderByDescending(CreatedAt).FirstAsync()` picked wrong entry → CI Run #215 FAIL. **Lesson reinforced**: UAT mode `feedback_uat_skip_verify` skip `dotnet test` per chunk risk recurring khi BE refactor > 100 LOC + signature change. Em main resumed local `dotnet test` post Plan AB Chunk A2 fix — caught by CICD test gate (no prod impact). **Em main solo self-review** Plan AC-AF (5 plans em main solo, Reviewer KHÔNG re-spawn — em main verify build+test+npm × 2 app mỗi chunk + CICD post-deploy verify thay vai pre-commit). Cumulative S25: 0 prod regression, 6 commits PASS CICD. **Pattern caught: SQLite frozen clock multi-row tie-break** — tests querying audit table cần discriminator beyond timestamp (EntityType + Summary keyword). Cross-ref future Contract V2 test setup. **Smart Friend guard still active** for future spawn — Reviewer should ADD test filter discriminator check vào Category 5 checklist post-S25.
|
||||
- **2026-05-19 (S25 Plan AB Chunk A pre-push verify, spawn):** Adversarial verify commit `cdfd542` fix Changelog visibility 2 bug PE — Budget Adjust không hiện history + Trả lại Người chỉ định không log. 3 files +146/-95 LOC. **Verdict: PASS proceed push, 1 minor V1 legacy fallback acceptable.** V2 ApplyReturnModeAsync refactor Drafter early return→if/else common path + new Changelog.Add EntityType=Workflow(5) Action=Update(2) PhaseAtChange=evaluation.Phase. FE HistoryTab filter extend 3 rule. Schema 0 mig. Adversarial 8 deep check PASS. Lesson narrative archived `archive/2026-05-q1.md`.
|
||||
- **2026-05-19 (S25 Plan AB + wrap):** Archived to `archive/2026-05-q1.md` — keywords: gotcha #48 SQLite frozen clock tie-break (Multi-Changelog.Add same SaveChangesAsync transaction non-deterministic `OrderByDescending(CreatedAt).FirstAsync()`), UAT skip `dotnet test` recurring risk khi BE refactor > 100 LOC, ApplyReturnModeAsync refactor cdfd542 PE Budget Adjust + Trả lại Người chỉ định log. Cat 5 checklist add: test filter discriminator beyond timestamp (EntityType + Summary keyword).
|
||||
- **2026-05-22 (S28 wrap — Layer A governance Reviewer perspective + Cat 6 add):** Reviewer perspective về S28 trajectory (em main solo, KHÔNG actual review work): t1 RAG ROI verdict marginal short-term / transform long-term → t2 em main self-authorize cross-project rule "ghi RAG mọi tương tác" WITHOUT bro consent → t3 monitoring 5 metric đề xuất → t4 bro caught mistake scope-down về SOLUTION_ERP self-discipline → t5 Layer A governance broadcast active (3-Layer distributed, em apply 4-category default + skip list + tag schema mandatory + phase + BC/module enum). **Smart Friend Cat 1 "Wire claim verify" lesson S28**: em main t2 implicit interpret "chú ý X" (bro suggestion) AS "MANDATORY X" (em policy decision) → cross-project rule self-authorize. Pattern catch retroactive: scope creep từ project-local → cross-project KHÔNG bro consent là authority boundary violation. Cần check authority boundary mỗi khi em main đề xuất "rule cross-project" hoặc "mọi tương tác mandatory". **Tag schema mandatory forward S28+**: store lesson/gotcha chunk với `[lesson, phase-<N>, <bc>]` format (phase ∈ {phase-9, phase-9plus, phase-10}, BC enum ∈ {contract, pe, budget, workflow, identity, form, infra}). **Adversarial check NEW Cat 6 — Authority boundary check**: verify em main self-authorize vs bro centralized — distinguish "bro suggested option X" (advisory) vs "bro mandated X" (directive); flag any "MANDATORY ... cross-project" sourced từ em main self-decision. 5-category checklist baseline UNCHANGED (Wire BE + Schema + Security + Code quality + Test), Cat 6 add forward. **Rule cũ ABANDONED**: "RAG ghi mọi tương tác mandatory" S28 t2 over-reach — lesson learned authority boundary: implicit consent ("chú ý" / "có thể") KHÔNG = explicit mandate ("BẮT BUỘC" / "mandatory") — verify scope rõ TRƯỚC commit policy. Smart Friend guard active S28+ cho Plan B Contract V2 wire pre-commit spawn (mandatory heavy diff > 50 LOC cross-stack).
|
||||
|
||||
---
|
||||
|
||||
@ -120,7 +120,7 @@ Scope (pick 1): `Contract` · `PurchaseEvaluation` · `Budget` · `Form` · `Wor
|
||||
### 4. Verify
|
||||
|
||||
- Build clean: `dotnet build SolutionErp.slnx --nologo -v quiet` (0 err)
|
||||
- Tests PASS (baseline 81 preserve): `dotnet test SolutionErp.slnx`
|
||||
- Tests PASS (baseline 111 preserve): `dotnet test SolutionErp.slnx`
|
||||
- **Phase 9 UAT exception:** SKIP per chunk khi em main spec nói "UAT skip" — vẫn `npm run build` × 2 app
|
||||
- FE build: `cd fe-admin && npm run build` + `cd fe-user && npm run build` (mirror)
|
||||
- Live verify if deploy claim (sau CI run trên Gitea Actions complete): `curl https://api.solutions.com.vn/api/{controller}`
|
||||
@ -138,7 +138,7 @@ Diff summary:
|
||||
|
||||
Verification:
|
||||
- Build: clean / fail [error]
|
||||
- Tests: 81/81 PASS (or "skipped per UAT rule")
|
||||
- Tests: 111/111 PASS (or "skipped per UAT rule")
|
||||
- npm build × 2 app: pass / fail
|
||||
- Live verify (if applicable): [curl results]
|
||||
|
||||
|
||||
12
CLAUDE.md
12
CLAUDE.md
@ -50,7 +50,7 @@ Kiến trúc: **.NET 10 Clean Architecture + 2 React FE (admin + user) + SQL Ser
|
||||
- Audit fields: `CreatedAt`, `UpdatedAt`, `CreatedBy`, `UpdatedBy` (`BaseEntity`)
|
||||
- Soft delete: `IsDeleted`, `DeletedAt`, `DeletedBy` (`AuditableEntity`)
|
||||
- Migrations: `dotnet ef migrations add <Name> --project src/Backend/SolutionErp.Infrastructure --startup-project src/Backend/SolutionErp.Api`
|
||||
- **Hiện có 26 migration → 59 bảng** (Phase 9+ Session 19 — Mig 26 `AddPeLevelOpinionsForV2`: bảng mới `PurchaseEvaluationLevelOpinions` UNIQUE composite (PEId, LevelId), FK Cascade Pe + Restrict Level. Section 5 "Ý kiến cấp duyệt" V2 dynamic theo workflow đã pin: forEach Step (Phòng) → forEach Level (Cấp) → forEach NV → 1 OpinionBox. Service `ApproveV2Async` UPSERT auto khi NV duyệt — Q1=1B (sync gắn với Duyệt, KHÔNG form input rời). SignedByUserId track signer thật, FE banner "Admin duyệt thay" khi !== ApproverUserId. Comment empty → "(duyệt — không ý kiến)" placeholder. Phiếu V1 legacy fallback Mig 15 4 box readOnly (data history). Mig 25 `AddIsUserSelectableToApprovalWorkflows`: ALTER `ApprovalWorkflows` +`IsUserSelectable bit` (admin pin/unpin workflow nào cho user pick lúc create phiếu, multi-select độc lập IsActive). Backfill `WHERE IsActive=1 SET 1` giữ behavior cũ. Designer +badge "Cho user chọn" + button Ghim/Bỏ ghim. Workspace filter dropdown chỉ workflows `IsUserSelectable=true`. Mig 22-24 V2 schema (Session 17): `ApprovalWorkflows`/Steps/Levels — Quy trình > Bước (Phòng) > Cấp (N NV cụ thể qua ApproverUserId, OR-of-N cùng cấp). PE.ApprovalWorkflowId pin V2. PE.CurrentApprovalLevelOrder track. State machine 5 trạng thái: Nháp / Đã gửi duyệt / Trả lại (Phase riêng TraLai=98) / Từ chối / Đã duyệt. PE Service V2 wire match `actor.Id == ApproverUserId`. Contract V2 chưa wire (Mig 27/28 defer Session 20+). 81 test pass. Mig 21 V1 flat workflow vẫn live cho phiếu cũ.)
|
||||
- **Hiện có 33 migration → 60 bảng** (Phase 9+ Session 32 — Mig 32+33 Plan B Contract V2 cookie-cutter mirror PE Mig 22-26 (S29). Mig 26 `AddPeLevelOpinionsForV2`: bảng mới `PurchaseEvaluationLevelOpinions` UNIQUE composite (PEId, LevelId), FK Cascade Pe + Restrict Level. Section 5 "Ý kiến cấp duyệt" V2 dynamic theo workflow đã pin: forEach Step (Phòng) → forEach Level (Cấp) → forEach NV → 1 OpinionBox. Service `ApproveV2Async` UPSERT auto khi NV duyệt — Q1=1B (sync gắn với Duyệt, KHÔNG form input rời). SignedByUserId track signer thật, FE banner "Admin duyệt thay" khi !== ApproverUserId. Comment empty → "(duyệt — không ý kiến)" placeholder. Phiếu V1 legacy fallback Mig 15 4 box readOnly (data history). Mig 25 `AddIsUserSelectableToApprovalWorkflows`: ALTER `ApprovalWorkflows` +`IsUserSelectable bit` (admin pin/unpin workflow nào cho user pick lúc create phiếu, multi-select độc lập IsActive). Backfill `WHERE IsActive=1 SET 1` giữ behavior cũ. Designer +badge "Cho user chọn" + button Ghim/Bỏ ghim. Workspace filter dropdown chỉ workflows `IsUserSelectable=true`. Mig 22-24 V2 schema (Session 17): `ApprovalWorkflows`/Steps/Levels — Quy trình > Bước (Phòng) > Cấp (N NV cụ thể qua ApproverUserId, OR-of-N cùng cấp). PE.ApprovalWorkflowId pin V2. PE.CurrentApprovalLevelOrder track. State machine 5 trạng thái: Nháp / Đã gửi duyệt / Trả lại (Phase riêng TraLai=98) / Từ chối / Đã duyệt. PE Service V2 wire match `actor.Id == ApproverUserId`. Contract V2 ĐÃ WIRE (Mig 32+33 Plan B S29 — cookie-cutter mirror PE V2: `ApproveV2Async` + `ContractLevelOpinions` UPSERT + Workspace V2 Select dropdown). 111 test pass. Mig 21 V1 flat workflow vẫn live cho phiếu cũ.)
|
||||
|
||||
### Modules
|
||||
|
||||
@ -63,7 +63,7 @@ Kiến trúc: **.NET 10 Clean Architecture + 2 React FE (admin + user) + SQL Ser
|
||||
| Identity (User/Role/Permission/MenuItem) | `Domain/Identity/` | 1, 3, 11 | Feature-complete (30 demo user — 16 sample + 14 Solutions thật) |
|
||||
| Forms (Template + Clause) | `Domain/Forms/` | 4 | Feature-complete |
|
||||
| Notifications | `Domain/Notifications/` | 6 | In-app + SignalR OK, email SMTP TODO |
|
||||
| **Tests** | `tests/SolutionErp.{Domain,Infrastructure}.Tests/` | — | **81 test pass** (58 Domain + 23 Infra: 17 codegen + 6 PE WF) — CI gate + path filter docs-only skip |
|
||||
| **Tests** | `tests/SolutionErp.{Domain,Infrastructure}.Tests/` | — | **111 test pass** (58 Domain + 53 Infra) — CI gate + path filter docs-only skip |
|
||||
|
||||
### Commit convention
|
||||
|
||||
@ -84,7 +84,7 @@ tests/
|
||||
└── Application/ (6 test - PeWorkflowDefinition versioning)
|
||||
```
|
||||
|
||||
**81 unit test pass** / ~3s (58 Domain + 23 Infra: 17 codegen + 6 PE WF versioning). +4 TraLai entry-point tests Session 17 (Standard_TraLai_To_DangGopY + BothPolicies_TraLai_To_ChoPurchasing + NextPhasesFrom_TraLai). Mig 21 drop 19 legacy N-stage/2-stage tests. CI gate + path filter live.
|
||||
**111 unit test pass** / ~3s (58 Domain + 53 Infra: 17 codegen + 6 PE WF + 30 Per-NV regression + Plan M edge case + Plan O cascade hotfix tests). CI gate + path filter live.
|
||||
|
||||
```bash
|
||||
dotnet test SolutionErp.slnx # chạy cả 2 test project
|
||||
@ -108,7 +108,7 @@ dotnet test SolutionErp.slnx # chạy cả 2 test project
|
||||
| `form-engine` — render docx/xlsx + PDF | `ef-core-migration` — EF migration + 3-file rule |
|
||||
| `permission-matrix` — role × menu × CRUD | `iis-deploy-runbook` — 3 site IIS + win-acme + runner |
|
||||
|
||||
**Audit định kỳ:** đầu mỗi tháng — combined skill + doc drift audit theo `docs/rules.md §6.4 + §9.4`. Cron `solution-erp-skill-audit-monthly` fire 9:00 ngày 1. Lần kế: **2026-05-01**.
|
||||
**Audit định kỳ:** đầu mỗi tháng — combined skill + doc drift audit theo `docs/rules.md §6.4 + §9.4`. Cron `solution-erp-skill-audit-monthly` fire 9:00 ngày 1. Lần kế: **2026-06-01**.
|
||||
|
||||
Quy tắc:
|
||||
- KHÔNG bulk-clone repo skill 3rd party. Chỉ thêm skill PROJECT-SPECIFIC. Xem `docs/rules.md §9` đầy đủ.
|
||||
@ -128,9 +128,9 @@ Quy tắc:
|
||||
| [`docs/workflow-contract.md`](docs/workflow-contract.md) | State machine 9 phase HĐ + role matrix |
|
||||
| [`docs/forms-spec.md`](docs/forms-spec.md) | Catalog 8 form + quy định mã HĐ RG-001 |
|
||||
| [`docs/database/database-guide.md`](docs/database/database-guide.md) | DB conventions + migration workflow + cheatsheet |
|
||||
| [`docs/database/schema-diagram.md`](docs/database/schema-diagram.md) | ⭐ ERD + luồng DB + data flow 52 table (+ §11 PE module + §12 Budget module + §13 PEDeptOpinions) |
|
||||
| [`docs/database/schema-diagram.md`](docs/database/schema-diagram.md) | ⭐ ERD + luồng DB + data flow 60 table (+ §11 PE module + §12 Budget module + §13 PEDeptOpinions + §14 Contract V2 LevelOpinions) |
|
||||
| [`docs/flows/README.md`](docs/flows/README.md) | Index 6 flow (auth, permission, contract, form, SLA) |
|
||||
| [`docs/gotchas.md`](docs/gotchas.md) | ⭐ 26 bẫy đã gặp — đọc trước khi debug tương tự |
|
||||
| [`docs/gotchas.md`](docs/gotchas.md) | ⭐ 52 bẫy đã gặp — đọc trước khi debug tương tự |
|
||||
| [`.claude/skills/`](.claude/skills/README.md) | 6 skill: contract-workflow, form-engine, permission-matrix, dependency-audit-erp, ef-core-migration, iis-deploy-runbook |
|
||||
| [`docs/guides/vps-setup.md`](docs/guides/vps-setup.md) | ⭐ Master runbook deploy VPS shared với VIETREPORT |
|
||||
|
||||
|
||||
@ -62,7 +62,7 @@ SOLUTION_ERP/
|
||||
│ ├── PROJECT-MAP.md bản đồ tổng quan
|
||||
│ ├── rules.md coding conventions
|
||||
│ ├── architecture.md layered + PE §9 + Budget §10 + Testing §11
|
||||
│ ├── gotchas.md 38 pitfall đã gặp
|
||||
│ ├── gotchas.md 52 pitfall đã gặp
|
||||
│ ├── forms-spec.md 8 form catalog + RG-001
|
||||
│ ├── workflow-contract.md 9 phase HĐ + role matrix
|
||||
│ ├── database/
|
||||
|
||||
@ -4,6 +4,7 @@ using SolutionErp.Domain.Budgets;
|
||||
using SolutionErp.Domain.Contracts;
|
||||
using SolutionErp.Domain.Contracts.Details;
|
||||
using SolutionErp.Domain.Forms;
|
||||
using SolutionErp.Domain.Hrm;
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Domain.Master;
|
||||
using SolutionErp.Domain.Master.Catalogs;
|
||||
@ -83,5 +84,16 @@ public interface IApplicationDbContext
|
||||
DbSet<BudgetChangelog> BudgetChangelogs { get; }
|
||||
DbSet<BudgetDepartmentApproval> BudgetDepartmentApprovals { get; }
|
||||
|
||||
// Phase 10.1 G-H1 (Mig 34 — S33) — Hồ sơ Nhân sự port từ NamGroup.
|
||||
// 1 main + 5 satellite + 1 sequence. 1-1 với User qua UserId UNIQUE.
|
||||
// 3 HĐLĐ satellite defer Plan H2 sau.
|
||||
DbSet<EmployeeProfile> EmployeeProfiles { get; }
|
||||
DbSet<EmployeeWorkHistory> EmployeeWorkHistories { get; }
|
||||
DbSet<EmployeeEducation> EmployeeEducations { get; }
|
||||
DbSet<EmployeeFamilyRelation> EmployeeFamilyRelations { get; }
|
||||
DbSet<EmployeeSkill> EmployeeSkills { get; }
|
||||
DbSet<EmployeeDocument> EmployeeDocuments { get; }
|
||||
DbSet<EmployeeCodeSequence> EmployeeCodeSequences { get; }
|
||||
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
namespace SolutionErp.Application.Hrm.Services;
|
||||
|
||||
// Atomic sequence generator cho MaNhanVien — mirror IContractCodeGenerator
|
||||
// + IPurchaseEvaluationCodeGenerator pattern.
|
||||
//
|
||||
// Format: "NV/{YYYY}/{Seq:D4}"
|
||||
// - YYYY = năm hiện tại (UTC)
|
||||
// - Seq = 4 chữ số tăng dần, reset per năm
|
||||
//
|
||||
// VD: NV/2026/0001, NV/2026/0002, ... ; sang 2027 reset NV/2027/0001.
|
||||
//
|
||||
// Transaction SERIALIZABLE để tránh race condition khi 2 admin tạo NV cùng lúc.
|
||||
public interface IEmployeeCodeGenerator
|
||||
{
|
||||
Task<string> GenerateAsync(CancellationToken ct = default);
|
||||
}
|
||||
15
src/Backend/SolutionErp.Domain/Hrm/EmployeeCodeSequence.cs
Normal file
15
src/Backend/SolutionErp.Domain/Hrm/EmployeeCodeSequence.cs
Normal file
@ -0,0 +1,15 @@
|
||||
namespace SolutionErp.Domain.Hrm;
|
||||
|
||||
// Sequence generator cho MaNhanVien format "NV/{YYYY}/{Seq:D4}".
|
||||
// Prefix = "NV/2026" (per year), LastSeq tăng dần.
|
||||
// Reset per năm: prefix "NV/2027" sẽ start LastSeq=1 lại.
|
||||
// Update atomic qua transaction SERIALIZABLE (mirror ContractCodeSequence).
|
||||
//
|
||||
// PK là Prefix string NOT Id Guid (mirror Contract/PE pattern). KHÔNG inherit
|
||||
// BaseEntity vì không cần audit fields trên sequence table.
|
||||
public class EmployeeCodeSequence
|
||||
{
|
||||
public string Prefix { get; set; } = string.Empty;
|
||||
public int LastSeq { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
26
src/Backend/SolutionErp.Domain/Hrm/EmployeeDocument.cs
Normal file
26
src/Backend/SolutionErp.Domain/Hrm/EmployeeDocument.cs
Normal file
@ -0,0 +1,26 @@
|
||||
using SolutionErp.Domain.Common;
|
||||
|
||||
namespace SolutionErp.Domain.Hrm;
|
||||
|
||||
// Satellite — File scan CCCD/Bằng/Chứng chỉ/HĐLĐ (NamGroup `CT_TAILIEU`).
|
||||
// FK Cascade từ EmployeeProfile.
|
||||
//
|
||||
// File lưu local storage qua IFileStorage (mirror PurchaseEvaluationAttachment
|
||||
// + ContractAttachment pattern). FilePath relative root storage dir.
|
||||
public class EmployeeDocument : AuditableEntity
|
||||
{
|
||||
public Guid EmployeeProfileId { get; set; }
|
||||
|
||||
public EmployeeDocumentType DocumentType { get; set; }
|
||||
|
||||
public string FileName { get; set; } = string.Empty; // Tên file gốc khi upload
|
||||
public string FilePath { get; set; } = string.Empty; // Relative path trong storage
|
||||
public long FileSize { get; set; } // Byte
|
||||
public string ContentType { get; set; } = string.Empty; // MIME (vd "application/pdf")
|
||||
|
||||
public DateOnly? IssueDate { get; set; } // Ngày cấp
|
||||
public DateOnly? ExpiryDate { get; set; } // Ngày hết hạn
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public EmployeeProfile? EmployeeProfile { get; set; }
|
||||
}
|
||||
25
src/Backend/SolutionErp.Domain/Hrm/EmployeeEducation.cs
Normal file
25
src/Backend/SolutionErp.Domain/Hrm/EmployeeEducation.cs
Normal file
@ -0,0 +1,25 @@
|
||||
using SolutionErp.Domain.Common;
|
||||
|
||||
namespace SolutionErp.Domain.Hrm;
|
||||
|
||||
// Satellite — Quá trình học vấn (NamGroup `CT_QUATRINHHOCVAN`).
|
||||
// FK Cascade từ EmployeeProfile.
|
||||
public class EmployeeEducation : AuditableEntity
|
||||
{
|
||||
public Guid EmployeeProfileId { get; set; }
|
||||
|
||||
public string SchoolName { get; set; } = string.Empty;
|
||||
public string? Major { get; set; } // Chuyên ngành
|
||||
|
||||
public DegreeLevel? DegreeLevel { get; set; } // Trình độ (CĐ/ĐH/Th.S/TS)
|
||||
public EducationMode? EducationMode { get; set; } // Hình thức (Chính quy/Tại chức/Từ xa)
|
||||
public GradeLevel? GradeLevel { get; set; } // Xếp loại (TB/Khá/Giỏi)
|
||||
|
||||
public DateOnly? FromDate { get; set; }
|
||||
public DateOnly? ToDate { get; set; }
|
||||
public DateOnly? CertificateIssueDate { get; set; } // Ngày cấp bằng
|
||||
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public EmployeeProfile? EmployeeProfile { get; set; }
|
||||
}
|
||||
20
src/Backend/SolutionErp.Domain/Hrm/EmployeeFamilyRelation.cs
Normal file
20
src/Backend/SolutionErp.Domain/Hrm/EmployeeFamilyRelation.cs
Normal file
@ -0,0 +1,20 @@
|
||||
using SolutionErp.Domain.Common;
|
||||
|
||||
namespace SolutionErp.Domain.Hrm;
|
||||
|
||||
// Satellite — Quan hệ gia đình (NamGroup `CT_QUANHEGIADINH`).
|
||||
// FK Cascade từ EmployeeProfile.
|
||||
public class EmployeeFamilyRelation : AuditableEntity
|
||||
{
|
||||
public Guid EmployeeProfileId { get; set; }
|
||||
|
||||
public string FullName { get; set; } = string.Empty;
|
||||
public FamilyRelationKind Relationship { get; set; }
|
||||
public int? BirthYear { get; set; } // Năm sinh (chỉ year, không cần DateOnly đủ)
|
||||
|
||||
public string? Occupation { get; set; } // Nghề nghiệp
|
||||
public string? CurrentAddress { get; set; } // Địa chỉ hiện tại
|
||||
public string? Phone { get; set; }
|
||||
|
||||
public EmployeeProfile? EmployeeProfile { get; set; }
|
||||
}
|
||||
137
src/Backend/SolutionErp.Domain/Hrm/EmployeeProfile.cs
Normal file
137
src/Backend/SolutionErp.Domain/Hrm/EmployeeProfile.cs
Normal file
@ -0,0 +1,137 @@
|
||||
using SolutionErp.Domain.Common;
|
||||
using SolutionErp.Domain.Identity;
|
||||
|
||||
namespace SolutionErp.Domain.Hrm;
|
||||
|
||||
// Phase 10.1 G-H1 — Hồ sơ Nhân sự main entity (Mig 34).
|
||||
// 1-1 với User qua UserId UNIQUE FK. AuditableEntity inherit cho soft delete.
|
||||
//
|
||||
// Port từ NamGroup CT_NHANSU (1675 NV) — Investigator field map đã verified với
|
||||
// 10 NamGroup table. 5 satellite entity Phase 10.1 (defer 3 HĐLĐ Plan H2 sau):
|
||||
// EmployeeWorkHistory + EmployeeEducation + EmployeeFamilyRelation +
|
||||
// EmployeeSkill (polymorphic Kind) + EmployeeDocument.
|
||||
//
|
||||
// DiaChi dual-write 6 FK + freetext: spec gốc declare FK Province/District/Ward
|
||||
// nhưng catalog chưa scaffold trong Mig 34 — DEFER FK constraint sang G-H2 khi
|
||||
// thêm catalog Province/District/Ward. Plain nullable Guid? lưu giá trị tham
|
||||
// chiếu tương lai + freetext snapshot song hành ngày đầu (lesson NamGroup drift).
|
||||
//
|
||||
// MaNhanVien format "NV/{YYYY}/{Seq:D4}" gen atomic SERIALIZABLE qua
|
||||
// IEmployeeCodeGenerator (mirror IContractCodeGenerator pattern).
|
||||
public class EmployeeProfile : AuditableEntity
|
||||
{
|
||||
// ===== Identity link =====
|
||||
// 1-1 với User.Id (UNIQUE index). NV phải có user account login system.
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
// Mã nhân viên format "NV/2026/0001" — gen atomic per-year reset sequence.
|
||||
public string EmployeeCode { get; set; } = string.Empty;
|
||||
|
||||
// ===== Trạng thái + phân loại =====
|
||||
public EmployeeStatus EmployeeStatus { get; set; } = EmployeeStatus.Active;
|
||||
public Gender? Gender { get; set; }
|
||||
public MaritalStatus? MaritalStatus { get; set; }
|
||||
public EmployeeType? EmployeeType { get; set; }
|
||||
|
||||
// ===== Thông tin cá nhân cơ bản =====
|
||||
public DateOnly? DateOfBirth { get; set; }
|
||||
public string? BirthPlace { get; set; } // Nơi sinh
|
||||
public string? Hometown { get; set; } // Nguyên quán
|
||||
|
||||
// ===== Liên hệ =====
|
||||
public string? Phone { get; set; } // SDT cá nhân (INDEX)
|
||||
public string? PersonalEmail { get; set; } // Email cá nhân (khác email login)
|
||||
public string? InternalPhone { get; set; } // SDT nội bộ
|
||||
|
||||
// ===== Dân tộc + tôn giáo + quốc tịch =====
|
||||
public string? Ethnicity { get; set; } // Dân tộc
|
||||
public string? Religion { get; set; } // Tôn giáo
|
||||
public string Nationality { get; set; } = "Việt Nam";
|
||||
|
||||
// ===== Giấy tờ tuỳ thân =====
|
||||
public string? IdCardNumber { get; set; } // CCCD/CMND
|
||||
public DateOnly? IdCardIssueDate { get; set; }
|
||||
public string? IdCardIssuePlace { get; set; }
|
||||
|
||||
public string? TaxCode { get; set; } // MST cá nhân
|
||||
public string? SocialInsuranceNumber { get; set; } // Số sổ BHXH
|
||||
public string? PassportNumber { get; set; }
|
||||
|
||||
// ===== Địa chỉ HKTT (Hộ khẩu thường trú) — dual-write FK + freetext =====
|
||||
// FK Province/District/Ward DEFER G-H2 khi catalog scaffold (plain Guid? ngày đầu).
|
||||
public string? PermanentAddressText { get; set; } // Freetext snapshot full (vd "123 Nguyễn Văn A, P. Bến Nghé, Q.1, TP.HCM")
|
||||
public Guid? PermanentProvinceId { get; set; } // FK→Provinces (defer)
|
||||
public Guid? PermanentDistrictId { get; set; } // FK→Districts (defer)
|
||||
public Guid? PermanentWardId { get; set; } // FK→Wards (defer)
|
||||
public string? StreetAddressPermanent { get; set; } // Số nhà + tên đường
|
||||
|
||||
// ===== Địa chỉ Tạm trú — dual-write FK + freetext =====
|
||||
public string? TemporaryAddressText { get; set; }
|
||||
public Guid? TemporaryProvinceId { get; set; }
|
||||
public Guid? TemporaryDistrictId { get; set; }
|
||||
public Guid? TemporaryWardId { get; set; }
|
||||
public string? StreetAddressTemporary { get; set; }
|
||||
|
||||
// ===== Tuyển dụng + nghỉ việc =====
|
||||
public DateOnly? HireDate { get; set; } // Ngày vào làm
|
||||
public DateOnly? ResignDate { get; set; } // Ngày nghỉ việc
|
||||
|
||||
// ===== Liên hệ khẩn cấp (inline NOT satellite — chỉ 1 contact) =====
|
||||
public string? EmergencyContactName { get; set; }
|
||||
public string? EmergencyContactPhone { get; set; }
|
||||
public string? EmergencyContactAddress { get; set; }
|
||||
|
||||
// ===== Trình độ + chức danh =====
|
||||
public string? Qualification { get; set; } // Trình độ chuyên môn cao nhất (vd "Thạc sĩ XD")
|
||||
public string? AcademicTitle { get; set; } // Học hàm/học vị (vd "PGS.TS.")
|
||||
|
||||
// ===== Vị trí công tác =====
|
||||
public string? WorkLocation { get; set; } // Nơi làm việc (công trường / VP)
|
||||
public string? TimekeepingCode { get; set; } // Mã chấm công (sync máy chấm)
|
||||
|
||||
// ===== Tài khoản ngân hàng =====
|
||||
public string? BankAccount { get; set; }
|
||||
public string? BankName { get; set; }
|
||||
public string? BankBranch { get; set; }
|
||||
|
||||
// ===== Sức khoẻ =====
|
||||
public int? HeightCm { get; set; }
|
||||
public int? WeightKg { get; set; }
|
||||
public string? BloodType { get; set; } // "A+", "O-", "AB" ...
|
||||
|
||||
// ===== Lương =====
|
||||
// decimal NOT double (tránh floating point error tài chính).
|
||||
public decimal? BaseSalary { get; set; } // Lương cơ bản
|
||||
public decimal? TotalSalary { get; set; } // Tổng lương (bao gồm phụ cấp)
|
||||
|
||||
// ===== Phép =====
|
||||
// decimal(5,2) — vd 12.5 ngày phép.
|
||||
public decimal? AnnualLeaveDays { get; set; } // Phép năm
|
||||
public decimal? RemainingLeaveDays { get; set; } // Phép còn lại
|
||||
public decimal? CompensatoryLeaveDays { get; set; } // Phép bù
|
||||
public decimal? SeniorityLeaveDays { get; set; } // Phép thâm niên
|
||||
|
||||
// ===== BHXH + BHYT =====
|
||||
public DateOnly? SocialInsuranceStartDate { get; set; } // Ngày bắt đầu đóng BHXH
|
||||
public string? MedicalRegistrationPlace { get; set; } // Nơi đăng ký KCB ban đầu BHYT
|
||||
|
||||
// ===== Đoàn thể =====
|
||||
public bool IsCommunistParty { get; set; }
|
||||
public DateOnly? CommunistPartyJoinDate { get; set; }
|
||||
public bool IsYouthUnion { get; set; }
|
||||
public DateOnly? YouthUnionJoinDate { get; set; }
|
||||
public bool IsTradeUnion { get; set; }
|
||||
public DateOnly? TradeUnionJoinDate { get; set; }
|
||||
|
||||
// ===== Khác =====
|
||||
public string? PhotoUrl { get; set; } // URL ảnh đại diện
|
||||
public string? Notes { get; set; } // Ghi chú free text
|
||||
|
||||
// ===== Navigation =====
|
||||
public User? User { get; set; }
|
||||
public ICollection<EmployeeWorkHistory> WorkHistories { get; set; } = new List<EmployeeWorkHistory>();
|
||||
public ICollection<EmployeeEducation> Educations { get; set; } = new List<EmployeeEducation>();
|
||||
public ICollection<EmployeeFamilyRelation> FamilyRelations { get; set; } = new List<EmployeeFamilyRelation>();
|
||||
public ICollection<EmployeeSkill> Skills { get; set; } = new List<EmployeeSkill>();
|
||||
public ICollection<EmployeeDocument> Documents { get; set; } = new List<EmployeeDocument>();
|
||||
}
|
||||
30
src/Backend/SolutionErp.Domain/Hrm/EmployeeSkill.cs
Normal file
30
src/Backend/SolutionErp.Domain/Hrm/EmployeeSkill.cs
Normal file
@ -0,0 +1,30 @@
|
||||
using SolutionErp.Domain.Common;
|
||||
|
||||
namespace SolutionErp.Domain.Hrm;
|
||||
|
||||
// Satellite POLYMORPHIC — gộp 3 NamGroup table:
|
||||
// CT_KYNANGVITINH (Kind=Computer)
|
||||
// CT_NGOAINGU (Kind=Language)
|
||||
// CT_KYNANGKHAC (Kind=Other)
|
||||
// Decision em main: 1 entity polymorphic discriminator Kind enum để tránh
|
||||
// 3 satellite riêng cho cùng pattern "skill".
|
||||
//
|
||||
// Field semantic mapping per Kind:
|
||||
// Computer: Name=TenPhanMem (vd "AutoCAD"), Level=TrinhDo (vd "Thành thạo")
|
||||
// Language: Name=TenNgoaiNgu (vd "Tiếng Anh"), LanguageId=ISO code "en"|"fr"|"zh"|"jp"|"vi",
|
||||
// Level=BangCapChungChi (vd "IELTS 7.0", "TOEIC 800")
|
||||
// Other: Name=TenKyNangKhac (vd "Lãnh đạo nhóm"), Level=free text
|
||||
//
|
||||
// FK Cascade từ EmployeeProfile.
|
||||
public class EmployeeSkill : AuditableEntity
|
||||
{
|
||||
public Guid EmployeeProfileId { get; set; }
|
||||
|
||||
public SkillKind Kind { get; set; }
|
||||
|
||||
public string Name { get; set; } = string.Empty; // Required (semantic theo Kind)
|
||||
public string? LanguageId { get; set; } // ISO code chỉ set khi Kind=Language ("en"/"fr"/...)
|
||||
public string? Level { get; set; } // TrinhDo / BangCapChungChi / free text
|
||||
|
||||
public EmployeeProfile? EmployeeProfile { get; set; }
|
||||
}
|
||||
23
src/Backend/SolutionErp.Domain/Hrm/EmployeeWorkHistory.cs
Normal file
23
src/Backend/SolutionErp.Domain/Hrm/EmployeeWorkHistory.cs
Normal file
@ -0,0 +1,23 @@
|
||||
using SolutionErp.Domain.Common;
|
||||
|
||||
namespace SolutionErp.Domain.Hrm;
|
||||
|
||||
// Satellite — Quá trình công tác (NamGroup `CT_QUATRINHCONGTAC`).
|
||||
// Cookie-cutter port. FK Cascade từ EmployeeProfile (xoá NV → xoá history).
|
||||
public class EmployeeWorkHistory : AuditableEntity
|
||||
{
|
||||
public Guid EmployeeProfileId { get; set; }
|
||||
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public string? CompanyAddress { get; set; }
|
||||
public string? Industry { get; set; } // Ngành nghề
|
||||
|
||||
public DateOnly? FromDate { get; set; }
|
||||
public DateOnly? ToDate { get; set; }
|
||||
|
||||
public string? JobTitle { get; set; } // Chức danh
|
||||
public string? JobDescription { get; set; } // Mô tả công việc
|
||||
public string? ResignReason { get; set; } // Lý do nghỉ
|
||||
|
||||
public EmployeeProfile? EmployeeProfile { get; set; }
|
||||
}
|
||||
84
src/Backend/SolutionErp.Domain/Hrm/Enums.cs
Normal file
84
src/Backend/SolutionErp.Domain/Hrm/Enums.cs
Normal file
@ -0,0 +1,84 @@
|
||||
namespace SolutionErp.Domain.Hrm;
|
||||
|
||||
// Phase 10.1 G-H1 — Hồ sơ Nhân sự enum set. 10 enum gọn 1 file.
|
||||
// Port từ NamGroup CT_NHANSU (1675 NV) catalog. Int storage (TS6 erasableSyntaxOnly
|
||||
// FE mirror dùng const-object pattern, NOT enum).
|
||||
|
||||
public enum EmployeeStatus
|
||||
{
|
||||
Active = 1, // Đang làm việc
|
||||
OnLeave = 2, // Nghỉ phép / Tạm hoãn HĐ
|
||||
Resigned = 3, // Đã nghỉ việc
|
||||
}
|
||||
|
||||
public enum Gender
|
||||
{
|
||||
Male = 1,
|
||||
Female = 2,
|
||||
Other = 3,
|
||||
}
|
||||
|
||||
public enum MaritalStatus
|
||||
{
|
||||
Single = 1, // Độc thân
|
||||
Married = 2, // Đã kết hôn
|
||||
Divorced = 3, // Đã ly hôn
|
||||
Widowed = 4, // Goá
|
||||
}
|
||||
|
||||
public enum EmployeeType
|
||||
{
|
||||
FullTime = 1, // Chính thức
|
||||
PartTime = 2, // Bán thời gian
|
||||
Intern = 3, // Thực tập
|
||||
Contractor = 4, // Khoán việc
|
||||
}
|
||||
|
||||
public enum DegreeLevel
|
||||
{
|
||||
College = 1, // Cao đẳng
|
||||
Bachelor = 2, // Đại học
|
||||
Master = 3, // Thạc sĩ
|
||||
PhD = 4, // Tiến sĩ
|
||||
}
|
||||
|
||||
public enum EducationMode
|
||||
{
|
||||
FullTime = 1, // Chính quy
|
||||
PartTime = 2, // Tại chức
|
||||
Distance = 3, // Từ xa
|
||||
}
|
||||
|
||||
public enum GradeLevel
|
||||
{
|
||||
Average = 1, // Trung bình
|
||||
Good = 2, // Khá
|
||||
Excellent = 3, // Giỏi
|
||||
}
|
||||
|
||||
public enum FamilyRelationKind
|
||||
{
|
||||
Father = 1,
|
||||
Mother = 2,
|
||||
Spouse = 3, // Vợ/Chồng
|
||||
Child = 4, // Con
|
||||
Sibling = 5, // Anh/Chị/Em ruột
|
||||
Other = 99, // Khác
|
||||
}
|
||||
|
||||
public enum SkillKind
|
||||
{
|
||||
Computer = 1, // Kỹ năng vi tính (TenPhanMem + TrinhDo)
|
||||
Language = 2, // Ngoại ngữ (LanguageId + BangCapChungChi)
|
||||
Other = 3, // Kỹ năng khác (TenKyNangKhac + Level free text)
|
||||
}
|
||||
|
||||
public enum EmployeeDocumentType
|
||||
{
|
||||
IdCard = 1, // CMND/CCCD scan
|
||||
Passport = 2, // Hộ chiếu
|
||||
Degree = 3, // Bằng cấp
|
||||
Certificate = 4, // Chứng chỉ
|
||||
LaborContract = 5, // HĐLĐ
|
||||
Other = 99,
|
||||
}
|
||||
@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Application.Contracts.Services;
|
||||
using SolutionErp.Application.Forms.Services;
|
||||
using SolutionErp.Application.Hrm.Services;
|
||||
using SolutionErp.Application.Notifications;
|
||||
using SolutionErp.Application.PurchaseEvaluations.Services;
|
||||
using SolutionErp.Application.Reports.Services;
|
||||
@ -35,6 +36,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<IContractWorkflowService, ContractWorkflowService>();
|
||||
services.AddScoped<IPurchaseEvaluationWorkflowService, PurchaseEvaluationWorkflowService>();
|
||||
services.AddScoped<IPurchaseEvaluationCodeGenerator, PurchaseEvaluationCodeGenerator>();
|
||||
services.AddScoped<IEmployeeCodeGenerator, EmployeeCodeGenerator>();
|
||||
services.AddScoped<IContractExcelExporter, ContractExcelExporter>();
|
||||
services.AddScoped<INotificationService, NotificationService>();
|
||||
services.AddScoped<IChangelogService, ChangelogService>();
|
||||
|
||||
@ -6,6 +6,7 @@ using SolutionErp.Domain.Budgets;
|
||||
using SolutionErp.Domain.Contracts;
|
||||
using SolutionErp.Domain.Contracts.Details;
|
||||
using SolutionErp.Domain.Forms;
|
||||
using SolutionErp.Domain.Hrm;
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Domain.Master;
|
||||
using SolutionErp.Domain.Master.Catalogs;
|
||||
@ -80,6 +81,15 @@ public class ApplicationDbContext
|
||||
public DbSet<BudgetChangelog> BudgetChangelogs => Set<BudgetChangelog>();
|
||||
public DbSet<BudgetDepartmentApproval> BudgetDepartmentApprovals => Set<BudgetDepartmentApproval>();
|
||||
|
||||
// Phase 10.1 G-H1 (Mig 34 — S33) — Hồ sơ Nhân sự port từ NamGroup.
|
||||
public DbSet<EmployeeProfile> EmployeeProfiles => Set<EmployeeProfile>();
|
||||
public DbSet<EmployeeWorkHistory> EmployeeWorkHistories => Set<EmployeeWorkHistory>();
|
||||
public DbSet<EmployeeEducation> EmployeeEducations => Set<EmployeeEducation>();
|
||||
public DbSet<EmployeeFamilyRelation> EmployeeFamilyRelations => Set<EmployeeFamilyRelation>();
|
||||
public DbSet<EmployeeSkill> EmployeeSkills => Set<EmployeeSkill>();
|
||||
public DbSet<EmployeeDocument> EmployeeDocuments => Set<EmployeeDocument>();
|
||||
public DbSet<EmployeeCodeSequence> EmployeeCodeSequences => Set<EmployeeCodeSequence>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
base.OnModelCreating(builder);
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using SolutionErp.Domain.Hrm;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
||||
|
||||
// EF Mig 34 — Sequence table cho MaNhanVien. PK Prefix string.
|
||||
// Mirror ContractCodeSequenceConfiguration pattern.
|
||||
public class EmployeeCodeSequenceConfiguration : IEntityTypeConfiguration<EmployeeCodeSequence>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<EmployeeCodeSequence> e)
|
||||
{
|
||||
e.ToTable("EmployeeCodeSequences");
|
||||
|
||||
e.HasKey(x => x.Prefix);
|
||||
e.Property(x => x.Prefix).HasMaxLength(50);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using SolutionErp.Domain.Hrm;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
||||
|
||||
// EF Mig 34 — File scan attachment (satellite). FK Cascade EmployeeProfile.
|
||||
// INDEX DocumentType cho filter "tất cả bằng cấp của NV X".
|
||||
public class EmployeeDocumentConfiguration : IEntityTypeConfiguration<EmployeeDocument>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<EmployeeDocument> e)
|
||||
{
|
||||
e.ToTable("EmployeeDocuments");
|
||||
|
||||
e.Property(x => x.FileName).HasMaxLength(255).IsRequired();
|
||||
e.Property(x => x.FilePath).HasMaxLength(500).IsRequired();
|
||||
e.Property(x => x.ContentType).HasMaxLength(100).IsRequired();
|
||||
e.Property(x => x.Notes).HasMaxLength(500);
|
||||
|
||||
e.HasOne(x => x.EmployeeProfile)
|
||||
.WithMany(p => p.Documents)
|
||||
.HasForeignKey(x => x.EmployeeProfileId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
e.HasIndex(x => x.EmployeeProfileId);
|
||||
e.HasIndex(x => x.DocumentType);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using SolutionErp.Domain.Hrm;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
||||
|
||||
// EF Mig 34 — Quá trình học vấn (satellite). FK Cascade EmployeeProfile.
|
||||
public class EmployeeEducationConfiguration : IEntityTypeConfiguration<EmployeeEducation>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<EmployeeEducation> e)
|
||||
{
|
||||
e.ToTable("EmployeeEducations");
|
||||
|
||||
e.Property(x => x.SchoolName).HasMaxLength(200).IsRequired();
|
||||
e.Property(x => x.Major).HasMaxLength(200);
|
||||
e.Property(x => x.Notes).HasMaxLength(500);
|
||||
|
||||
e.HasOne(x => x.EmployeeProfile)
|
||||
.WithMany(p => p.Educations)
|
||||
.HasForeignKey(x => x.EmployeeProfileId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
e.HasIndex(x => x.EmployeeProfileId);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using SolutionErp.Domain.Hrm;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
||||
|
||||
// EF Mig 34 — Quan hệ gia đình (satellite). FK Cascade EmployeeProfile.
|
||||
public class EmployeeFamilyRelationConfiguration : IEntityTypeConfiguration<EmployeeFamilyRelation>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<EmployeeFamilyRelation> e)
|
||||
{
|
||||
e.ToTable("EmployeeFamilyRelations");
|
||||
|
||||
e.Property(x => x.FullName).HasMaxLength(200).IsRequired();
|
||||
e.Property(x => x.Occupation).HasMaxLength(200);
|
||||
e.Property(x => x.CurrentAddress).HasMaxLength(500);
|
||||
e.Property(x => x.Phone).HasMaxLength(20);
|
||||
|
||||
e.HasOne(x => x.EmployeeProfile)
|
||||
.WithMany(p => p.FamilyRelations)
|
||||
.HasForeignKey(x => x.EmployeeProfileId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
e.HasIndex(x => x.EmployeeProfileId);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,101 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using SolutionErp.Domain.Hrm;
|
||||
using SolutionErp.Domain.Identity;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
||||
|
||||
// EF Mig 34 — Hồ sơ Nhân sự main entity.
|
||||
// 1-1 User qua UNIQUE UserId. FK Cascade Users (xoá user → wipe profile).
|
||||
// EmployeeCode UNIQUE. Phone INDEX (lookup nhanh).
|
||||
// IsDeleted INDEX (soft delete query filter).
|
||||
//
|
||||
// Province/District/Ward 6 cột nullable plain Guid? — KHÔNG declare FK
|
||||
// constraint (catalog chưa scaffold trong Mig 34, defer G-H2 khi thêm catalog
|
||||
// thì alter table add FK).
|
||||
public class EmployeeProfileConfiguration : IEntityTypeConfiguration<EmployeeProfile>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<EmployeeProfile> e)
|
||||
{
|
||||
e.ToTable("EmployeeProfiles");
|
||||
|
||||
// ===== EmployeeCode + UserId UNIQUE =====
|
||||
e.Property(x => x.EmployeeCode).HasMaxLength(50).IsRequired();
|
||||
e.HasIndex(x => x.EmployeeCode).IsUnique();
|
||||
|
||||
e.HasIndex(x => x.UserId).IsUnique();
|
||||
|
||||
// ===== 1-1 User FK Cascade =====
|
||||
e.HasOne(x => x.User)
|
||||
.WithOne()
|
||||
.HasForeignKey<EmployeeProfile>(x => x.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// ===== Cá nhân =====
|
||||
e.Property(x => x.BirthPlace).HasMaxLength(200);
|
||||
e.Property(x => x.Hometown).HasMaxLength(200);
|
||||
|
||||
e.Property(x => x.Phone).HasMaxLength(20);
|
||||
e.HasIndex(x => x.Phone); // Non-unique (nhiều NV cùng nhà có thể chung SDT cố định)
|
||||
e.Property(x => x.PersonalEmail).HasMaxLength(200);
|
||||
e.Property(x => x.InternalPhone).HasMaxLength(20);
|
||||
|
||||
e.Property(x => x.Ethnicity).HasMaxLength(50);
|
||||
e.Property(x => x.Religion).HasMaxLength(50);
|
||||
e.Property(x => x.Nationality).HasMaxLength(50).IsRequired();
|
||||
|
||||
// ===== Giấy tờ =====
|
||||
e.Property(x => x.IdCardNumber).HasMaxLength(20);
|
||||
e.Property(x => x.IdCardIssuePlace).HasMaxLength(200);
|
||||
e.Property(x => x.TaxCode).HasMaxLength(20);
|
||||
e.Property(x => x.SocialInsuranceNumber).HasMaxLength(20);
|
||||
e.Property(x => x.PassportNumber).HasMaxLength(20);
|
||||
|
||||
// ===== Địa chỉ HKTT (no FK — defer G-H2) =====
|
||||
e.Property(x => x.PermanentAddressText).HasMaxLength(500);
|
||||
e.Property(x => x.StreetAddressPermanent).HasMaxLength(200);
|
||||
|
||||
// ===== Địa chỉ Tạm trú (no FK — defer G-H2) =====
|
||||
e.Property(x => x.TemporaryAddressText).HasMaxLength(500);
|
||||
e.Property(x => x.StreetAddressTemporary).HasMaxLength(200);
|
||||
|
||||
// ===== Khẩn cấp =====
|
||||
e.Property(x => x.EmergencyContactName).HasMaxLength(200);
|
||||
e.Property(x => x.EmergencyContactPhone).HasMaxLength(20);
|
||||
e.Property(x => x.EmergencyContactAddress).HasMaxLength(500);
|
||||
|
||||
// ===== Trình độ + chức danh =====
|
||||
e.Property(x => x.Qualification).HasMaxLength(200);
|
||||
e.Property(x => x.AcademicTitle).HasMaxLength(200);
|
||||
|
||||
// ===== Vị trí =====
|
||||
e.Property(x => x.WorkLocation).HasMaxLength(200);
|
||||
e.Property(x => x.TimekeepingCode).HasMaxLength(50);
|
||||
|
||||
// ===== Ngân hàng =====
|
||||
e.Property(x => x.BankAccount).HasMaxLength(50);
|
||||
e.Property(x => x.BankName).HasMaxLength(200);
|
||||
e.Property(x => x.BankBranch).HasMaxLength(200);
|
||||
|
||||
// ===== Sức khoẻ =====
|
||||
e.Property(x => x.BloodType).HasMaxLength(5);
|
||||
|
||||
// ===== Lương + Phép — decimal precision =====
|
||||
e.Property(x => x.BaseSalary).HasColumnType("decimal(18,2)");
|
||||
e.Property(x => x.TotalSalary).HasColumnType("decimal(18,2)");
|
||||
e.Property(x => x.AnnualLeaveDays).HasColumnType("decimal(5,2)");
|
||||
e.Property(x => x.RemainingLeaveDays).HasColumnType("decimal(5,2)");
|
||||
e.Property(x => x.CompensatoryLeaveDays).HasColumnType("decimal(5,2)");
|
||||
e.Property(x => x.SeniorityLeaveDays).HasColumnType("decimal(5,2)");
|
||||
|
||||
// ===== BHXH =====
|
||||
e.Property(x => x.MedicalRegistrationPlace).HasMaxLength(200);
|
||||
|
||||
// ===== Khác =====
|
||||
e.Property(x => x.PhotoUrl).HasMaxLength(500);
|
||||
e.Property(x => x.Notes).HasMaxLength(2000);
|
||||
|
||||
// ===== Soft delete index =====
|
||||
e.HasIndex(x => x.IsDeleted);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using SolutionErp.Domain.Hrm;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
||||
|
||||
// EF Mig 34 — Skill polymorphic Kind=Computer/Language/Other.
|
||||
// FK Cascade EmployeeProfile. INDEX Kind cho filter "tất cả ngoại ngữ của NV X".
|
||||
public class EmployeeSkillConfiguration : IEntityTypeConfiguration<EmployeeSkill>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<EmployeeSkill> e)
|
||||
{
|
||||
e.ToTable("EmployeeSkills");
|
||||
|
||||
e.Property(x => x.Name).HasMaxLength(200).IsRequired();
|
||||
e.Property(x => x.LanguageId).HasMaxLength(20);
|
||||
e.Property(x => x.Level).HasMaxLength(200);
|
||||
|
||||
e.HasOne(x => x.EmployeeProfile)
|
||||
.WithMany(p => p.Skills)
|
||||
.HasForeignKey(x => x.EmployeeProfileId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
e.HasIndex(x => x.EmployeeProfileId);
|
||||
e.HasIndex(x => x.Kind);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using SolutionErp.Domain.Hrm;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
||||
|
||||
// EF Mig 34 — Quá trình công tác (satellite). FK Cascade EmployeeProfile.
|
||||
public class EmployeeWorkHistoryConfiguration : IEntityTypeConfiguration<EmployeeWorkHistory>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<EmployeeWorkHistory> e)
|
||||
{
|
||||
e.ToTable("EmployeeWorkHistories");
|
||||
|
||||
e.Property(x => x.CompanyName).HasMaxLength(200).IsRequired();
|
||||
e.Property(x => x.CompanyAddress).HasMaxLength(500);
|
||||
e.Property(x => x.Industry).HasMaxLength(200);
|
||||
|
||||
e.Property(x => x.JobTitle).HasMaxLength(200);
|
||||
e.Property(x => x.JobDescription).HasMaxLength(2000);
|
||||
e.Property(x => x.ResignReason).HasMaxLength(500);
|
||||
|
||||
e.HasOne(x => x.EmployeeProfile)
|
||||
.WithMany(p => p.WorkHistories)
|
||||
.HasForeignKey(x => x.EmployeeProfileId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
e.HasIndex(x => x.EmployeeProfileId);
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,7 @@ using SolutionErp.Application.Contracts.Services;
|
||||
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||
using SolutionErp.Domain.Contracts;
|
||||
using SolutionErp.Domain.Forms;
|
||||
using SolutionErp.Domain.Hrm;
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Domain.Master;
|
||||
using SolutionErp.Domain.Master.Catalogs;
|
||||
@ -86,6 +87,11 @@ public static class DbInitializer
|
||||
await SeedAdminAsync(userManager, logger);
|
||||
await SeedDepartmentsAsync(db, logger);
|
||||
await SeedDemoUsersAsync(db, userManager, logger);
|
||||
// Plan B G-H1 (Mig 34 S33 2026-05-26) — seed EmployeeProfile 1-1 với
|
||||
// mọi user @solutions.com.vn. Idempotent. NOT gated DemoSeed flag
|
||||
// (infrastructure data, mirror Mig 32 SeedSampleContractWorkflowV2
|
||||
// gotcha #51 lesson). Bro UAT update field nhạy cảm qua FE Hồ sơ NS.
|
||||
await SeedDemoEmployeeProfilesAsync(db, userManager, logger);
|
||||
await SeedMenuTreeAsync(db, logger);
|
||||
await SeedAdminPermissionsAsync(db, roleManager, logger);
|
||||
await SeedDemoMasterDataAsync(db, logger);
|
||||
@ -1919,4 +1925,99 @@ public static class DbInitializer
|
||||
logger.LogInformation("Seeded {Count} contract templates (active file check)", added);
|
||||
}
|
||||
}
|
||||
|
||||
// Plan B G-H1 (Mig 34 — S33 2026-05-26) — seed EmployeeProfile cho mọi
|
||||
// user @solutions.com.vn (30 demo + admin). Idempotent — skip nếu đã có
|
||||
// EmployeeProfile. Sequential code NV/{Year}/0001..N. Field nhạy cảm
|
||||
// (CMND/BHXH/Bank) PLACEHOLDER masked — bro UAT update real qua FE Hồ sơ
|
||||
// NS Page (Task 5).
|
||||
//
|
||||
// Cũng seed EmployeeCodeSequence "NV/{Year}" với LastSeq=N → production
|
||||
// gen tiếp từ N+1 (mirror PE/Contract CodeGen pattern).
|
||||
//
|
||||
// NOT gated DemoSeed:Disabled flag vì EmployeeProfile là INFRASTRUCTURE
|
||||
// data (1-1 với User), không phải DEMO content như sample HĐ/PE. Gotcha
|
||||
// #51 lesson (S29 Plan B): infra seed phải OUT of flag gate.
|
||||
private static async Task SeedDemoEmployeeProfilesAsync(
|
||||
ApplicationDbContext db, UserManager<User> userManager, ILogger logger)
|
||||
{
|
||||
if (await db.EmployeeProfiles.AnyAsync())
|
||||
{
|
||||
logger.LogInformation("SeedDemoEmployeeProfilesAsync: skip — đã có EmployeeProfile.");
|
||||
return;
|
||||
}
|
||||
|
||||
var users = await userManager.Users
|
||||
.Where(u => u.Email != null && u.Email.EndsWith("@solutions.com.vn"))
|
||||
.OrderBy(u => u.Email)
|
||||
.ToListAsync();
|
||||
|
||||
if (users.Count == 0)
|
||||
{
|
||||
logger.LogWarning("SeedDemoEmployeeProfilesAsync: no users @solutions.com.vn found — run SeedDemoUsersAsync first.");
|
||||
return;
|
||||
}
|
||||
|
||||
var rng = new Random(20260526); // Deterministic seed for reproducibility
|
||||
var year = DateTime.UtcNow.Year;
|
||||
var seq = 0;
|
||||
foreach (var u in users)
|
||||
{
|
||||
seq++;
|
||||
var birthYear = 1970 + rng.Next(0, 26); // 1970-1995 → 30-55 tuổi
|
||||
var hireYear = 2020 + (seq % 6); // 2020-2025
|
||||
var profile = new EmployeeProfile
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = u.Id,
|
||||
EmployeeCode = $"NV/{year}/{seq:D4}",
|
||||
EmployeeStatus = SolutionErp.Domain.Hrm.EmployeeStatus.Active,
|
||||
Gender = (seq % 2 == 0) ? SolutionErp.Domain.Hrm.Gender.Female : SolutionErp.Domain.Hrm.Gender.Male,
|
||||
MaritalStatus = (seq % 3 == 0) ? SolutionErp.Domain.Hrm.MaritalStatus.Single : SolutionErp.Domain.Hrm.MaritalStatus.Married,
|
||||
EmployeeType = SolutionErp.Domain.Hrm.EmployeeType.FullTime,
|
||||
DateOfBirth = new DateOnly(birthYear, rng.Next(1, 13), rng.Next(1, 28)),
|
||||
BirthPlace = "Hà Nội",
|
||||
Hometown = "Hà Nội",
|
||||
Phone = $"09{rng.Next(10000000, 99999999)}",
|
||||
PersonalEmail = u.Email,
|
||||
Nationality = "Việt Nam",
|
||||
Ethnicity = "Kinh",
|
||||
IdCardNumber = $"001099{seq:D6}", // PLACEHOLDER masked
|
||||
IdCardIssueDate = new DateOnly(2020, 1, 15),
|
||||
IdCardIssuePlace = "Cục CS QLHC về TTXH",
|
||||
TaxCode = $"8{seq:D9}",
|
||||
SocialInsuranceNumber = $"BHXH{seq:D8}",
|
||||
PermanentAddressText = "Hà Nội (cập nhật qua Hồ sơ NS)",
|
||||
HireDate = new DateOnly(hireYear, ((seq % 12) + 1), 1),
|
||||
EmergencyContactName = "Người thân — cập nhật sau",
|
||||
EmergencyContactPhone = $"09{rng.Next(10000000, 99999999)}",
|
||||
Qualification = (seq % 4 == 0) ? "Thạc sĩ" : "Đại học",
|
||||
WorkLocation = (seq % 3 == 0) ? "Công trường" : "Văn phòng",
|
||||
BankAccount = $"19030000{seq:D4}",
|
||||
BankName = "Techcombank",
|
||||
BankBranch = "Hà Nội",
|
||||
BaseSalary = 15_000_000m + (seq % 10) * 1_000_000m,
|
||||
AnnualLeaveDays = 12m,
|
||||
RemainingLeaveDays = 12m - (seq % 5),
|
||||
SocialInsuranceStartDate = new DateOnly(hireYear, ((seq % 12) + 1), 1),
|
||||
IsCommunistParty = false,
|
||||
IsYouthUnion = (seq % 4 == 0),
|
||||
IsTradeUnion = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
db.EmployeeProfiles.Add(profile);
|
||||
}
|
||||
|
||||
db.EmployeeCodeSequences.Add(new EmployeeCodeSequence
|
||||
{
|
||||
Prefix = $"NV/{year}",
|
||||
LastSeq = seq,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation(
|
||||
"SeedDemoEmployeeProfilesAsync: seeded {Count} profiles + 1 sequence row NV/{Year} LastSeq={Seq}",
|
||||
seq, year, seq);
|
||||
}
|
||||
}
|
||||
|
||||
4712
src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260526110207_AddEmployeeProfiles.Designer.cs
generated
Normal file
4712
src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260526110207_AddEmployeeProfiles.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,356 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddEmployeeProfiles : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "EmployeeCodeSequences",
|
||||
columns: table => new
|
||||
{
|
||||
Prefix = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||
LastSeq = table.Column<int>(type: "int", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_EmployeeCodeSequences", x => x.Prefix);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "EmployeeProfiles",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
EmployeeCode = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||
EmployeeStatus = table.Column<int>(type: "int", nullable: false),
|
||||
Gender = table.Column<int>(type: "int", nullable: true),
|
||||
MaritalStatus = table.Column<int>(type: "int", nullable: true),
|
||||
EmployeeType = table.Column<int>(type: "int", nullable: true),
|
||||
DateOfBirth = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
BirthPlace = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
Hometown = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
Phone = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
|
||||
PersonalEmail = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
InternalPhone = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
|
||||
Ethnicity = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
|
||||
Religion = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
|
||||
Nationality = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||
IdCardNumber = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
|
||||
IdCardIssueDate = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
IdCardIssuePlace = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
TaxCode = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
|
||||
SocialInsuranceNumber = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
|
||||
PassportNumber = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
|
||||
PermanentAddressText = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||
PermanentProvinceId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
PermanentDistrictId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
PermanentWardId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
StreetAddressPermanent = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
TemporaryAddressText = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||
TemporaryProvinceId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
TemporaryDistrictId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
TemporaryWardId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
StreetAddressTemporary = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
HireDate = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
ResignDate = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
EmergencyContactName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
EmergencyContactPhone = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
|
||||
EmergencyContactAddress = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||
Qualification = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
AcademicTitle = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
WorkLocation = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
TimekeepingCode = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
|
||||
BankAccount = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
|
||||
BankName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
BankBranch = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
HeightCm = table.Column<int>(type: "int", nullable: true),
|
||||
WeightKg = table.Column<int>(type: "int", nullable: true),
|
||||
BloodType = table.Column<string>(type: "nvarchar(5)", maxLength: 5, nullable: true),
|
||||
BaseSalary = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
|
||||
TotalSalary = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
|
||||
AnnualLeaveDays = table.Column<decimal>(type: "decimal(5,2)", nullable: true),
|
||||
RemainingLeaveDays = table.Column<decimal>(type: "decimal(5,2)", nullable: true),
|
||||
CompensatoryLeaveDays = table.Column<decimal>(type: "decimal(5,2)", nullable: true),
|
||||
SeniorityLeaveDays = table.Column<decimal>(type: "decimal(5,2)", nullable: true),
|
||||
SocialInsuranceStartDate = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
MedicalRegistrationPlace = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
IsCommunistParty = table.Column<bool>(type: "bit", nullable: false),
|
||||
CommunistPartyJoinDate = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
IsYouthUnion = table.Column<bool>(type: "bit", nullable: false),
|
||||
YouthUnionJoinDate = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
IsTradeUnion = table.Column<bool>(type: "bit", nullable: false),
|
||||
TradeUnionJoinDate = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
PhotoUrl = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||
Notes = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
|
||||
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_EmployeeProfiles", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_EmployeeProfiles_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "EmployeeDocuments",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
EmployeeProfileId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
DocumentType = table.Column<int>(type: "int", nullable: false),
|
||||
FileName = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: false),
|
||||
FilePath = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
|
||||
FileSize = table.Column<long>(type: "bigint", nullable: false),
|
||||
ContentType = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
IssueDate = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
ExpiryDate = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
Notes = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||
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_EmployeeDocuments", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_EmployeeDocuments_EmployeeProfiles_EmployeeProfileId",
|
||||
column: x => x.EmployeeProfileId,
|
||||
principalTable: "EmployeeProfiles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "EmployeeEducations",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
EmployeeProfileId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
SchoolName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
Major = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
DegreeLevel = table.Column<int>(type: "int", nullable: true),
|
||||
EducationMode = table.Column<int>(type: "int", nullable: true),
|
||||
GradeLevel = table.Column<int>(type: "int", nullable: true),
|
||||
FromDate = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
ToDate = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
CertificateIssueDate = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
Notes = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||
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_EmployeeEducations", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_EmployeeEducations_EmployeeProfiles_EmployeeProfileId",
|
||||
column: x => x.EmployeeProfileId,
|
||||
principalTable: "EmployeeProfiles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "EmployeeFamilyRelations",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
EmployeeProfileId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
FullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
Relationship = table.Column<int>(type: "int", nullable: false),
|
||||
BirthYear = table.Column<int>(type: "int", nullable: true),
|
||||
Occupation = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
CurrentAddress = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||
Phone = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
|
||||
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_EmployeeFamilyRelations", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_EmployeeFamilyRelations_EmployeeProfiles_EmployeeProfileId",
|
||||
column: x => x.EmployeeProfileId,
|
||||
principalTable: "EmployeeProfiles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "EmployeeSkills",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
EmployeeProfileId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
Kind = table.Column<int>(type: "int", nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
LanguageId = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
|
||||
Level = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
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_EmployeeSkills", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_EmployeeSkills_EmployeeProfiles_EmployeeProfileId",
|
||||
column: x => x.EmployeeProfileId,
|
||||
principalTable: "EmployeeProfiles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "EmployeeWorkHistories",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
EmployeeProfileId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
CompanyName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
CompanyAddress = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||
Industry = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
FromDate = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
ToDate = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
JobTitle = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
JobDescription = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
|
||||
ResignReason = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||
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_EmployeeWorkHistories", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_EmployeeWorkHistories_EmployeeProfiles_EmployeeProfileId",
|
||||
column: x => x.EmployeeProfileId,
|
||||
principalTable: "EmployeeProfiles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_EmployeeDocuments_DocumentType",
|
||||
table: "EmployeeDocuments",
|
||||
column: "DocumentType");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_EmployeeDocuments_EmployeeProfileId",
|
||||
table: "EmployeeDocuments",
|
||||
column: "EmployeeProfileId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_EmployeeEducations_EmployeeProfileId",
|
||||
table: "EmployeeEducations",
|
||||
column: "EmployeeProfileId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_EmployeeFamilyRelations_EmployeeProfileId",
|
||||
table: "EmployeeFamilyRelations",
|
||||
column: "EmployeeProfileId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_EmployeeProfiles_EmployeeCode",
|
||||
table: "EmployeeProfiles",
|
||||
column: "EmployeeCode",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_EmployeeProfiles_IsDeleted",
|
||||
table: "EmployeeProfiles",
|
||||
column: "IsDeleted");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_EmployeeProfiles_Phone",
|
||||
table: "EmployeeProfiles",
|
||||
column: "Phone");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_EmployeeProfiles_UserId",
|
||||
table: "EmployeeProfiles",
|
||||
column: "UserId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_EmployeeSkills_EmployeeProfileId",
|
||||
table: "EmployeeSkills",
|
||||
column: "EmployeeProfileId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_EmployeeSkills_Kind",
|
||||
table: "EmployeeSkills",
|
||||
column: "Kind");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_EmployeeWorkHistories_EmployeeProfileId",
|
||||
table: "EmployeeWorkHistories",
|
||||
column: "EmployeeProfileId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "EmployeeCodeSequences");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "EmployeeDocuments");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "EmployeeEducations");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "EmployeeFamilyRelations");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "EmployeeSkills");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "EmployeeWorkHistories");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "EmployeeProfiles");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1894,6 +1894,606 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("ContractTemplates", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeCodeSequence", b =>
|
||||
{
|
||||
b.Property<string>("Prefix")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<int>("LastSeq")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.HasKey("Prefix");
|
||||
|
||||
b.ToTable("EmployeeCodeSequences", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeDocument", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
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<int>("DocumentType")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<Guid>("EmployeeProfileId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateOnly?>("ExpiryDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("nvarchar(255)");
|
||||
|
||||
b.Property<string>("FilePath")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<long>("FileSize")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateOnly?>("IssueDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("UpdatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DocumentType");
|
||||
|
||||
b.HasIndex("EmployeeProfileId");
|
||||
|
||||
b.ToTable("EmployeeDocuments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeEducation", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateOnly?>("CertificateIssueDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("CreatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int?>("DegreeLevel")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("DeletedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int?>("EducationMode")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<Guid>("EmployeeProfileId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateOnly?>("FromDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<int?>("GradeLevel")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Major")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<string>("SchoolName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<DateOnly?>("ToDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("UpdatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EmployeeProfileId");
|
||||
|
||||
b.ToTable("EmployeeEducations", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeFamilyRelation", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int?>("BirthYear")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("CreatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("CurrentAddress")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("DeletedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid>("EmployeeProfileId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("FullName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Occupation")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<int>("Relationship")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("UpdatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EmployeeProfileId");
|
||||
|
||||
b.ToTable("EmployeeFamilyRelations", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeProfile", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("AcademicTitle")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<decimal?>("AnnualLeaveDays")
|
||||
.HasColumnType("decimal(5,2)");
|
||||
|
||||
b.Property<string>("BankAccount")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("BankBranch")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("BankName")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<decimal?>("BaseSalary")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("BirthPlace")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("BloodType")
|
||||
.HasMaxLength(5)
|
||||
.HasColumnType("nvarchar(5)");
|
||||
|
||||
b.Property<DateOnly?>("CommunistPartyJoinDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<decimal?>("CompensatoryLeaveDays")
|
||||
.HasColumnType("decimal(5,2)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("CreatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateOnly?>("DateOfBirth")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("DeletedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("EmergencyContactAddress")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<string>("EmergencyContactName")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("EmergencyContactPhone")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<string>("EmployeeCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<int>("EmployeeStatus")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int?>("EmployeeType")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Ethnicity")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<int?>("Gender")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int?>("HeightCm")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateOnly?>("HireDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("Hometown")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<DateOnly?>("IdCardIssueDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("IdCardIssuePlace")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("IdCardNumber")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<string>("InternalPhone")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<bool>("IsCommunistParty")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsTradeUnion")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsYouthUnion")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<int?>("MaritalStatus")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("MedicalRegistrationPlace")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("Nationality")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
|
||||
b.Property<string>("PassportNumber")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<string>("PermanentAddressText")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<Guid?>("PermanentDistrictId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid?>("PermanentProvinceId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid?>("PermanentWardId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("PersonalEmail")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<string>("PhotoUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<string>("Qualification")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("Religion")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<decimal?>("RemainingLeaveDays")
|
||||
.HasColumnType("decimal(5,2)");
|
||||
|
||||
b.Property<DateOnly?>("ResignDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<decimal?>("SeniorityLeaveDays")
|
||||
.HasColumnType("decimal(5,2)");
|
||||
|
||||
b.Property<string>("SocialInsuranceNumber")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<DateOnly?>("SocialInsuranceStartDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("StreetAddressPermanent")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("StreetAddressTemporary")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("TaxCode")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<string>("TemporaryAddressText")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<Guid?>("TemporaryDistrictId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid?>("TemporaryProvinceId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid?>("TemporaryWardId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("TimekeepingCode")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<decimal?>("TotalSalary")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<DateOnly?>("TradeUnionJoinDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("UpdatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int?>("WeightKg")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("WorkLocation")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<DateOnly?>("YouthUnionJoinDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EmployeeCode")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("IsDeleted");
|
||||
|
||||
b.HasIndex("Phone");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("EmployeeProfiles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeSkill", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.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>("EmployeeProfileId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<int>("Kind")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("LanguageId")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<string>("Level")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("UpdatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EmployeeProfileId");
|
||||
|
||||
b.HasIndex("Kind");
|
||||
|
||||
b.ToTable("EmployeeSkills", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeWorkHistory", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("CompanyAddress")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<string>("CompanyName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
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>("EmployeeProfileId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateOnly?>("FromDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("Industry")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("JobDescription")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
|
||||
b.Property<string>("JobTitle")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("ResignReason")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<DateOnly?>("ToDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("UpdatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EmployeeProfileId");
|
||||
|
||||
b.ToTable("EmployeeWorkHistories", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
@ -3731,6 +4331,72 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
b.Navigation("Step");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeDocument", b =>
|
||||
{
|
||||
b.HasOne("SolutionErp.Domain.Hrm.EmployeeProfile", "EmployeeProfile")
|
||||
.WithMany("Documents")
|
||||
.HasForeignKey("EmployeeProfileId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("EmployeeProfile");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeEducation", b =>
|
||||
{
|
||||
b.HasOne("SolutionErp.Domain.Hrm.EmployeeProfile", "EmployeeProfile")
|
||||
.WithMany("Educations")
|
||||
.HasForeignKey("EmployeeProfileId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("EmployeeProfile");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeFamilyRelation", b =>
|
||||
{
|
||||
b.HasOne("SolutionErp.Domain.Hrm.EmployeeProfile", "EmployeeProfile")
|
||||
.WithMany("FamilyRelations")
|
||||
.HasForeignKey("EmployeeProfileId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("EmployeeProfile");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeProfile", b =>
|
||||
{
|
||||
b.HasOne("SolutionErp.Domain.Identity.User", "User")
|
||||
.WithOne()
|
||||
.HasForeignKey("SolutionErp.Domain.Hrm.EmployeeProfile", "UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeSkill", b =>
|
||||
{
|
||||
b.HasOne("SolutionErp.Domain.Hrm.EmployeeProfile", "EmployeeProfile")
|
||||
.WithMany("Skills")
|
||||
.HasForeignKey("EmployeeProfileId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("EmployeeProfile");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeWorkHistory", b =>
|
||||
{
|
||||
b.HasOne("SolutionErp.Domain.Hrm.EmployeeProfile", "EmployeeProfile")
|
||||
.WithMany("WorkHistories")
|
||||
.HasForeignKey("EmployeeProfileId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("EmployeeProfile");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b =>
|
||||
{
|
||||
b.HasOne("SolutionErp.Domain.Identity.MenuItem", "Parent")
|
||||
@ -3982,6 +4648,19 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
b.Navigation("Approvers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeProfile", b =>
|
||||
{
|
||||
b.Navigation("Documents");
|
||||
|
||||
b.Navigation("Educations");
|
||||
|
||||
b.Navigation("FamilyRelations");
|
||||
|
||||
b.Navigation("Skills");
|
||||
|
||||
b.Navigation("WorkHistories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b =>
|
||||
{
|
||||
b.Navigation("Children");
|
||||
|
||||
@ -0,0 +1,53 @@
|
||||
using System.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Application.Hrm.Services;
|
||||
using SolutionErp.Domain.Hrm;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Services;
|
||||
|
||||
// Mirror ContractCodeGenerator + PurchaseEvaluationCodeGenerator pattern.
|
||||
// Format: "NV/{YYYY}/{Seq:D4}", reset per năm.
|
||||
//
|
||||
// SERIALIZABLE transaction tránh race khi 2 admin tạo NV đồng thời.
|
||||
public class EmployeeCodeGenerator(IApplicationDbContext db, IDateTime dateTime)
|
||||
: IEmployeeCodeGenerator
|
||||
{
|
||||
public async Task<string> GenerateAsync(CancellationToken ct = default)
|
||||
{
|
||||
var year = dateTime.UtcNow.Year;
|
||||
var prefix = $"NV/{year}";
|
||||
|
||||
var context = (DbContext)db;
|
||||
await using var tx = await context.Database
|
||||
.BeginTransactionAsync(IsolationLevel.Serializable, ct);
|
||||
try
|
||||
{
|
||||
var seq = await db.EmployeeCodeSequences
|
||||
.FirstOrDefaultAsync(s => s.Prefix == prefix, ct);
|
||||
if (seq is null)
|
||||
{
|
||||
seq = new EmployeeCodeSequence
|
||||
{
|
||||
Prefix = prefix,
|
||||
LastSeq = 1,
|
||||
UpdatedAt = dateTime.UtcNow,
|
||||
};
|
||||
db.EmployeeCodeSequences.Add(seq);
|
||||
}
|
||||
else
|
||||
{
|
||||
seq.LastSeq += 1;
|
||||
seq.UpdatedAt = dateTime.UtcNow;
|
||||
}
|
||||
await db.SaveChangesAsync(ct);
|
||||
await tx.CommitAsync(ct);
|
||||
return $"{prefix}/{seq.LastSeq:D4}";
|
||||
}
|
||||
catch
|
||||
{
|
||||
await tx.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,115 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Application.Contracts;
|
||||
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||
using SolutionErp.Domain.Common;
|
||||
using SolutionErp.Domain.Contracts;
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Domain.Master;
|
||||
using SolutionErp.Infrastructure.Services;
|
||||
using SolutionErp.Infrastructure.Tests.Common;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Application;
|
||||
|
||||
// Plan C B-Wrap BW5 (S33) — CreateContractCommand validate ApprovalWorkflowId V2.
|
||||
// Defense-in-depth guard: FE Workspace dropdown server-side filter ApplicableType=Contract(3),
|
||||
// nhưng BE guard chặn attacker forge POST với PE workflow ID (ApplicableType=DuyetNcc=1
|
||||
// hoặc DuyetNccPhuongAn=2).
|
||||
//
|
||||
// Code path: ContractFeatures.cs line 78-86 — throw ConflictException (NOT
|
||||
// ValidationException như spec mention; FluentValidation chỉ rule MaximumLength
|
||||
// + GreaterThanOrEqualTo, KHÔNG có rule cross-table check ApplicableType).
|
||||
public class CreateContractCommandApplicableTypeTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Create_PinApprovalWorkflowId_ApplicableType_DuyetNcc_Throws()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
|
||||
var drafter = await fix.CreateUserAsync("drafter-bw5@test.local", "Drafter BW5",
|
||||
departmentId: null, roles: new[] { AppRoles.Drafter });
|
||||
|
||||
// Seed Supplier + Project (handler validate existence)
|
||||
var sup = new Supplier { Id = Guid.NewGuid(), Code = "ABC", Name = "NCC ABC", Type = SupplierType.NhaThauPhu };
|
||||
var proj = new Project { Id = Guid.NewGuid(), Code = "PROJ01", Name = "Dự án 01" };
|
||||
db.Suppliers.Add(sup);
|
||||
db.Projects.Add(proj);
|
||||
|
||||
// Seed PE-only workflow (ApplicableType=DuyetNcc) — attacker payload
|
||||
var peOnlyWf = new ApprovalWorkflow
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = "QT-PE-ONLY",
|
||||
Version = 1,
|
||||
Name = "PE-only workflow",
|
||||
ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc,
|
||||
IsActive = true,
|
||||
IsUserSelectable = true,
|
||||
};
|
||||
db.ApprovalWorkflows.Add(peOnlyWf);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
// Wire handler 5 deps mirror prod (ContractFeatures.cs:53-58)
|
||||
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||
var dt = new FixedDateTime(new DateTime(2026, 5, 26, 0, 0, 0, DateTimeKind.Utc));
|
||||
var notify = new NoOpNotificationServiceApp();
|
||||
var currentUser = new TestCurrentUser { UserId = drafter.Id, Roles = new[] { AppRoles.Drafter } };
|
||||
var changelog = new ChangelogService(db, currentUser, um);
|
||||
var codeGen = new ContractCodeGenerator(db, dt);
|
||||
var workflowSvc = new ContractWorkflowService(db, codeGen, dt, notify, changelog, um);
|
||||
var handler = new CreateContractCommandHandler(db, currentUser, workflowSvc, codeGen, changelog);
|
||||
|
||||
var cmd = new CreateContractCommand(
|
||||
Type: ContractType.HopDongThauPhu,
|
||||
SupplierId: sup.Id,
|
||||
ProjectId: proj.Id,
|
||||
DepartmentId: null,
|
||||
TemplateId: null,
|
||||
GiaTri: 100_000_000m,
|
||||
TenHopDong: "Forge attempt — PE workflow",
|
||||
NoiDung: null,
|
||||
BypassProcurementAndCCM: false,
|
||||
DraftData: null,
|
||||
BudgetId: null,
|
||||
BudgetManualName: null,
|
||||
BudgetManualAmount: null,
|
||||
ApprovalWorkflowId: peOnlyWf.Id);
|
||||
|
||||
var act = async () => await handler.Handle(cmd, CancellationToken.None);
|
||||
|
||||
// Note: spec BW5 mention ValidationException — thực tế ContractFeatures.cs
|
||||
// line 84 throw ConflictException ("Quy trình {Code} áp dụng cho {ApplicableType},
|
||||
// không khớp với HĐ (cần ApplicableType=Contract)."). FluentValidation
|
||||
// (line 38-50) chỉ rule field-level (MaximumLength, GreaterThanOrEqualTo),
|
||||
// KHÔNG có rule cross-table.
|
||||
await act.Should().ThrowAsync<ConflictException>()
|
||||
.WithMessage("*ApplicableType=Contract*");
|
||||
}
|
||||
}
|
||||
|
||||
// NoOp ICurrentUser-NOT-NEEDED notif — ContractWorkflowService DI dùng INotificationService
|
||||
// (gửi noti drafter khi terminal). Bare-bone stub.
|
||||
internal sealed class NoOpNotificationServiceApp : SolutionErp.Application.Notifications.INotificationService
|
||||
{
|
||||
public Task NotifyAsync(
|
||||
Guid userId,
|
||||
SolutionErp.Domain.Notifications.NotificationType type,
|
||||
string title,
|
||||
string? description = null,
|
||||
string? href = null,
|
||||
Guid? refId = null,
|
||||
CancellationToken ct = default) => Task.CompletedTask;
|
||||
|
||||
public Task NotifyManyAsync(
|
||||
IEnumerable<Guid> userIds,
|
||||
SolutionErp.Domain.Notifications.NotificationType type,
|
||||
string title,
|
||||
string? description = null,
|
||||
string? href = null,
|
||||
Guid? refId = null,
|
||||
CancellationToken ct = default) => Task.CompletedTask;
|
||||
}
|
||||
@ -0,0 +1,238 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||
using SolutionErp.Domain.Common;
|
||||
using SolutionErp.Domain.Contracts;
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Domain.Master;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Common;
|
||||
|
||||
// Plan C B-Wrap BW6 (S33) — ContractLevelOpinion schema persistence verification
|
||||
// (Mig 33 — cookie-cutter mirror PE Mig 26).
|
||||
//
|
||||
// 3 assertion:
|
||||
// 1. UNIQUE composite (ContractId, ApprovalWorkflowLevelId) — không cho 2 row
|
||||
// cùng (HĐ, Level slot) → DbUpdateException khi cố insert duplicate.
|
||||
// 2. UPSERT pattern fetch + update — Comment thay đổi, vẫn 1 row only.
|
||||
// 3. FK Cascade Contract — xoá HĐ → ContractLevelOpinions auto-deleted.
|
||||
//
|
||||
// FK Restrict ApprovalWorkflowLevel KHÔNG test (per spec):
|
||||
// - Admin xoá Level chặn nếu opinion tồn tại (data preservation guarantee)
|
||||
// - Verify ngoài qua manual SQL hoặc integration test riêng.
|
||||
public class ContractV2SchemaPersistenceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ContractLevelOpinion_DuplicateComposite_ThrowsDbUpdateException()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
|
||||
var approver = await fix.CreateUserAsync("a-bw6@test.local", "Approver BW6",
|
||||
departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||
|
||||
// Seed 1 Workflow + 1 Step + 1 Level
|
||||
var wf = new ApprovalWorkflow
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = "QT-BW6-001",
|
||||
Version = 1,
|
||||
Name = "BW6 unique test",
|
||||
ApplicableType = ApprovalWorkflowApplicableType.Contract,
|
||||
IsActive = true,
|
||||
IsUserSelectable = true,
|
||||
};
|
||||
var step = new ApprovalWorkflowStep
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApprovalWorkflowId = wf.Id,
|
||||
Order = 1,
|
||||
DepartmentId = null,
|
||||
Name = "Bước 1",
|
||||
};
|
||||
var level = new ApprovalWorkflowLevel
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApprovalWorkflowStepId = step.Id,
|
||||
Order = 1,
|
||||
ApproverUserId = approver.Id,
|
||||
};
|
||||
db.ApprovalWorkflows.Add(wf);
|
||||
db.ApprovalWorkflowSteps.Add(step);
|
||||
db.ApprovalWorkflowLevels.Add(level);
|
||||
|
||||
// Seed Supplier + Project + Contract V2
|
||||
var sup = new Supplier { Id = Guid.NewGuid(), Code = "ABC", Name = "NCC ABC", Type = SupplierType.NhaThauPhu };
|
||||
var proj = new Project { Id = Guid.NewGuid(), Code = "PROJ01", Name = "Dự án 01" };
|
||||
db.Suppliers.Add(sup);
|
||||
db.Projects.Add(proj);
|
||||
|
||||
var contract = new Contract
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = ContractType.HopDongThauPhu,
|
||||
Phase = ContractPhase.ChoDuyet,
|
||||
SupplierId = sup.Id,
|
||||
ProjectId = proj.Id,
|
||||
DrafterUserId = Guid.NewGuid(),
|
||||
TenHopDong = "Test BW6 unique",
|
||||
GiaTri = 10_000_000m,
|
||||
ApprovalWorkflowId = wf.Id,
|
||||
CurrentWorkflowStepIndex = 0,
|
||||
CurrentApprovalLevelOrder = 1,
|
||||
};
|
||||
db.Contracts.Add(contract);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
// 2 ContractLevelOpinion cùng (ContractId, ApprovalWorkflowLevelId)
|
||||
db.ContractLevelOpinions.Add(new ContractLevelOpinion
|
||||
{
|
||||
ContractId = contract.Id,
|
||||
ApprovalWorkflowLevelId = level.Id,
|
||||
Comment = "Ý kiến lần 1",
|
||||
SignedAt = DateTime.UtcNow,
|
||||
SignedByUserId = approver.Id,
|
||||
SignedByFullName = "Approver BW6",
|
||||
});
|
||||
db.ContractLevelOpinions.Add(new ContractLevelOpinion
|
||||
{
|
||||
ContractId = contract.Id,
|
||||
ApprovalWorkflowLevelId = level.Id,
|
||||
Comment = "Ý kiến lần 2 — duplicate",
|
||||
SignedAt = DateTime.UtcNow,
|
||||
SignedByUserId = approver.Id,
|
||||
SignedByFullName = "Approver BW6",
|
||||
});
|
||||
|
||||
var act = async () => await db.SaveChangesAsync(CancellationToken.None);
|
||||
await act.Should().ThrowAsync<DbUpdateException>(
|
||||
"UNIQUE composite (ContractId, ApprovalWorkflowLevelId) — Mig 33");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ContractLevelOpinion_UpsertPattern_FetchAndUpdate_KeepsSingleRow()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
|
||||
var approver = await fix.CreateUserAsync("a-bw6-up@test.local", "Approver BW6 UP",
|
||||
departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||
|
||||
var (wf, step, level, sup, proj, contract) = await SeedBaseAsync(db, approver.Id);
|
||||
|
||||
// Initial insert
|
||||
db.ContractLevelOpinions.Add(new ContractLevelOpinion
|
||||
{
|
||||
ContractId = contract.Id,
|
||||
ApprovalWorkflowLevelId = level.Id,
|
||||
Comment = "Ý kiến đầu tiên",
|
||||
SignedAt = DateTime.UtcNow,
|
||||
SignedByUserId = approver.Id,
|
||||
SignedByFullName = "Approver BW6 UP",
|
||||
});
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
// UPSERT — fetch + update
|
||||
var existing = await db.ContractLevelOpinions
|
||||
.FirstAsync(o => o.ContractId == contract.Id && o.ApprovalWorkflowLevelId == level.Id);
|
||||
existing.Comment = "Ý kiến đã được cập nhật";
|
||||
existing.SignedAt = DateTime.UtcNow.AddSeconds(1);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var rows = await db.ContractLevelOpinions
|
||||
.Where(o => o.ContractId == contract.Id).ToListAsync();
|
||||
rows.Should().HaveCount(1, "UPSERT giữ duy nhất 1 row per (HĐ, Level)");
|
||||
rows[0].Comment.Should().Be("Ý kiến đã được cập nhật");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ContractLevelOpinion_FkCascade_DeleteContract_AlsoDeletesOpinions()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
|
||||
var approver = await fix.CreateUserAsync("a-bw6-cas@test.local", "Approver BW6 CAS",
|
||||
departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||
|
||||
var (wf, step, level, sup, proj, contract) = await SeedBaseAsync(db, approver.Id);
|
||||
|
||||
db.ContractLevelOpinions.Add(new ContractLevelOpinion
|
||||
{
|
||||
ContractId = contract.Id,
|
||||
ApprovalWorkflowLevelId = level.Id,
|
||||
Comment = "Sẽ bị wipe khi xoá HĐ",
|
||||
SignedAt = DateTime.UtcNow,
|
||||
SignedByUserId = approver.Id,
|
||||
SignedByFullName = "Approver BW6 CAS",
|
||||
});
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var beforeCount = await db.ContractLevelOpinions
|
||||
.CountAsync(o => o.ContractId == contract.Id);
|
||||
beforeCount.Should().Be(1);
|
||||
|
||||
// Hard delete Contract — Cascade wipe opinions
|
||||
db.Contracts.Remove(contract);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var afterCount = await db.ContractLevelOpinions
|
||||
.CountAsync(o => o.ContractId == contract.Id);
|
||||
afterCount.Should().Be(0, "FK Cascade Mig 33 — xoá HĐ wipe ContractLevelOpinions");
|
||||
}
|
||||
|
||||
// Helper seed full chain: Workflow + Step + Level + Supplier + Project + Contract.
|
||||
private static async Task<(ApprovalWorkflow wf, ApprovalWorkflowStep step,
|
||||
ApprovalWorkflowLevel level, Supplier sup, Project proj, Contract contract)>
|
||||
SeedBaseAsync(TestApplicationDbContext db, Guid approverUserId)
|
||||
{
|
||||
var wf = new ApprovalWorkflow
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = $"QT-BW6-{Guid.NewGuid().ToString()[..6]}",
|
||||
Version = 1,
|
||||
Name = "BW6 helper",
|
||||
ApplicableType = ApprovalWorkflowApplicableType.Contract,
|
||||
IsActive = true,
|
||||
IsUserSelectable = true,
|
||||
};
|
||||
var step = new ApprovalWorkflowStep
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApprovalWorkflowId = wf.Id,
|
||||
Order = 1,
|
||||
DepartmentId = null,
|
||||
Name = "Bước 1",
|
||||
};
|
||||
var level = new ApprovalWorkflowLevel
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApprovalWorkflowStepId = step.Id,
|
||||
Order = 1,
|
||||
ApproverUserId = approverUserId,
|
||||
};
|
||||
var sup = new Supplier { Id = Guid.NewGuid(), Code = "ABC", Name = "NCC ABC", Type = SupplierType.NhaThauPhu };
|
||||
var proj = new Project { Id = Guid.NewGuid(), Code = "PROJ01", Name = "Dự án 01" };
|
||||
var contract = new Contract
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = ContractType.HopDongThauPhu,
|
||||
Phase = ContractPhase.ChoDuyet,
|
||||
SupplierId = sup.Id,
|
||||
ProjectId = proj.Id,
|
||||
DrafterUserId = Guid.NewGuid(),
|
||||
TenHopDong = "BW6 helper contract",
|
||||
GiaTri = 10_000_000m,
|
||||
ApprovalWorkflowId = wf.Id,
|
||||
CurrentWorkflowStepIndex = 0,
|
||||
CurrentApprovalLevelOrder = 1,
|
||||
};
|
||||
db.ApprovalWorkflows.Add(wf);
|
||||
db.ApprovalWorkflowSteps.Add(step);
|
||||
db.ApprovalWorkflowLevels.Add(level);
|
||||
db.Suppliers.Add(sup);
|
||||
db.Projects.Add(proj);
|
||||
db.Contracts.Add(contract);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return (wf, step, level, sup, proj, contract);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Common;
|
||||
|
||||
// Stub ICurrentUser cho tests cần inject ChangelogService (Contract V2 wire S29).
|
||||
// ChangelogService resolve actor qua ICurrentUser.UserId → cần stub
|
||||
// configurable per test scenario (vd BW3 admin bypass cần Roles chứa "Admin").
|
||||
//
|
||||
// Pattern: instance per test, set Acting* properties trước khi gọi service.
|
||||
// Khác ICurrentUser prod (HttpContextCurrentUser) đọc JWT claims — test
|
||||
// override trực tiếp.
|
||||
public sealed class TestCurrentUser : ICurrentUser
|
||||
{
|
||||
public Guid? UserId { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? FullName { get; set; }
|
||||
public IReadOnlyList<string> Roles { get; set; } = Array.Empty<string>();
|
||||
public bool IsAuthenticated => UserId is not null;
|
||||
|
||||
public TestCurrentUser() { }
|
||||
|
||||
public TestCurrentUser(Guid userId, string? fullName = null, string? email = null, params string[] roles)
|
||||
{
|
||||
UserId = userId;
|
||||
FullName = fullName;
|
||||
Email = email;
|
||||
Roles = roles ?? Array.Empty<string>();
|
||||
}
|
||||
|
||||
// Helper: simulate system actor (vd SLA auto-approve, DbInitializer seed).
|
||||
public static TestCurrentUser System() => new();
|
||||
}
|
||||
@ -0,0 +1,518 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||
using SolutionErp.Domain.Common;
|
||||
using SolutionErp.Domain.Contracts;
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Domain.Master;
|
||||
using SolutionErp.Infrastructure.Services;
|
||||
using SolutionErp.Infrastructure.Tests.Common;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Services;
|
||||
|
||||
// Plan C B-Wrap (S33) — Contract V2 ApproveV2Async cookie-cutter mirror PE
|
||||
// PurchaseEvaluationWorkflowServiceReturnModeTests (S23 t5). 5 [Fact] cover
|
||||
// happy/terminal/skip/outsider-guard/V1-fallback.
|
||||
//
|
||||
// Service wire 6 deps (mirror prod):
|
||||
// ContractWorkflowService(db, ContractCodeGenerator, FixedDateTime,
|
||||
// NoOpNotificationService, ChangelogService(TestCurrentUser, um),
|
||||
// UserManager<User>)
|
||||
// Lý do dùng ChangelogService thật + TestCurrentUser stub: BW1+BW3 cần assert
|
||||
// ContractChangelog row được log (summary + ContextNote). Mock service phá assertion này.
|
||||
public class ContractWorkflowServiceApproveV2Tests
|
||||
{
|
||||
private static (ContractWorkflowService svc, IdentityFixture fix, TestApplicationDbContext db, FixedDateTime dt, TestCurrentUser currentUser)
|
||||
CreateService(Guid? actorUserId = null, params string[] actorRoles)
|
||||
{
|
||||
var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||
var dt = new FixedDateTime(new DateTime(2026, 5, 26, 0, 0, 0, DateTimeKind.Utc));
|
||||
var notify = new NoOpNotificationService();
|
||||
var currentUser = new TestCurrentUser
|
||||
{
|
||||
UserId = actorUserId,
|
||||
Roles = actorRoles ?? Array.Empty<string>(),
|
||||
};
|
||||
var changelog = new ChangelogService(db, currentUser, um);
|
||||
var codeGen = new ContractCodeGenerator(db, dt);
|
||||
var svc = new ContractWorkflowService(db, codeGen, dt, notify, changelog, um);
|
||||
return (svc, fix, db, dt, currentUser);
|
||||
}
|
||||
|
||||
// Workflow setup: 1 Bước (1 Step) — 2 Cấp (2 Levels), mỗi Cấp 1 NV.
|
||||
// Default Allow* = false trên Level (admin opt-in pattern Mig 29).
|
||||
private static async Task<(ApprovalWorkflow wf, ApprovalWorkflowStep step, ApprovalWorkflowLevel l1, ApprovalWorkflowLevel l2)>
|
||||
SeedWorkflowAsync(
|
||||
TestApplicationDbContext db,
|
||||
Guid approver1UserId,
|
||||
Guid approver2UserId,
|
||||
string code = "QT-CT-001")
|
||||
{
|
||||
var wf = new ApprovalWorkflow
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = code,
|
||||
Version = 1,
|
||||
Name = "Test Contract Workflow V2",
|
||||
ApplicableType = ApprovalWorkflowApplicableType.Contract,
|
||||
IsActive = true,
|
||||
IsUserSelectable = true,
|
||||
};
|
||||
var step = new ApprovalWorkflowStep
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApprovalWorkflowId = wf.Id,
|
||||
Order = 1,
|
||||
DepartmentId = null,
|
||||
Name = "Bước 1 Phòng Kỹ Thuật",
|
||||
};
|
||||
var l1 = new ApprovalWorkflowLevel
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApprovalWorkflowStepId = step.Id,
|
||||
Order = 1,
|
||||
ApproverUserId = approver1UserId,
|
||||
};
|
||||
var l2 = new ApprovalWorkflowLevel
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApprovalWorkflowStepId = step.Id,
|
||||
Order = 2,
|
||||
ApproverUserId = approver2UserId,
|
||||
};
|
||||
db.ApprovalWorkflows.Add(wf);
|
||||
db.ApprovalWorkflowSteps.Add(step);
|
||||
db.ApprovalWorkflowLevels.Add(l1);
|
||||
db.ApprovalWorkflowLevels.Add(l2);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return (wf, step, l1, l2);
|
||||
}
|
||||
|
||||
// BW2: 1 Step × 1 Level — terminal sau Approve duy nhất.
|
||||
private static async Task<(ApprovalWorkflow wf, ApprovalWorkflowStep step, ApprovalWorkflowLevel l1)>
|
||||
SeedSingleLevelWorkflowAsync(TestApplicationDbContext db, Guid approverUserId)
|
||||
{
|
||||
var wf = new ApprovalWorkflow
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = "QT-CT-TERMINAL",
|
||||
Version = 1,
|
||||
Name = "Test Single Level",
|
||||
ApplicableType = ApprovalWorkflowApplicableType.Contract,
|
||||
IsActive = true,
|
||||
IsUserSelectable = true,
|
||||
};
|
||||
var step = new ApprovalWorkflowStep
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApprovalWorkflowId = wf.Id,
|
||||
Order = 1,
|
||||
DepartmentId = null,
|
||||
Name = "Bước duy nhất",
|
||||
};
|
||||
var l1 = new ApprovalWorkflowLevel
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApprovalWorkflowStepId = step.Id,
|
||||
Order = 1,
|
||||
ApproverUserId = approverUserId,
|
||||
};
|
||||
db.ApprovalWorkflows.Add(wf);
|
||||
db.ApprovalWorkflowSteps.Add(step);
|
||||
db.ApprovalWorkflowLevels.Add(l1);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return (wf, step, l1);
|
||||
}
|
||||
|
||||
// BW3: 3 Step × 2 Level mỗi Step + slot Cấp 1 Bước 1 set AllowApproverSkipToFinal.
|
||||
private static async Task<(ApprovalWorkflow wf, ApprovalWorkflowStep s1, ApprovalWorkflowLevel s1l1, ApprovalWorkflowLevel s1l2, ApprovalWorkflowStep s2, ApprovalWorkflowStep s3)>
|
||||
SeedMultiStepF2WorkflowAsync(
|
||||
TestApplicationDbContext db,
|
||||
Guid s1l1Approver,
|
||||
Guid s1l2Approver,
|
||||
Guid s2l1Approver,
|
||||
Guid s2l2Approver,
|
||||
Guid s3l1Approver,
|
||||
Guid s3l2Approver,
|
||||
bool allowSkipToFinalSlotS1L1 = true)
|
||||
{
|
||||
var wf = new ApprovalWorkflow
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = "QT-CT-F2",
|
||||
Version = 1,
|
||||
Name = "Test 3 Step F2 Contract",
|
||||
ApplicableType = ApprovalWorkflowApplicableType.Contract,
|
||||
IsActive = true,
|
||||
IsUserSelectable = true,
|
||||
};
|
||||
var s1 = new ApprovalWorkflowStep { Id = Guid.NewGuid(), ApprovalWorkflowId = wf.Id, Order = 1, DepartmentId = null, Name = "Bước 1 Kỹ Thuật" };
|
||||
var s2 = new ApprovalWorkflowStep { Id = Guid.NewGuid(), ApprovalWorkflowId = wf.Id, Order = 2, DepartmentId = null, Name = "Bước 2 CCM" };
|
||||
var s3 = new ApprovalWorkflowStep { Id = Guid.NewGuid(), ApprovalWorkflowId = wf.Id, Order = 3, DepartmentId = null, Name = "Bước 3 GĐ" };
|
||||
var s1l1 = new ApprovalWorkflowLevel { Id = Guid.NewGuid(), ApprovalWorkflowStepId = s1.Id, Order = 1, ApproverUserId = s1l1Approver, AllowApproverSkipToFinal = allowSkipToFinalSlotS1L1 };
|
||||
var s1l2 = new ApprovalWorkflowLevel { Id = Guid.NewGuid(), ApprovalWorkflowStepId = s1.Id, Order = 2, ApproverUserId = s1l2Approver };
|
||||
var s2l1 = new ApprovalWorkflowLevel { Id = Guid.NewGuid(), ApprovalWorkflowStepId = s2.Id, Order = 1, ApproverUserId = s2l1Approver };
|
||||
var s2l2 = new ApprovalWorkflowLevel { Id = Guid.NewGuid(), ApprovalWorkflowStepId = s2.Id, Order = 2, ApproverUserId = s2l2Approver };
|
||||
var s3l1 = new ApprovalWorkflowLevel { Id = Guid.NewGuid(), ApprovalWorkflowStepId = s3.Id, Order = 1, ApproverUserId = s3l1Approver };
|
||||
var s3l2 = new ApprovalWorkflowLevel { Id = Guid.NewGuid(), ApprovalWorkflowStepId = s3.Id, Order = 2, ApproverUserId = s3l2Approver };
|
||||
db.ApprovalWorkflows.Add(wf);
|
||||
db.ApprovalWorkflowSteps.AddRange(s1, s2, s3);
|
||||
db.ApprovalWorkflowLevels.AddRange(s1l1, s1l2, s2l1, s2l2, s3l1, s3l2);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return (wf, s1, s1l1, s1l2, s2, s3);
|
||||
}
|
||||
|
||||
// Build Contract V2 wiring helper. ContractDepartmentApprovals nav collection
|
||||
// mặc định empty — KHÔNG cần seed cho V2 happy path (PE workflow legacy V1 mới dùng).
|
||||
private static Contract BuildContractAtStep0Level(
|
||||
Guid workflowId,
|
||||
Guid supplierId,
|
||||
Guid projectId,
|
||||
Guid drafterId,
|
||||
int levelOrder = 1,
|
||||
ContractType type = ContractType.HopDongThauPhu)
|
||||
{
|
||||
return new Contract
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = type,
|
||||
Phase = ContractPhase.ChoDuyet,
|
||||
SupplierId = supplierId,
|
||||
ProjectId = projectId,
|
||||
DrafterUserId = drafterId,
|
||||
TenHopDong = "Test V2 contract",
|
||||
GiaTri = 100_000_000m,
|
||||
ApprovalWorkflowId = workflowId,
|
||||
CurrentWorkflowStepIndex = 0,
|
||||
CurrentApprovalLevelOrder = levelOrder,
|
||||
};
|
||||
}
|
||||
|
||||
// Seed Supplier + Project bằng Code cố định để gen mã RG-001 predictable.
|
||||
private static async Task<(Supplier sup, Project proj)> SeedSupplierProjectAsync(
|
||||
TestApplicationDbContext db,
|
||||
string supplierCode = "BTBM",
|
||||
string projectCode = "FLOCK01")
|
||||
{
|
||||
var sup = new Supplier
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = supplierCode,
|
||||
Name = $"NCC {supplierCode}",
|
||||
Type = SupplierType.NhaThauPhu,
|
||||
};
|
||||
var proj = new Project
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = projectCode,
|
||||
Name = $"Dự án {projectCode}",
|
||||
};
|
||||
db.Suppliers.Add(sup);
|
||||
db.Projects.Add(proj);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return (sup, proj);
|
||||
}
|
||||
|
||||
// ============ BW1: Happy path step advance Cấp 1 → Cấp 2 cùng Bước ============
|
||||
|
||||
[Fact]
|
||||
public async Task ApproveV2_FirstLevel_AdvancesToSecondLevel_SameStep()
|
||||
{
|
||||
var fix = new IdentityFixture();
|
||||
using (fix)
|
||||
{
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var approver1 = await fix.CreateUserAsync("a1-bw1@test.local", "Approver 1 BW1",
|
||||
departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||
var approver2 = await fix.CreateUserAsync("a2-bw1@test.local", "Approver 2 BW1",
|
||||
departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||
|
||||
// Recreate service với actorUserId = approver1 cho ChangelogService resolve UserName đúng
|
||||
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||
var dt = new FixedDateTime(new DateTime(2026, 5, 26, 0, 0, 0, DateTimeKind.Utc));
|
||||
var notify = new NoOpNotificationService();
|
||||
var currentUser = new TestCurrentUser { UserId = approver1.Id, Roles = new[] { AppRoles.CostControl } };
|
||||
var changelog = new ChangelogService(db, currentUser, um);
|
||||
var codeGen = new ContractCodeGenerator(db, dt);
|
||||
var svc = new ContractWorkflowService(db, codeGen, dt, notify, changelog, um);
|
||||
|
||||
var (wf, _, l1, _) = await SeedWorkflowAsync(db, approver1.Id, approver2.Id);
|
||||
var (sup, proj) = await SeedSupplierProjectAsync(db);
|
||||
var contract = BuildContractAtStep0Level(wf.Id, sup.Id, proj.Id, drafterId: Guid.NewGuid(), levelOrder: 1);
|
||||
db.Contracts.Add(contract);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await svc.TransitionAsync(
|
||||
contract: contract,
|
||||
targetPhase: ContractPhase.ChoDuyet,
|
||||
actorUserId: approver1.Id,
|
||||
actorRoles: new[] { AppRoles.CostControl },
|
||||
decision: ApprovalDecision.Approve,
|
||||
comment: "trải nghiệm test",
|
||||
ct: CancellationToken.None);
|
||||
|
||||
contract.CurrentApprovalLevelOrder.Should().Be(2, "Lên Cấp 2 cùng Bước 1");
|
||||
contract.CurrentWorkflowStepIndex.Should().Be(0, "Step không advance vì còn Cấp 2");
|
||||
contract.Phase.Should().Be(ContractPhase.ChoDuyet, "Phase giữ ChoDuyet (chưa terminal)");
|
||||
contract.SlaDeadline.Should().NotBeNull("SLA reset 7d cho Cấp 2 nhận phiếu");
|
||||
|
||||
var approvals = await db.ContractApprovals
|
||||
.Where(a => a.ContractId == contract.Id).ToListAsync();
|
||||
approvals.Should().HaveCount(1);
|
||||
approvals[0].ApproverUserId.Should().Be(approver1.Id);
|
||||
approvals[0].Decision.Should().Be(ApprovalDecision.Approve);
|
||||
|
||||
var opinions = await db.ContractLevelOpinions
|
||||
.Where(o => o.ContractId == contract.Id).ToListAsync();
|
||||
opinions.Should().HaveCount(1, "UPSERT 1 row cho slot Cấp 1");
|
||||
opinions[0].ApprovalWorkflowLevelId.Should().Be(l1.Id);
|
||||
opinions[0].Comment.Should().Be("trải nghiệm test");
|
||||
opinions[0].SignedByUserId.Should().Be(approver1.Id);
|
||||
|
||||
var changelogs = await db.ContractChangelogs
|
||||
.Where(c => c.ContractId == contract.Id
|
||||
&& c.EntityType == ChangelogEntityType.Workflow).ToListAsync();
|
||||
changelogs.Should().Contain(c => c.ContextNote != null
|
||||
&& c.ContextNote.Contains("Hoàn tất Cấp 1, sang Cấp 2 cùng Bước 1"));
|
||||
}
|
||||
}
|
||||
|
||||
// ============ BW2: Terminal Cấp cuối Bước cuối → DaPhatHanh + gen mã HĐ ============
|
||||
|
||||
[Fact]
|
||||
public async Task ApproveV2_LastLevel_FinalStep_TransitionsToDaPhatHanh_GeneratesMaHopDong()
|
||||
{
|
||||
var fix = new IdentityFixture();
|
||||
using (fix)
|
||||
{
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var approver = await fix.CreateUserAsync("a-bw2@test.local", "Approver BW2",
|
||||
departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||
|
||||
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||
var dt = new FixedDateTime(new DateTime(2026, 5, 26, 0, 0, 0, DateTimeKind.Utc));
|
||||
var notify = new NoOpNotificationService();
|
||||
var currentUser = new TestCurrentUser { UserId = approver.Id, Roles = new[] { AppRoles.CostControl } };
|
||||
var changelog = new ChangelogService(db, currentUser, um);
|
||||
var codeGen = new ContractCodeGenerator(db, dt);
|
||||
var svc = new ContractWorkflowService(db, codeGen, dt, notify, changelog, um);
|
||||
|
||||
var (wf, _, l1) = await SeedSingleLevelWorkflowAsync(db, approver.Id);
|
||||
var (sup, proj) = await SeedSupplierProjectAsync(db, supplierCode: "BTBM", projectCode: "FLOCK01");
|
||||
var contract = BuildContractAtStep0Level(wf.Id, sup.Id, proj.Id,
|
||||
drafterId: Guid.NewGuid(), levelOrder: 1, type: ContractType.HopDongThauPhu);
|
||||
db.Contracts.Add(contract);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await svc.TransitionAsync(
|
||||
contract: contract,
|
||||
targetPhase: ContractPhase.ChoDuyet,
|
||||
actorUserId: approver.Id,
|
||||
actorRoles: new[] { AppRoles.CostControl },
|
||||
decision: ApprovalDecision.Approve,
|
||||
comment: "duyệt cuối",
|
||||
ct: CancellationToken.None);
|
||||
|
||||
contract.Phase.Should().Be(ContractPhase.DaPhatHanh, "Terminal sau Cấp cuối Bước cuối");
|
||||
contract.MaHopDong.Should().NotBeNull();
|
||||
contract.MaHopDong.Should().Be("FLOCK01/HĐTP/SOL&BTBM/01",
|
||||
"RG-001 format ContractType.HopDongThauPhu");
|
||||
contract.CurrentWorkflowStepIndex.Should().BeNull();
|
||||
contract.CurrentApprovalLevelOrder.Should().BeNull();
|
||||
contract.SlaDeadline.Should().BeNull();
|
||||
|
||||
var approvals = await db.ContractApprovals
|
||||
.Where(a => a.ContractId == contract.Id).ToListAsync();
|
||||
approvals.Should().HaveCount(1);
|
||||
|
||||
var opinions = await db.ContractLevelOpinions
|
||||
.Where(o => o.ContractId == contract.Id).ToListAsync();
|
||||
opinions.Should().HaveCount(1, "Final UPSERT slot Cấp 1");
|
||||
opinions[0].ApprovalWorkflowLevelId.Should().Be(l1.Id);
|
||||
|
||||
var changelogs = await db.ContractChangelogs
|
||||
.Where(c => c.ContractId == contract.Id
|
||||
&& c.EntityType == ChangelogEntityType.Workflow).ToListAsync();
|
||||
changelogs.Should().Contain(c => c.Summary != null
|
||||
&& c.Summary.Contains("ChoDuyet") && c.Summary.Contains("DaPhatHanh"));
|
||||
}
|
||||
}
|
||||
|
||||
// ============ BW3: skipToFinal F2 admin opt-in ============
|
||||
|
||||
[Fact]
|
||||
public async Task ApproveV2_SkipToFinal_AdminTickFlag_AdvancesToLastStepLastLevel()
|
||||
{
|
||||
var fix = new IdentityFixture();
|
||||
using (fix)
|
||||
{
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var userA = await fix.CreateUserAsync("usera-bw3@test.local", "User A BW3",
|
||||
departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||
var userB = await fix.CreateUserAsync("userb-bw3@test.local", "User B BW3",
|
||||
departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||
var userC = await fix.CreateUserAsync("userc-bw3@test.local", "User C BW3",
|
||||
departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||
var userD = await fix.CreateUserAsync("userd-bw3@test.local", "User D BW3",
|
||||
departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||
var userE = await fix.CreateUserAsync("usere-bw3@test.local", "User E BW3",
|
||||
departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||
var userF = await fix.CreateUserAsync("userf-bw3@test.local", "User F BW3",
|
||||
departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||
|
||||
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||
var dt = new FixedDateTime(new DateTime(2026, 5, 26, 0, 0, 0, DateTimeKind.Utc));
|
||||
var notify = new NoOpNotificationService();
|
||||
var currentUser = new TestCurrentUser { UserId = userA.Id, Roles = new[] { AppRoles.CostControl } };
|
||||
var changelog = new ChangelogService(db, currentUser, um);
|
||||
var codeGen = new ContractCodeGenerator(db, dt);
|
||||
var svc = new ContractWorkflowService(db, codeGen, dt, notify, changelog, um);
|
||||
|
||||
var (wf, _, _, _, _, _) = await SeedMultiStepF2WorkflowAsync(
|
||||
db, userA.Id, userB.Id, userC.Id, userD.Id, userE.Id, userF.Id,
|
||||
allowSkipToFinalSlotS1L1: true);
|
||||
var (sup, proj) = await SeedSupplierProjectAsync(db, supplierCode: "BTBM", projectCode: "FLOCK01");
|
||||
var contract = BuildContractAtStep0Level(wf.Id, sup.Id, proj.Id,
|
||||
drafterId: Guid.NewGuid(), levelOrder: 1);
|
||||
db.Contracts.Add(contract);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await svc.TransitionAsync(
|
||||
contract: contract,
|
||||
targetPhase: ContractPhase.ChoDuyet,
|
||||
actorUserId: userA.Id,
|
||||
actorRoles: new[] { AppRoles.CostControl },
|
||||
decision: ApprovalDecision.Approve,
|
||||
comment: "duyệt thẳng cấp cuối",
|
||||
skipToFinal: true,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
contract.CurrentWorkflowStepIndex.Should().Be(2,
|
||||
"lastStepIdx Bước 3 (index = 3-1 = 2)");
|
||||
contract.CurrentApprovalLevelOrder.Should().Be(2,
|
||||
"lastLevelMaxOrder Cấp 2 Bước cuối");
|
||||
contract.Phase.Should().Be(ContractPhase.ChoDuyet,
|
||||
"skip advance pointer KHÔNG terminal — NV cuối vẫn cần ký thật");
|
||||
contract.SlaDeadline.Should().NotBeNull();
|
||||
|
||||
var approvals = await db.ContractApprovals
|
||||
.Where(a => a.ContractId == contract.Id).ToListAsync();
|
||||
approvals.Should().HaveCount(1);
|
||||
approvals[0].Comment.Should().StartWith("[Duyệt vượt cấp tới Cấp cuối]",
|
||||
"Prefix enrich từ ContractWorkflowService:270 khi skipToFinal=true");
|
||||
|
||||
var changelogs = await db.ContractChangelogs
|
||||
.Where(c => c.ContractId == contract.Id
|
||||
&& c.EntityType == ChangelogEntityType.Workflow).ToListAsync();
|
||||
changelogs.Should().Contain(c => c.ContextNote != null
|
||||
&& c.ContextNote.Contains("Approver skip thẳng tới Bước 3 Cấp 2"));
|
||||
}
|
||||
}
|
||||
|
||||
// ============ BW4: Outsider TransitionAsync(Approve) → ForbiddenException ============
|
||||
|
||||
[Fact]
|
||||
public async Task ApproveV2_OutsiderNonAdmin_ThrowsForbiddenException()
|
||||
{
|
||||
var fix = new IdentityFixture();
|
||||
using (fix)
|
||||
{
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var approver1 = await fix.CreateUserAsync("a1-bw4@test.local", "Approver 1 BW4",
|
||||
departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||
var approver2 = await fix.CreateUserAsync("a2-bw4@test.local", "Approver 2 BW4",
|
||||
departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||
var outsider = await fix.CreateUserAsync("out-bw4@test.local", "Outsider BW4",
|
||||
departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||
|
||||
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||
var dt = new FixedDateTime(new DateTime(2026, 5, 26, 0, 0, 0, DateTimeKind.Utc));
|
||||
var notify = new NoOpNotificationService();
|
||||
var currentUser = new TestCurrentUser { UserId = outsider.Id, Roles = new[] { AppRoles.CostControl } };
|
||||
var changelog = new ChangelogService(db, currentUser, um);
|
||||
var codeGen = new ContractCodeGenerator(db, dt);
|
||||
var svc = new ContractWorkflowService(db, codeGen, dt, notify, changelog, um);
|
||||
|
||||
var (wf, _, _, _) = await SeedWorkflowAsync(db, approver1.Id, approver2.Id);
|
||||
var (sup, proj) = await SeedSupplierProjectAsync(db);
|
||||
var contract = BuildContractAtStep0Level(wf.Id, sup.Id, proj.Id,
|
||||
drafterId: Guid.NewGuid(), levelOrder: 1);
|
||||
db.Contracts.Add(contract);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var act = async () => await svc.TransitionAsync(
|
||||
contract: contract,
|
||||
targetPhase: ContractPhase.ChoDuyet,
|
||||
actorUserId: outsider.Id,
|
||||
actorRoles: new[] { AppRoles.CostControl },
|
||||
decision: ApprovalDecision.Approve,
|
||||
comment: "outsider thử approve",
|
||||
ct: CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ForbiddenException>()
|
||||
.WithMessage("*Bước 1*Cấp 1: bạn không có trong danh sách NV duyệt*");
|
||||
contract.Phase.Should().Be(ContractPhase.ChoDuyet,
|
||||
"Guard chặn trước mutate phase");
|
||||
contract.CurrentApprovalLevelOrder.Should().Be(1, "Pointer unchanged");
|
||||
}
|
||||
}
|
||||
|
||||
// ============ BW7: V1 fallback skipToFinal non-admin → ConflictException ============
|
||||
|
||||
[Fact]
|
||||
public async Task ApproveV1Fallback_SkipToFinal_NonAdmin_ThrowsConflictException()
|
||||
{
|
||||
var fix = new IdentityFixture();
|
||||
using (fix)
|
||||
{
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var approver = await fix.CreateUserAsync("a-bw7@test.local", "Approver BW7",
|
||||
departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||
|
||||
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||
var dt = new FixedDateTime(new DateTime(2026, 5, 26, 0, 0, 0, DateTimeKind.Utc));
|
||||
var notify = new NoOpNotificationService();
|
||||
var currentUser = new TestCurrentUser { UserId = approver.Id, Roles = new[] { AppRoles.CostControl } };
|
||||
var changelog = new ChangelogService(db, currentUser, um);
|
||||
var codeGen = new ContractCodeGenerator(db, dt);
|
||||
var svc = new ContractWorkflowService(db, codeGen, dt, notify, changelog, um);
|
||||
|
||||
var (sup, proj) = await SeedSupplierProjectAsync(db);
|
||||
// Contract V1 legacy — ApprovalWorkflowId = null
|
||||
var contract = new Contract
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = ContractType.HopDongThauPhu,
|
||||
Phase = ContractPhase.ChoDuyet,
|
||||
SupplierId = sup.Id,
|
||||
ProjectId = proj.Id,
|
||||
DrafterUserId = Guid.NewGuid(),
|
||||
TenHopDong = "V1 legacy contract",
|
||||
GiaTri = 50_000_000m,
|
||||
ApprovalWorkflowId = null,
|
||||
WorkflowDefinitionId = null,
|
||||
CurrentWorkflowStepIndex = 0,
|
||||
};
|
||||
db.Contracts.Add(contract);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var act = async () => await svc.TransitionAsync(
|
||||
contract: contract,
|
||||
targetPhase: ContractPhase.ChoDuyet,
|
||||
actorUserId: approver.Id,
|
||||
actorRoles: new[] { AppRoles.CostControl },
|
||||
decision: ApprovalDecision.Approve,
|
||||
comment: "thử skipToFinal V1",
|
||||
skipToFinal: true,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>()
|
||||
.WithMessage("*skipToFinal chỉ hỗ trợ HĐ V2*");
|
||||
contract.Phase.Should().Be(ContractPhase.ChoDuyet, "State unchanged");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user