diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 146f956..794808b 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -1,19 +1,22 @@ # HANDOFF — Brief 5 phút cho session tiếp theo -**Last updated:** 2026-04-23 tối (Phase 6 — PE skeleton + G-084 hardening, **PE còn chỉnh nhiều**) +**Last updated:** 2026-04-24 sáng (Phase 6 — PE polish: demo seed + MaPhieu atomic + Pe_* perm defaults) ## TL;DR **PE module skeleton E2E** — 2 quy trình A/B config được admin, 10 bảng, 17 endpoint, 3 page × 2 app + kế thừa HĐ 1-click. BE + FE + migration đã push -lên Gitea main (`2c6f0ca..3990066`, 6 commit). **CHƯA XONG — nhiều polish -+ feature thiếu cho session tiếp** (xem STATUS.md §🔥 In Progress đầy đủ). +lên Gitea main (`2c6f0ca..3990066`, 6+ commit). **Polish session 2026-04-24:** +demo seed 4 phiếu (`[DEMO] PE/.../A-001..A-003 + B-001`) + MaPhieu atomic +sequence (mirror ContractCodeSequences) + Pe_* permission defaults cho 7 +role business → admin login thấy data ngay. Còn polish nhỏ (designer UI, +attachments, details mapping, export PDF) — xem STATUS.md §🔥 In Progress. **G-084 hardening:** localhost → 127.0.0.1 trong scripts + skill, doc gotcha #33 từ bài học VietReport (IPv4/IPv6 port hijack trên VPS shared). -**Tổng:** 46 DB tables, ~110 endpoints, 12 migrations, 33 gotchas. Blocker -user/ops cũ vẫn còn: UAT thật + SMTP + rotate creds + runner apply. +**Tổng:** 47 DB tables (+1 `PurchaseEvaluationCodeSequences`), ~110 endpoints, +13 migrations (+13 `AddPurchaseEvaluationCodeSequences`), 33 gotchas. ## ⚠️ CẢNH BÁO session tiếp @@ -110,9 +113,9 @@ Xem **STATUS.md §🔥 In Progress** đầy đủ (nhóm A/B/C/D). Tóm tắt nh 2. **PE Attachments upload** — copy pattern `ContractAttachmentFeatures.cs` + `ContractAttachmentsSection.tsx`. Entity + enum có sẵn. 3. **Ý kiến 4 phòng ban** (Phê duyệt / P.CCM / P.MuaHàng / SM-PM) — Excel form có, entity chưa map. Cần design: 4 text field + signoff date, hoặc dùng Approvals với role-kind. 4. **Payment terms tách field** từ JSON blob → 6 field riêng (Tạm ứng / TT tạm / Quyết toán / Bảo hành / Hạn mức / Đánh giá) theo Excel section D. -5. **Seed demo PE data** — 2-3 phiếu cho UAT + screenshot. -6. **Permission grant Pe_* cho non-admin role** — `/system/permissions` tick `PurchaseEvaluations.Read` + CRUD cho role Drafter/PRO/CCM/BOD. -7. **MaPhieu format chính thức** — user confirm. +5. ~~Seed demo PE data~~ ✅ DONE (2026-04-24, 4 phiếu varied A/B × phase). +6. ~~Permission grant Pe_* defaults~~ ✅ DONE (2026-04-24, 7 role × 9 menu key). +7. ~~MaPhieu format chính thức~~ ✅ DONE (2026-04-24, atomic `PE/{YYYY}/{A\|B}/{Seq:D3}`). 8. **Export phiếu PDF/Excel** — tái dùng `IDocumentConverter` + template. 9. **Auto-map PE Details → Contract Details** khi gen HĐ (optional — nâng cấp). diff --git a/docs/STATUS.md b/docs/STATUS.md index 3faf618..28da809 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -2,9 +2,9 @@ > **Update rule:** trước khi bắt đầu 1 task → ghi row vào `🔥 In Progress`. Xong → chuyển sang `✅ Recently Done`. -**Last updated:** 2026-04-23 tối (Phase 6 — PE module **skeleton E2E + G-084 hardening, CÒN CHỈNH NHIỀU**) +**Last updated:** 2026-04-24 sáng (Phase 6 — PE module **demo seed + MaPhieu atomic + Pe_* perm defaults**) -## 📍 Phase hiện tại: **Module Duyệt NCC (tiền-HĐ) — skeleton deployed, refinement WIP** — 46 DB tables (+10 PE), ~110 endpoints (+17 PE), 12 migrations, 6 commits PE push Gitea (`2c6f0ca..3990066`). Skeleton BE + FE + kế thừa HĐ hoạt động; còn nhiều polish + feature thiếu (designer UI, attachments, details mapping, export PDF). +## 📍 Phase hiện tại: **Module Duyệt NCC (tiền-HĐ) — skeleton deployed, refinement WIP** — 47 DB tables (+10 PE +1 MaPhieu seq), ~110 endpoints (+17 PE), 13 migrations (+13 `AddPurchaseEvaluationCodeSequences`), 6+ commits PE push Gitea. Skeleton BE + FE + kế thừa HĐ + 4 [DEMO] PE seed + Pe_* perm defaults hoạt động; còn polish: designer UI, attachments, details mapping, export PDF. ### 🌐 Production URLs @@ -27,8 +27,8 @@ - Menu leaf `PeWf_*` đã seed rồi, resolver Layout.tsx đã map `/system/pe-workflows/:code` - [ ] **PE Attachments upload** — pattern copy từ `ContractAttachmentFeatures.cs` + FE `ContractAttachmentsSection.tsx`. Entity + `PurchaseEvaluationAttachmentPurpose` enum đã sẵn (QuoteDocument/RequirementSpec/DecisionExport). - [ ] **Auto-map PE Details → Contract 7 per-type Details khi gen HĐ** — hiện `CreateContractFromEvaluationCommand` chỉ copy header + GiaTri, KHÔNG copy Details. Cần mapping per ContractType (PE Detail schema flat ≠ 7 Contract Details schemas). -- [ ] **Demo PE data seed** — chưa có `[DEMO]` PE nào. Cần 2-3 phiếu seed (1 NccOnly đã DaDuyet chưa tạo HĐ, 1 NccWithPlan ở phase ChoCCM giữa chừng, 1 TuChoi) cho UAT + screenshot. -- [ ] **MaPhieu format chính thức** — hiện `PE-YYYYMM-XXXX` random 4-digit. User said "tính sau". Cần confirm format: `{ProjectCode}/PE/{seq:D3}` hoặc atomic sequence bảng như `ContractCodeSequences`. +- [x] ✅ **Demo PE data seed** (2026-04-24) — 4 phiếu `[DEMO]` cover full state-space: A-001 DangSoanThao (Drafter mới mở), A-002 ChoCEODuyetNCC (winner đề xuất + 9 quotes), A-003 DaDuyet (chưa tạo HĐ — kế thừa demo), B-001 ChoDuAn (5-step giữa chừng). Idempotent skip-if-exists. PaymentTerms JSON cho A-003. +- [x] ✅ **MaPhieu atomic sequence** (2026-04-24) — Migration 13 `AddPurchaseEvaluationCodeSequences` (1 bảng, Prefix PK). `IPurchaseEvaluationCodeGenerator` + `PurchaseEvaluationCodeGenerator` (SERIALIZABLE transaction, mirror ContractCodeGenerator). Format `PE/{YYYY}/{TypeLetter}/{Seq:D3}` — VD `PE/2026/A/001`. Wired vào CreatePurchaseEvaluationCommandHandler thay Random.Shared. DI registered. - [ ] **Section "Ý kiến các phòng ban" (Phê duyệt/P.CCM/P.MuaHàng/SM-PM) ở tab Thông tin** — Excel form mẫu có, entity hiện chưa map. Cần thêm 4-8 text field + signoff date (hoặc dùng Approvals row như ContractApprovals). ### B. UX / Polish cần chỉnh @@ -36,7 +36,7 @@ - [ ] **Payment terms chi tiết** — hiện JSON blob. UX tách field riêng (Tạm ứng / TT tạm / Quyết toán / Bảo hành / Hạn mức / Đánh giá) theo Excel section D. - [ ] **Matrix Quotes bulk paste** — click cell → popup: OK. Nhưng bulk paste column giá từ Excel chưa có (power user feature). - [ ] **Export phiếu PDF/Excel** — user cần bản in ký. Tái dùng `IDocumentConverter` + template `PE-TrinhDuyet.docx`. -- [ ] **Permission grant Pe_* cho non-admin role** — admin thấy menu Pe_*, non-admin chưa. Cần vào `/system/permissions` tick `PurchaseEvaluations.Read` cho role Drafter/Procurement/CostControl/Director. +- [x] ✅ **Permission grant Pe_* cho non-admin role** (2026-04-24) — `SeedPurchaseEvaluationPermissionDefaultsAsync` grant CRUD per role: Drafter/DeptManager/Procurement (R+C+U) · CostControl/PM/Director/AuthorizedSigner (R+U). DeptManager thêm Delete. Idempotent per-(roleId × menuKey). 9 menu key (PurchaseEvaluations + 2× group + 6× leaf) × 7 role = ~63 row defaults. Sau seed admin bật/tắt finer-grain qua `/system/permissions`. - [ ] **fe-user Inbox** — hiện chỉ HĐ. Cần thêm section "Phiếu Duyệt NCC chờ tôi" (hoặc route `/pe-inbox` riêng). - [ ] **Sidebar accordion fe-user** — test `Pe_DuyetNcc` group với `Ct_*` accordion context (Layout.tsx fe-user accordion hiện chỉ track `Ct_`, có thể cần extend cover `Pe_`). @@ -58,6 +58,7 @@ | Ngày | Ai | Task | Commit | |---|---|---|---| +| 2026-04-24 | Claude | **PE polish — Demo seed + MaPhieu atomic + Pe_* perm defaults** — Migration 13 `AddPurchaseEvaluationCodeSequences` (Prefix PK, mirror ContractCodeSequences). `IPurchaseEvaluationCodeGenerator` + impl SERIALIZABLE — format `PE/{YYYY}/{A\|B}/{Seq:D3}`. Replace Random.Shared trong CreatePEHandler. `SeedDemoPurchaseEvaluationsAsync` 4 phiếu varied (A-001 DangSoanThao, A-002 ChoCEODuyetNCC + 9 quotes, A-003 DaDuyet PaymentTerms JSON, B-001 ChoDuAn 5-step). `SeedPurchaseEvaluationPermissionDefaultsAsync` 7 role × 9 menu key — Drafter/DeptManager/Procurement Create+Update, các role duyệt R+U, DeptManager thêm Delete. | (pending) | | 2026-04-24 | Claude | **Rebrand 3 domain huypham.vn → solutions.com.vn E2E** — 18 file repo (FE env + scripts + CI/CD + docs + skill + code comments). Viết `scripts/migrate-domains.ps1` (ASCII-only gotcha #30) chạy trên VPS: 3 HTTP binding mới + 3 cert Let's Encrypt (HTTP-01 via SelfHosting) + auto HTTPS 443 + redirect. CI/CD auto-deploy commit `66c1a5c+b93dacf`: appsettings.Production.json CORS mới + FE bundle `VITE_API_BASE_URL=api.solutions.com.vn`. E2E verified: `/api/purchase-evaluations` 401, CORS preflight OK, FE dist có domain mới. URL cũ fallback. Claude SSH tự chạy (không cần user RDP). | `66c1a5c` · `b93dacf` | | 2026-04-23 | Claude | **SeedDemoUsersAsync robust reconcile + 16 demo users** — Reconcile pattern (per-user try-catch, fix drift dept/position/role nếu user existing). 13 → 16 demo (+bod.tran NĐUQ thứ 2, +pm.le PM thứ 2, +nv.truong NV CCM). Detailed log created/fixed/failed counts. Resolves prod issue: chỉ thấy admin user vì old skip-if-exists fail silent. | `a667665` | | 2026-04-23 | Claude | **G-084 hardening** — localhost → 127.0.0.1 trong `deploy-iis.ps1` + skill `iis-deploy-runbook`. Thêm gotcha #33 (IPv4/IPv6 port hijack) ref VietReport incident + 3 rules (reverse-proxy IP literal / backend loopback IPv4 explicit / service dependency). SOLUTION_ERP risk thấp (API in-process IIS, no ARR proxy) nhưng chuẩn hóa cho tương lai. | `3990066` | diff --git a/docs/changelog/migration-todos.md b/docs/changelog/migration-todos.md index 45f76d3..aaf6e9b 100644 --- a/docs/changelog/migration-todos.md +++ b/docs/changelog/migration-todos.md @@ -336,19 +336,29 @@ - [ ] **Auto-map PE Details → Contract Details khi gen HĐ** (optional — nâng cấp) - 7 mapping function per ContractType (khó vì schema khác biệt) - MVP: skip, user nhập manual -- [ ] **Demo PE data seed** — `SeedDemoPurchaseEvaluationsAsync` - - 1 NccOnly DaDuyet chưa tạo HĐ (showcase kế thừa button) - - 1 NccWithPlan phase ChoCCM (showcase workflow middle) - - 1 TuChoi (showcase reject path) -- [ ] **MaPhieu format chính thức** — hiện `PE-YYYYMM-XXXX` random. User confirm format + atomic sequence: - - Option A: `{ProjectCode}/PE/{yyyy}/{seq:D3}` — atomic via `PurchaseEvaluationCodeSequences` bảng mới - - Option B: Tái dùng `ContractCodeSequences` với Prefix = `PE-{yyyy}-{ProjectCode}` +- [x] ✅ **Demo PE data seed** — `SeedDemoPurchaseEvaluationsAsync` (2026-04-24) + - A-001 DangSoanThao (Drafter mới mở, chưa có quotes) + - A-002 ChoCEODuyetNCC (winner đề xuất + 9 quotes 3×3 grid) + - A-003 DaDuyet (chưa tạo HĐ — showcase kế thừa button) + PaymentTerms JSON + - B-001 ChoDuAn (5-step giữa chừng) + - Idempotent: skip-if-`[DEMO]` exists +- [x] ✅ **MaPhieu format chính thức + atomic sequence** (2026-04-24) + - Format: `PE/{YYYY}/{TypeLetter}/{Seq:D3}` (TypeLetter = A | B) + - `PurchaseEvaluationCodeSequences` bảng mới (Prefix PK, mirror ContractCodeSequences) + - `IPurchaseEvaluationCodeGenerator` + impl SERIALIZABLE transaction + - Migration 13 `AddPurchaseEvaluationCodeSequences` (1 bảng) + - Wired vào CreatePurchaseEvaluationCommandHandler thay Random.Shared ### B. UX / Polish - [ ] Matrix Quotes **bulk paste** từ Excel column giá → paste matrix row - [ ] **Export phiếu PDF/Excel** — tái dùng `IDocumentConverter` + template `PE-TrinhDuyet.docx` upload qua FormsPage -- [ ] **Permission grant Pe_* cho non-admin role** — 1 click trong PermissionsPage matrix (Pe_* inherit từ `PurchaseEvaluations.Read` tương tự Contracts inheritance) +- [x] ✅ **Permission grant Pe_* cho non-admin role** (2026-04-24) + - `SeedPurchaseEvaluationPermissionDefaultsAsync` — 7 role × 9 menu key + - Drafter/DeptManager/Procurement: R+C+U + - CostControl/PM/Director/AuthorizedSigner: R+U + - DeptManager thêm Delete (xóa nháp) + - Idempotent per-(roleId × menuKey), admin tinh chỉnh tiếp qua /system/permissions - [ ] **fe-user Inbox** thêm section "Phiếu Duyệt NCC chờ tôi" (hoặc route `/pe-inbox`) - [ ] **Sidebar accordion fe-user** extend cover `Pe_` (hiện chỉ track `Ct_`) - [ ] **Dashboard** — thêm KPI "PE phiếu chờ tôi", "PE đã duyệt tháng này", "PE cần tạo HĐ" diff --git a/docs/changelog/sessions/2026-04-24-1030-pe-polish-demo-maphieu-perms.md b/docs/changelog/sessions/2026-04-24-1030-pe-polish-demo-maphieu-perms.md new file mode 100644 index 0000000..ff3f0f8 --- /dev/null +++ b/docs/changelog/sessions/2026-04-24-1030-pe-polish-demo-maphieu-perms.md @@ -0,0 +1,127 @@ +# Session 2026-04-24 sáng — PE polish: Demo seed + MaPhieu atomic + Pe_* perm defaults + +**Context:** Tiếp nối session "[Roles+Demo+Pending cleanup]" (commit a667665) — user yêu cầu +"Chốt lại toàn bộ MD: 'rules, architech, gotcha, skill, daily, hand-off, DB, +luồng DB ...' và làm tiếp đi nhé." → continue PE polish theo STATUS.md §🔥 In +Progress nhóm A (chức năng MISSING trong MVP). + +## Tasks completed (4) + +### 1. Demo PE data seed — 4 phiếu varied + +**File:** `src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs` + +`SeedDemoPurchaseEvaluationsAsync` — pattern mirror `SeedDemoContractsAsync`: + +| Phiếu | Type | Phase | Đặc điểm | Quotes | +|---|---|---|---|---| +| `[DEMO]-A-001` | DuyetNcc | DangSoanThao | Drafter mới mở, chưa nhập giá | 0 | +| `[DEMO]-A-002` | DuyetNcc | ChoCEODuyetNCC | Winner đề xuất + 9 quotes 3×3 grid | 9 | +| `[DEMO]-A-003` | DuyetNcc | DaDuyet | Chưa tạo HĐ — showcase kế thừa + PaymentTerms JSON | 4 | +| `[DEMO]-B-001` | DuyetNccPhuongAn | ChoDuAn | 5-step giữa chừng (Procurement đã chuyển) | 9 | + +**Idempotent:** skip nếu MaPhieu prefix `[DEMO]` exists. Approval history seed +đúng theo policy A (3-step Procurement→CCM→CEO) hoặc B (5-step +Procurement→PM→CCM→CEO PA→CEO NCC). ApproverUserId mapping đúng role. + +**Resolves:** STATUS.md §🔥 A — "Demo PE data seed". + +### 2. MaPhieu atomic sequence — Migration 13 + +**Files mới:** +- `src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationCodeSequence.cs` — Prefix PK + LastSeq + UpdatedAt +- `src/Backend/SolutionErp.Application/PurchaseEvaluations/Services/IPurchaseEvaluationWorkflowService.cs` — thêm `IPurchaseEvaluationCodeGenerator` interface +- `src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationCodeGenerator.cs` — impl SERIALIZABLE transaction +- `src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260424033449_AddPurchaseEvaluationCodeSequences.cs` — 1 bảng (Prefix nvarchar(100) PK) + +**Files modified:** +- `IApplicationDbContext` + `ApplicationDbContext` thêm `PurchaseEvaluationCodeSequences` DbSet +- `PurchaseEvaluationConfiguration` thêm `PurchaseEvaluationCodeSequenceConfiguration` (Prefix max 100) +- `DependencyInjection` register Scoped `IPurchaseEvaluationCodeGenerator → PurchaseEvaluationCodeGenerator` +- `PurchaseEvaluationFeatures` `CreatePurchaseEvaluationCommandHandler` constructor inject codeGen, replace `Random.Shared` với `await codeGen.GenerateAsync(entity, ct)` + +**Format:** `PE/{YYYY}/{TypeLetter}/{Seq:D3}` — VD `PE/2026/A/001`, `PE/2026/B/001`. +TypeLetter = `A` (DuyetNcc) | `B` (DuyetNccPhuongAn). + +**Pattern mirror:** `ContractCodeGenerator` 1:1 — SERIALIZABLE transaction + +UPDATE-or-INSERT seq row + commit. Race-safe trên multi-instance. + +**Resolves:** STATUS.md §🔥 A — "MaPhieu format chính thức". + +### 3. Pe_* permission defaults — 7 role × 9 menu key + +**File:** `src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs` + +`SeedPurchaseEvaluationPermissionDefaultsAsync` — call cuối +`SeedAdminPermissionsAsync`. Strategy: + +| Role | Read | Create | Update | Delete | +|---|---|---|---|---| +| Drafter, DeptManager, Procurement | ✓ | ✓ | ✓ | (chỉ DeptManager) | +| CostControl, ProjectManager, Director, AuthorizedSigner | ✓ | — | ✓ | — | + +9 menu key per role: `PurchaseEvaluations` (root) + 2 group (`Pe_DuyetNcc`, +`Pe_DuyetNccPhuongAn`) + 6 leaf (List/Create/Pending × 2). Total ~63 row insert. + +**Idempotent:** skip per-(roleId × menuKey) đã có row. Admin tinh chỉnh thêm +qua `/system/permissions`. + +**Resolves:** STATUS.md §🔥 B — "Permission grant Pe_*". + +### 4. Docs updates + +- `docs/STATUS.md` — Phase line 7 "47 DB tables", "13 migrations". Tick `Demo PE data seed`, `MaPhieu format chính thức`, `Permission grant Pe_*`. Add Recently Done row. +- `docs/HANDOFF.md` — TL;DR update, gạch các item ✅ DONE, "47 DB tables" "13 migrations". +- `docs/changelog/migration-todos.md` — Tick 3 task PE.A. + +## Build status + +``` +dotnet build SolutionErp.slnx --nologo -v minimal +→ Build succeeded. 2 Warning(s) (pre-existing DocxRenderer), 0 Error(s). +``` + +## Files touched (12) + +**New (4):** +- `src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationCodeSequence.cs` +- `src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationCodeGenerator.cs` +- `src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260424033449_AddPurchaseEvaluationCodeSequences.cs` + `.Designer.cs` + +**Modified (8):** +- `src/Backend/SolutionErp.Application/Common/Interfaces/IApplicationDbContext.cs` +- `src/Backend/SolutionErp.Application/PurchaseEvaluations/Services/IPurchaseEvaluationWorkflowService.cs` +- `src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs` +- `src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs` +- `src/Backend/SolutionErp.Infrastructure/Persistence/ApplicationDbContext.cs` +- `src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/PurchaseEvaluationConfiguration.cs` +- `src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs` +- `src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs` + +## Pending after this session (xem STATUS.md §🔥 In Progress) + +### A. Còn MISSING +- PE Workflow admin designer UI (`/system/pe-workflows/:typeCode`) +- PE Attachments upload (entity + enum sẵn) +- Auto-map PE Details → Contract 7 per-type Details khi gen HĐ +- Section "Ý kiến phòng ban" trong PE form + +### B. UX +- Payment terms tách field (JSON → 6 field) +- Matrix Quotes bulk paste từ Excel +- Export PDF/Excel +- fe-user Inbox PE section +- Sidebar accordion fe-user PE + +### C. Edge case +- Reject path E2E +- Delete với linked contract +- Workflow v02 invariant pin v01 + +### D. Deploy +- win-acme task fix +- Remove huypham.vn old binding sau verify stable + +## Commit + +`[CLAUDE] PurchaseEvaluation: demo seed 4 phiếu + MaPhieu atomic sequence + Pe_* perm defaults` diff --git a/src/Backend/SolutionErp.Application/Common/Interfaces/IApplicationDbContext.cs b/src/Backend/SolutionErp.Application/Common/Interfaces/IApplicationDbContext.cs index 8b4d539..22d08b3 100644 --- a/src/Backend/SolutionErp.Application/Common/Interfaces/IApplicationDbContext.cs +++ b/src/Backend/SolutionErp.Application/Common/Interfaces/IApplicationDbContext.cs @@ -56,6 +56,7 @@ public interface IApplicationDbContext DbSet PurchaseEvaluationWorkflowDefinitions { get; } DbSet PurchaseEvaluationWorkflowSteps { get; } DbSet PurchaseEvaluationWorkflowStepApprovers { get; } + DbSet PurchaseEvaluationCodeSequences { get; } Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs index d3bc88a..a15a83b 100644 --- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs @@ -39,7 +39,8 @@ public class CreatePurchaseEvaluationCommandValidator : AbstractValidator + IPurchaseEvaluationWorkflowService workflow, + IPurchaseEvaluationCodeGenerator codeGen) : IRequestHandler { public async Task Handle(CreatePurchaseEvaluationCommand request, CancellationToken ct) { @@ -67,8 +68,9 @@ public class CreatePurchaseEvaluationCommandHandler( workflow.GetPhaseSla(PurchaseEvaluationPhase.DangSoanThao) ?? TimeSpan.FromDays(3)), }; - // Auto-gen MaPhieu đơn giản PE-YYYYMM-XXXX (4 digit random) — format sau - entity.MaPhieu = $"PE-{DateTime.UtcNow:yyyyMM}-{Random.Shared.Next(1000, 9999)}"; + // Atomic MaPhieu sequence — format PE/{YYYY}/{TypeLetter}/{Seq:D3} + // (mirror IContractCodeGenerator pattern, transaction SERIALIZABLE). + entity.MaPhieu = await codeGen.GenerateAsync(entity, ct); db.PurchaseEvaluations.Add(entity); diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/Services/IPurchaseEvaluationWorkflowService.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/Services/IPurchaseEvaluationWorkflowService.cs index 2839ba2..9113ca4 100644 --- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/Services/IPurchaseEvaluationWorkflowService.cs +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/Services/IPurchaseEvaluationWorkflowService.cs @@ -18,3 +18,15 @@ public interface IPurchaseEvaluationWorkflowService TimeSpan? GetPhaseSla(PurchaseEvaluationPhase phase); } + +// Atomic sequence generator cho mã PE (MaPhieu) — mirror IContractCodeGenerator. +// Format: PE/{YYYY}/{TypeLetter}/{Seq:D3} +// - YYYY = năm hiện tại (UTC) +// - TypeLetter = "A" (DuyetNcc) / "B" (DuyetNccPhuongAn) +// - Seq = 3 chữ số tăng dần per (year × type) +// VD: PE/2026/A/001, PE/2026/A/002, PE/2026/B/001 +public interface IPurchaseEvaluationCodeGenerator +{ + // Gen mã phiếu atomic. Transaction SERIALIZABLE để tránh race condition. + Task GenerateAsync(PurchaseEvaluation evaluation, CancellationToken ct = default); +} diff --git a/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationCodeSequence.cs b/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationCodeSequence.cs new file mode 100644 index 0000000..08095c7 --- /dev/null +++ b/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationCodeSequence.cs @@ -0,0 +1,11 @@ +namespace SolutionErp.Domain.PurchaseEvaluations; + +// Sequence generator cho mã PE (MaPhieu) — mirror ContractCodeSequence pattern. +// Prefix = phần đầu mã (vd "PE/2026/A" cho A type 2026, "PE/2026/B" cho B type). +// LastSeq tăng dần, update atomic qua transaction SERIALIZABLE. +public class PurchaseEvaluationCodeSequence +{ + public string Prefix { get; set; } = string.Empty; + public int LastSeq { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs b/src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs index 8f0ecc0..79a8920 100644 --- a/src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs +++ b/src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs @@ -34,6 +34,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/ApplicationDbContext.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/ApplicationDbContext.cs index 89aa543..35be23a 100644 --- a/src/Backend/SolutionErp.Infrastructure/Persistence/ApplicationDbContext.cs +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/ApplicationDbContext.cs @@ -57,6 +57,7 @@ public class ApplicationDbContext public DbSet PurchaseEvaluationWorkflowDefinitions => Set(); public DbSet PurchaseEvaluationWorkflowSteps => Set(); public DbSet PurchaseEvaluationWorkflowStepApprovers => Set(); + public DbSet PurchaseEvaluationCodeSequences => Set(); protected override void OnModelCreating(ModelBuilder builder) { diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/PurchaseEvaluationConfiguration.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/PurchaseEvaluationConfiguration.cs index 57991a9..92154df 100644 --- a/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/PurchaseEvaluationConfiguration.cs +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/PurchaseEvaluationConfiguration.cs @@ -203,3 +203,16 @@ public class PurchaseEvaluationWorkflowStepApproverConfiguration : IEntityTypeCo .OnDelete(DeleteBehavior.Cascade); } } + +// Mirror ContractCodeSequenceConfiguration — Prefix là PK, atomic UPDATE qua +// SERIALIZABLE transaction trong PurchaseEvaluationCodeGenerator. +public class PurchaseEvaluationCodeSequenceConfiguration + : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder b) + { + b.ToTable("PurchaseEvaluationCodeSequences"); + b.HasKey(x => x.Prefix); + b.Property(x => x.Prefix).HasMaxLength(100); + } +} diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs index fe15d03..d418368 100644 --- a/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs @@ -671,6 +671,323 @@ public static class DbInitializer c1.MaHopDong, c2.MaHopDong, c3.MaHopDong, c4.MaHopDong, c5.MaHopDong, c6.MaHopDong, c7.MaHopDong); } + // Seed 4 demo PE phiếu — varied Type (A/B) × Phase (DangSoanThao / mid / + // ChoCEODuyetNCC / DaDuyet). Idempotent: skip nếu MaPhieu prefix `[DEMO]` + // đã tồn tại (KHÔNG backfill — vì Quotes/Approvals nested khó merge). + // + // Mỗi phiếu có: + // - 2-3 NCC tham gia thầu (Suppliers) + // - 2-4 hạng mục (Details — group A.I/A.II/A.III) + // - N×M Quotes (báo giá per supplier × hạng mục) + // - Approval history theo phase đã đi qua + // - Phiếu DaDuyet có SelectedSupplierId (winner) + // + // MaPhieu format tạm hardcode `[DEMO]-A-001` etc. — sau này MaPhieu atomic + // sequence (migration 13) sẽ replace. + private static async Task SeedDemoPurchaseEvaluationsAsync( + ApplicationDbContext db, UserManager userManager, ILogger logger) + { + // Skip nếu đã có demo PE + if (await db.PurchaseEvaluations.AnyAsync(p => p.MaPhieu != null && p.MaPhieu!.StartsWith("[DEMO]"))) + { + logger.LogInformation("SeedDemoPurchaseEvaluationsAsync: skip — đã có [DEMO] PE."); + return; + } + + // Lookup masters + var suppliersByCode = await db.Suppliers.ToDictionaryAsync(s => s.Code, s => s); + var projectsByCode = await db.Projects.ToDictionaryAsync(p => p.Code, p => p); + if (suppliersByCode.Count < 3 || projectsByCode.Count == 0) + { + logger.LogWarning("SeedDemoPurchaseEvaluationsAsync: skip — thiếu Supplier/Project master."); + return; + } + + // Lookup actor users (per role) + var qsHoang = await userManager.FindByEmailAsync("qs.hoang@solutionerp.local"); + var proPham = await userManager.FindByEmailAsync("pro.pham@solutionerp.local"); + var ccmTran = await userManager.FindByEmailAsync("ccm.tran@solutionerp.local"); + var pmNguyen = await userManager.FindByEmailAsync("pm.nguyen@solutionerp.local"); + var bodHuynh = await userManager.FindByEmailAsync("bod.huynh@solutionerp.local"); + var qsDeptId = (await db.Departments.FirstOrDefaultAsync(d => d.Code == "QS"))?.Id; + var nowUtc = DateTime.UtcNow; + + // Helper: build PE phiếu với Suppliers + Details + Quotes + Approvals + async Task createDemoPeAsync( + string maPhieu, + PurchaseEvaluationType type, + PurchaseEvaluationPhase finalPhase, + string tenGoiThau, + string projectCode, + string? diaDiem, + string? moTa, + string[] supplierCodes, // 2-3 supplier codes + (string GroupCode, string GroupName, string ItemCode, string NoiDung, string Dvt, decimal KlNs, decimal KlTc, decimal DgNs)[] detailRows, + // Quotes: index by [detailIdx][supplierIdx] → (BgVat, ChuaVat, IsSelected) + (decimal BgVat, decimal ChuaVat, bool IsSelected)[][] quotesGrid, + string? winnerSupplierCode = null, + string? paymentTermsJson = null) + { + var activeWfId = await db.PurchaseEvaluationWorkflowDefinitions.AsNoTracking() + .Where(w => w.EvaluationType == type && w.IsActive) + .Select(w => (Guid?)w.Id) + .FirstOrDefaultAsync(); + + var project = projectsByCode.TryGetValue(projectCode, out var p) ? p : projectsByCode.Values.First(); + + var pe = new PurchaseEvaluation + { + MaPhieu = $"[DEMO]-{maPhieu}", + Type = type, + Phase = PurchaseEvaluationPhase.DangSoanThao, // tạo ở phase 1, transition sau + TenGoiThau = $"[DEMO] {tenGoiThau}", + ProjectId = project.Id, + DepartmentId = qsDeptId, + DrafterUserId = qsHoang?.Id, + DiaDiem = diaDiem, + MoTa = moTa, + WorkflowDefinitionId = activeWfId, + SlaDeadline = nowUtc.AddDays(7), + PaymentTerms = paymentTermsJson, + }; + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(); + + // Suppliers + var supplierEntities = new List(); + for (int i = 0; i < supplierCodes.Length; i++) + { + var sCode = supplierCodes[i]; + if (!suppliersByCode.TryGetValue(sCode, out var supplier)) continue; + var pes = new PurchaseEvaluationSupplier + { + PurchaseEvaluationId = pe.Id, + SupplierId = supplier.Id, + DisplayName = supplier.Name, + ContactName = supplier.ContactPerson, + ContactEmail = supplier.Email, + ContactPhone = supplier.Phone, + PaymentTermText = i == 0 ? "TGN-30 ngày" : i == 1 ? "TGN-45 ngày" : "Tiến độ", + Note = i == 0 ? "ĐÃ CHỐT SO SÁNH LẦN 1" : null, + Order = i + 1, + }; + db.PurchaseEvaluationSuppliers.Add(pes); + supplierEntities.Add(pes); + } + await db.SaveChangesAsync(); + + // Details + var detailEntities = new List(); + for (int i = 0; i < detailRows.Length; i++) + { + var (gc, gn, ic, nd, dvt, klNs, klTc, dgNs) = detailRows[i]; + var det = new PurchaseEvaluationDetail + { + PurchaseEvaluationId = pe.Id, + GroupCode = gc, GroupName = gn, + ItemCode = ic, NoiDung = nd, DonViTinh = dvt, + KhoiLuongNganSach = klNs, + KhoiLuongThiCong = klTc, + DonGiaNganSach = dgNs, + ThanhTienNganSach = klNs * dgNs, + Order = i + 1, + }; + db.PurchaseEvaluationDetails.Add(det); + detailEntities.Add(det); + } + await db.SaveChangesAsync(); + + // Quotes: detailIdx × supplierIdx + for (int di = 0; di < detailEntities.Count; di++) + { + if (di >= quotesGrid.Length) break; + var row = quotesGrid[di]; + for (int si = 0; si < supplierEntities.Count; si++) + { + if (si >= row.Length) break; + var (bgVat, chuaVat, isSelected) = row[si]; + db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote + { + PurchaseEvaluationDetailId = detailEntities[di].Id, + PurchaseEvaluationSupplierId = supplierEntities[si].Id, + BgVat = bgVat, + ChuaVat = chuaVat, + ThanhTien = detailEntities[di].KhoiLuongNganSach * chuaVat, + IsSelected = isSelected, + }); + } + } + await db.SaveChangesAsync(); + + // Approval history theo flow của type + PurchaseEvaluationPhase[] flow = type == PurchaseEvaluationType.DuyetNcc + ? [PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.ChoCCM, + PurchaseEvaluationPhase.ChoCEODuyetNCC, PurchaseEvaluationPhase.DaDuyet] + : [PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.ChoDuAn, + PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.ChoCEODuyetPA, + PurchaseEvaluationPhase.ChoCEODuyetNCC, PurchaseEvaluationPhase.DaDuyet]; + + var current = PurchaseEvaluationPhase.DangSoanThao; + foreach (var next in flow) + { + if (next > finalPhase && finalPhase != PurchaseEvaluationPhase.DaDuyet) break; + if (next > finalPhase) break; + Guid? actorId = next switch + { + PurchaseEvaluationPhase.ChoPurchasing => qsHoang?.Id, // Drafter submit + PurchaseEvaluationPhase.ChoDuAn => proPham?.Id, // Procurement chuyển sang + PurchaseEvaluationPhase.ChoCCM => type == PurchaseEvaluationType.DuyetNcc + ? proPham?.Id // A: Procurement + : pmNguyen?.Id, // B: PM + PurchaseEvaluationPhase.ChoCEODuyetPA => ccmTran?.Id, // CCM + PurchaseEvaluationPhase.ChoCEODuyetNCC => type == PurchaseEvaluationType.DuyetNcc + ? ccmTran?.Id // A: CCM + : bodHuynh?.Id, // B: CEO duyệt PA xong + PurchaseEvaluationPhase.DaDuyet => bodHuynh?.Id, // CEO chốt + _ => qsHoang?.Id, + }; + db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval + { + PurchaseEvaluationId = pe.Id, + FromPhase = current, + ToPhase = next, + ApproverUserId = actorId, + Decision = ApprovalDecision.Approve, + Comment = next switch + { + PurchaseEvaluationPhase.ChoPurchasing => "Demo seed — Drafter submit phiếu", + PurchaseEvaluationPhase.ChoCCM => "Demo seed — chuyển CCM kiểm tra ngân sách", + PurchaseEvaluationPhase.DaDuyet => "Demo seed — CEO duyệt chọn NCC", + _ => null, + }, + ApprovedAt = nowUtc.AddMinutes(-(flow.Length - Array.IndexOf(flow, next)) * 30), + }); + current = next; + } + pe.Phase = current; + + // Set winner nếu DaDuyet + if (current == PurchaseEvaluationPhase.DaDuyet + && winnerSupplierCode is not null + && suppliersByCode.TryGetValue(winnerSupplierCode, out var winner)) + { + pe.SelectedSupplierId = winner.Id; + } + + return pe; + } + + // =========================================================== + // 1. Phiếu A — DangSoanThao (Drafter còn đang soạn) + // =========================================================== + var pe1 = await createDemoPeAsync( + maPhieu: "A-001", + type: PurchaseEvaluationType.DuyetNcc, + finalPhase: PurchaseEvaluationPhase.DangSoanThao, + tenGoiThau: "Cung cấp xi măng + sắt thép Q3/2026 — FLOCK 01", + projectCode: "FLOCK01", + diaDiem: "Lô K, KCN Lộc An", + moTa: "Đợt 1: 200 tấn xi măng + 30 tấn thép cho phần thân.", + supplierCodes: ["NCC-XIMANG", "NCC-THEP", "NCC-DIEN"], + detailRows: + [ + ("A.I", "Xi măng", "XM-001", "Xi măng PCB40 50kg", "tan", 200, 200, 1_800_000), + ("A.I", "Xi măng", "XM-002", "Xi măng PCB30 50kg", "tan", 50, 50, 1_650_000), + ("A.II", "Sắt thép", "TH-014", "Thép cây phi 14", "kg", 15000, 15000, 18_500), + ("A.II", "Sắt thép", "TH-018", "Thép cây phi 18", "kg", 15000, 15000, 18_800), + ], + quotesGrid: + [ + // Hạng 1 (xi măng PCB40) — chưa nhập (Drafter mới mở phiếu) + [(0, 0, false), (0, 0, false), (0, 0, false)], + [(0, 0, false), (0, 0, false), (0, 0, false)], + [(0, 0, false), (0, 0, false), (0, 0, false)], + [(0, 0, false), (0, 0, false), (0, 0, false)], + ]); + + // =========================================================== + // 2. Phiếu A — ChoCEODuyetNCC (sắp duyệt — đã có quotes + winner đề xuất) + // =========================================================== + var pe2 = await createDemoPeAsync( + maPhieu: "A-002", + type: PurchaseEvaluationType.DuyetNcc, + finalPhase: PurchaseEvaluationPhase.ChoCEODuyetNCC, + tenGoiThau: "Cung cấp gạch xây + cát đá Q2/2026 — Vinhomes Ocean Park", + projectCode: "VHOMES-OP", + diaDiem: "Vinhomes Ocean Park, Gia Lâm, HN", + moTa: "Vật tư xây dựng đợt 2 cho block A1-A5.", + supplierCodes: ["NCC-XIMANG", "NCC-THEP", "NCC-DIEN"], + detailRows: + [ + ("A.I", "Gạch", "GACH-001", "Gạch xây 4 lỗ 8x8x18", "vien", 50000, 50000, 1_500), + ("A.II", "Cát đá", "CAT-001", "Cát vàng đổ bê tông", "m3", 200, 200, 350_000), + ("A.II", "Cát đá", "DA-001", "Đá 1x2", "m3", 150, 150, 280_000), + ], + quotesGrid: + [ + // Hạng 1: Gạch — supplier 1 thắng (báo giá thấp + cam kết) + [(1_700, 1_550, true), (1_750, 1_590, false), (1_780, 1_618, false)], + // Hạng 2: Cát vàng — supplier 1 thắng + [(385_000, 350_000, true), (390_000, 354_000, false), (395_000, 359_000, false)], + // Hạng 3: Đá 1x2 — supplier 1 thắng + [(308_000, 280_000, true), (315_000, 286_000, false), (320_000, 290_000, false)], + ]); + + // =========================================================== + // 3. Phiếu A — DaDuyet (đã duyệt — chưa tạo HĐ — demo "Tạo HĐ từ phiếu") + // =========================================================== + var pe3 = await createDemoPeAsync( + maPhieu: "A-003", + type: PurchaseEvaluationType.DuyetNcc, + finalPhase: PurchaseEvaluationPhase.DaDuyet, + tenGoiThau: "Cung cấp ống PVC + phụ kiện cấp thoát nước — Resort Phú Quốc", + projectCode: "RESORT-PQ", + diaDiem: "Bãi Trường, Phú Quốc, Kiên Giang", + moTa: "Hệ thống cấp thoát nước villa + biệt thự ven biển.", + supplierCodes: ["NCC-XIMANG", "NCC-THEP"], + detailRows: + [ + ("A.I", "Ống nhựa", "ONG-090", "Ống PVC phi 90", "m", 2000, 2000, 65_000), + ("A.I", "Ống nhựa", "ONG-110", "Ống PVC phi 110", "m", 1500, 1500, 95_000), + ], + quotesGrid: + [ + [(71_500, 65_000, true), (72_600, 66_000, false)], + [(104_500, 95_000, true), (106_700, 97_000, false)], + ], + winnerSupplierCode: "NCC-XIMANG", + paymentTermsJson: """{"tamUng":20,"thanhToanTam":70,"quyetToan":10,"baoHanh":12,"hanMucCongNo":500000000,"danhGia":"Đối tác chiến lược 3 năm — uy tín cao."}"""); + + // =========================================================== + // 4. Phiếu B — ChoDuAn (5-step giữa chừng — Procurement đã chuyển) + // =========================================================== + var pe4 = await createDemoPeAsync( + maPhieu: "B-001", + type: PurchaseEvaluationType.DuyetNccPhuongAn, + finalPhase: PurchaseEvaluationPhase.ChoDuAn, + tenGoiThau: "Cung cấp + lắp đặt cẩu tháp 6 tháng — FLOCK 01", + projectCode: "FLOCK01", + diaDiem: "Lô K, KCN Lộc An", + moTa: "Phương án A: thuê cẩu Liebherr 320 EC-H. Phương án B: thuê cẩu Potain MD 365.", + supplierCodes: ["DV-VANCHUYEN", "DV-CLEAN", "NTP-XD"], + detailRows: + [ + ("A.I", "Cẩu tháp", "CT-001", "Cẩu tháp Liebherr 320 EC-H — phương án A", "thang", 6, 6, 90_000_000), + ("A.II", "Vận chuyển", "VC-001", "Vận chuyển + lắp đặt cẩu tháp", "lan", 1, 1, 80_000_000), + ("A.III", "Bảo trì", "BT-001", "Bảo trì cẩu tháp định kỳ", "lan", 6, 6, 5_000_000), + ], + quotesGrid: + [ + [(99_000_000, 90_000_000, false), (104_500_000, 95_000_000, false), (96_800_000, 88_000_000, false)], + [(88_000_000, 80_000_000, false), (93_500_000, 85_000_000, false), (82_500_000, 75_000_000, false)], + [(5_500_000, 5_000_000, false), (5_775_000, 5_250_000, false), (5_280_000, 4_800_000, false)], + ]); + + await db.SaveChangesAsync(); + logger.LogInformation("Seed 4 demo PE phiếu: {P1} {P2} {P3} {P4}", + pe1.MaPhieu, pe2.MaPhieu, pe3.MaPhieu, pe4.MaPhieu); + } + // Phase 5.1 security: log warning nếu admin vẫn dùng password mặc định sau deploy production. private static async Task WarnDefaultAdminPasswordAsync(UserManager userManager, ILogger logger) { @@ -1024,6 +1341,79 @@ public static class DbInitializer await db.SaveChangesAsync(); logger.LogInformation("Seeded {Count} admin permissions", added); } + + // Defaults cho Pe_* permissions per role — mỗi role business liên quan + // PE module được grant CRUD level phù hợp lên 2 type (DuyetNcc / + // DuyetNccPhuongAn). + // Idempotent: skip per-(role,menuKey) đã có, chỉ add row mới. + await SeedPurchaseEvaluationPermissionDefaultsAsync(db, roleManager, logger); + } + + // Permission defaults cho module Duyệt NCC (Pe_*). Strategy: + // - Drafter / DeptManager / Procurement / CostControl / Director / AuthorizedSigner / ProjectManager + // đều có Read + Create + Update trên CẢ 2 type (xem được, soạn / chỉnh). + // - Workflow admin (PeWf_*) chỉ Admin (đã grant qua MenuKeys.All) — không cấp non-admin. + // - Idempotent: skip per-(roleId × menuKey) đã có row. + private static async Task SeedPurchaseEvaluationPermissionDefaultsAsync( + ApplicationDbContext db, RoleManager roleManager, ILogger logger) + { + // Roles cần access PE module + var roleNames = new[] + { + AppRoles.Drafter, AppRoles.DeptManager, + AppRoles.Procurement, AppRoles.CostControl, + AppRoles.ProjectManager, + AppRoles.Director, AppRoles.AuthorizedSigner, + }; + + // Menu keys cần grant: PurchaseEvaluations root + per-type group + 3 leaf + var menuKeys = new List { MenuKeys.PurchaseEvaluations }; + foreach (var typeCode in MenuKeys.PurchaseEvaluationTypeCodes) + { + menuKeys.Add(MenuKeys.PurchaseEvaluationGroup(typeCode)); + menuKeys.Add(MenuKeys.PurchaseEvaluationList(typeCode)); + menuKeys.Add(MenuKeys.PurchaseEvaluationCreate(typeCode)); + menuKeys.Add(MenuKeys.PurchaseEvaluationPending(typeCode)); + } + + int added = 0; + foreach (var roleName in roleNames) + { + var role = await roleManager.FindByNameAsync(roleName); + if (role is null) continue; + + var existing = await db.Permissions + .Where(p => p.RoleId == role.Id && menuKeys.Contains(p.MenuKey)) + .Select(p => p.MenuKey) + .ToListAsync(); + + // Drafter có quyền Create + Update (soạn phiếu); Director chỉ Read + + // Update (duyệt — xem + nhấn nút). Map default rõ ràng: + bool canCreate = roleName is AppRoles.Drafter or AppRoles.DeptManager + or AppRoles.Procurement; + bool canUpdate = true; // mọi role được transition (workflow check tách) + bool canDelete = roleName == AppRoles.DeptManager; // chỉ TPB xóa nháp + + foreach (var menuKey in menuKeys) + { + if (existing.Contains(menuKey)) continue; + db.Permissions.Add(new Permission + { + RoleId = role.Id, + MenuKey = menuKey, + CanRead = true, + CanCreate = canCreate, + CanUpdate = canUpdate, + CanDelete = canDelete, + }); + added++; + } + } + if (added > 0) + { + await db.SaveChangesAsync(); + logger.LogInformation("Seeded {Count} PE permission defaults across {Roles} roles", added, roleNames.Length); + } } // 9 departments từ QT-TP-NCC.docx — reference data, không phải demo. diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260424033449_AddPurchaseEvaluationCodeSequences.Designer.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260424033449_AddPurchaseEvaluationCodeSequences.Designer.cs new file mode 100644 index 0000000..b1e1773 --- /dev/null +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260424033449_AddPurchaseEvaluationCodeSequences.Designer.cs @@ -0,0 +1,2918 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SolutionErp.Infrastructure.Persistence; + +#nullable disable + +namespace SolutionErp.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260424033449_AddPurchaseEvaluationCodeSequences")] + partial class AddPurchaseEvaluationCodeSequences + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Contract", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BypassProcurementAndCCM") + .HasColumnType("bit"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DepartmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("DraftData") + .HasColumnType("nvarchar(max)"); + + b.Property("DrafterUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("GiaTri") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("MaHopDong") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("NoiDung") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Phase") + .HasColumnType("int"); + + b.Property("ProjectId") + .HasColumnType("uniqueidentifier"); + + b.Property("SlaDeadline") + .HasColumnType("datetime2"); + + b.Property("SlaWarningSent") + .HasColumnType("bit"); + + b.Property("SupplierId") + .HasColumnType("uniqueidentifier"); + + b.Property("TemplateId") + .HasColumnType("uniqueidentifier"); + + b.Property("TenHopDong") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkflowDefinitionId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("MaHopDong") + .IsUnique() + .HasFilter("[MaHopDong] IS NOT NULL"); + + b.HasIndex("ProjectId"); + + b.HasIndex("SlaDeadline"); + + b.HasIndex("SupplierId"); + + b.HasIndex("Phase", "IsDeleted"); + + b.ToTable("Contracts", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractApproval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovedAt") + .HasColumnType("datetime2"); + + b.Property("ApproverUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Comment") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Decision") + .HasColumnType("int"); + + b.Property("FromPhase") + .HasColumnType("int"); + + b.Property("ToPhase") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "ApprovedAt"); + + b.ToTable("ContractApprovals", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Purpose") + .HasColumnType("int"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractId"); + + b.ToTable("ContractAttachments", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractChangelog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Action") + .HasColumnType("int"); + + b.Property("ContextNote") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityType") + .HasColumnType("int"); + + b.Property("FieldChangesJson") + .HasColumnType("nvarchar(max)"); + + b.Property("PhaseAtChange") + .HasColumnType("int"); + + b.Property("Summary") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "CreatedAt"); + + b.HasIndex("ContractId", "EntityType"); + + b.ToTable("ContractChangelogs", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractCodeSequence", b => + { + b.Property("Prefix") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("LastSeq") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Prefix"); + + b.ToTable("ContractCodeSequences", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Phase") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "CreatedAt"); + + b.ToTable("ContractComments", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.DichVuDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DenNgay") + .HasColumnType("datetime2"); + + b.Property("DonGia") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonViTinh") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GhiChu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("MaDichVu") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("MoTa") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("TenDichVu") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ThanhTien") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ThoiGian") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TuNgay") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "Order"); + + b.ToTable("DichVuDetails", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.GiaoKhoanDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DonGia") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonViTinh") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GhiChu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("KhoiLuong") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("MaCongViec") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("TenCongViec") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ThanhTien") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ThoiGianHoanThanh") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("YeuCauKyThuat") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "Order"); + + b.ToTable("GiaoKhoanDetails", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.MuaBanDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DonGia") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonViTinh") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GhiChu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("MaSP") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("MoTa") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("SoLuong") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TenSP") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ThanhTien") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ThueVAT") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("XuatXu") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "Order"); + + b.ToTable("MuaBanDetails", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.NguyenTacDvDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DonGiaToiDa") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonGiaToiThieu") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonViTinh") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GhiChu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("LoaiDichVu") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("PhamViDichVu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("SLA") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("TenDichVu") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ThanhTien") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "Order"); + + b.ToTable("NguyenTacDvDetails", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.NguyenTacNccDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DieuKienGiaoHang") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("DieuKienThanhToan") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("DonGiaToiDa") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonGiaToiThieu") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonViTinh") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GhiChu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("NhomSP") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("TenSP") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ThanhTien") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "Order"); + + b.ToTable("NguyenTacNccDetails", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.NhaCungCapDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DonGia") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonViTinh") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GhiChu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("MaSP") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("SoLuong") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TenSP") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ThanhTien") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ThoiGianGiao") + .HasColumnType("datetime2"); + + b.Property("ThongSoKyThuat") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("XuatXu") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "Order"); + + b.ToTable("NhaCungCapDetails", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.ThauPhuDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DonGia") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonViTinh") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GhiChu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("HangMuc") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("KhoiLuong") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("ThanhTien") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ThoiGianHoanThanh") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "Order"); + + b.ToTable("ThauPhuDetails", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActivatedAt") + .HasColumnType("datetime2"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ContractType") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Version") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Code", "Version") + .IsUnique(); + + b.HasIndex("ContractType", "IsActive"); + + b.ToTable("WorkflowDefinitions", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("Phase") + .HasColumnType("int"); + + b.Property("SlaDays") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkflowDefinitionId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("WorkflowDefinitionId", "Order"); + + b.ToTable("WorkflowSteps", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStepApprover", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AssignmentValue") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Kind") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkflowStepId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("WorkflowStepId"); + + b.ToTable("WorkflowStepApprovers", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowTypeAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractType") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractType") + .IsUnique(); + + b.ToTable("WorkflowTypeAssignments", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Forms.ContractClause", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Version") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("ContractClauses", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Forms.ContractTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractType") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("FieldSpec") + .HasColumnType("nvarchar(max)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FormCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Format") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractType"); + + b.HasIndex("FormCode") + .IsUnique(); + + b.ToTable("ContractTemplates", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b => + { + b.Property("Key") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Icon") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("ParentKey") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Key"); + + b.HasIndex("ParentKey"); + + b.ToTable("MenuItems", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Identity.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CanCreate") + .HasColumnType("bit"); + + b.Property("CanDelete") + .HasColumnType("bit"); + + b.Property("CanRead") + .HasColumnType("bit"); + + b.Property("CanUpdate") + .HasColumnType("bit"); + + b.Property("MenuKey") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("MenuKey"); + + b.HasIndex("RoleId", "MenuKey") + .IsUnique(); + + b.ToTable("Permissions", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Identity.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ShortName") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DepartmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("Position") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RefreshToken") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("RefreshTokenExpiresAt") + .HasColumnType("datetime2"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Master.Catalogs.MaterialItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DefaultUnit") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OriginCountry") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Specification") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Category"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("MaterialItems", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Master.Catalogs.ServiceItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DefaultUnit") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Category"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("ServiceItems", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Master.Catalogs.UnitOfMeasure", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("UnitsOfMeasure", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Master.Catalogs.WorkItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DefaultUnit") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Category"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("WorkItems", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Master.Department", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ManagerUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Note") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("Departments", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Master.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BudgetTotal") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("EndDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ManagerUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Note") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("Projects", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Master.Supplier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Address") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ContactPerson") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Note") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Phone") + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("TaxCode") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Type"); + + b.ToTable("Suppliers", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Notifications.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Href") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ReadAt") + .HasColumnType("datetime2"); + + b.Property("RefId") + .HasColumnType("uniqueidentifier"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UserId", "ReadAt"); + + b.ToTable("Notifications", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DepartmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("DiaDiem") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DrafterUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("MaPhieu") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("MoTa") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("PaymentTerms") + .HasColumnType("nvarchar(max)"); + + b.Property("Phase") + .HasColumnType("int"); + + b.Property("ProjectId") + .HasColumnType("uniqueidentifier"); + + b.Property("SelectedSupplierId") + .HasColumnType("uniqueidentifier"); + + b.Property("SlaDeadline") + .HasColumnType("datetime2"); + + b.Property("SlaWarningSent") + .HasColumnType("bit"); + + b.Property("TenGoiThau") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkflowDefinitionId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractId"); + + b.HasIndex("MaPhieu") + .IsUnique() + .HasFilter("[MaPhieu] IS NOT NULL"); + + b.HasIndex("ProjectId"); + + b.HasIndex("SlaDeadline"); + + b.HasIndex("WorkflowDefinitionId"); + + b.HasIndex("Phase", "IsDeleted"); + + b.ToTable("PurchaseEvaluations", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationApproval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovedAt") + .HasColumnType("datetime2"); + + b.Property("ApproverUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Comment") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Decision") + .HasColumnType("int"); + + b.Property("FromPhase") + .HasColumnType("int"); + + b.Property("PurchaseEvaluationId") + .HasColumnType("uniqueidentifier"); + + b.Property("ToPhase") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseEvaluationId", "ApprovedAt"); + + b.ToTable("PurchaseEvaluationApprovals", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("PurchaseEvaluationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PurchaseEvaluationSupplierId") + .HasColumnType("uniqueidentifier"); + + b.Property("Purpose") + .HasColumnType("int"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseEvaluationId"); + + b.HasIndex("PurchaseEvaluationSupplierId"); + + b.ToTable("PurchaseEvaluationAttachments", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationChangelog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Action") + .HasColumnType("int"); + + b.Property("ContextNote") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityType") + .HasColumnType("int"); + + b.Property("FieldChangesJson") + .HasColumnType("nvarchar(max)"); + + b.Property("PhaseAtChange") + .HasColumnType("int"); + + b.Property("PurchaseEvaluationId") + .HasColumnType("uniqueidentifier"); + + b.Property("Summary") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseEvaluationId", "CreatedAt"); + + b.HasIndex("PurchaseEvaluationId", "EntityType"); + + b.ToTable("PurchaseEvaluationChangelogs", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationCodeSequence", b => + { + b.Property("Prefix") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("LastSeq") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Prefix"); + + b.ToTable("PurchaseEvaluationCodeSequences", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DonGiaNganSach") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonViTinh") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GhiChu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("GroupCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GroupName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ItemCode") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("KhoiLuongNganSach") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("KhoiLuongThiCong") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("NoiDung") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("PurchaseEvaluationId") + .HasColumnType("uniqueidentifier"); + + b.Property("ThanhTienNganSach") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseEvaluationId", "Order"); + + b.ToTable("PurchaseEvaluationDetails", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationQuote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BgVat") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ChuaVat") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IsSelected") + .HasColumnType("bit"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("PurchaseEvaluationDetailId") + .HasColumnType("uniqueidentifier"); + + b.Property("PurchaseEvaluationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PurchaseEvaluationSupplierId") + .HasColumnType("uniqueidentifier"); + + b.Property("ThanhTien") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseEvaluationId"); + + b.HasIndex("PurchaseEvaluationSupplierId"); + + b.HasIndex("PurchaseEvaluationDetailId", "PurchaseEvaluationSupplierId") + .IsUnique(); + + b.ToTable("PurchaseEvaluationQuotes", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationSupplier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContactEmail") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ContactName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ContactPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("PaymentTermText") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PurchaseEvaluationId") + .HasColumnType("uniqueidentifier"); + + b.Property("SupplierId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("SupplierId"); + + b.HasIndex("PurchaseEvaluationId", "SupplierId") + .IsUnique(); + + b.ToTable("PurchaseEvaluationSuppliers", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActivatedAt") + .HasColumnType("datetime2"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("EvaluationType") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Version") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Code", "Version") + .IsUnique(); + + b.HasIndex("EvaluationType", "IsActive"); + + b.ToTable("PurchaseEvaluationWorkflowDefinitions", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("Phase") + .HasColumnType("int"); + + b.Property("PurchaseEvaluationWorkflowDefinitionId") + .HasColumnType("uniqueidentifier"); + + b.Property("SlaDays") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseEvaluationWorkflowDefinitionId", "Order"); + + b.ToTable("PurchaseEvaluationWorkflowSteps", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStepApprover", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AssignmentValue") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Kind") + .HasColumnType("int"); + + b.Property("PurchaseEvaluationWorkflowStepId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseEvaluationWorkflowStepId"); + + b.ToTable("PurchaseEvaluationWorkflowStepApprovers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("SolutionErp.Domain.Identity.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("SolutionErp.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("SolutionErp.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("SolutionErp.Domain.Identity.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SolutionErp.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("SolutionErp.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractApproval", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("Approvals") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractAttachment", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("Attachments") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractChangelog", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("Changelogs") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractComment", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("Comments") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.DichVuDetail", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("DichVuDetails") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.GiaoKhoanDetail", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("GiaoKhoanDetails") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.MuaBanDetail", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("MuaBanDetails") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.NguyenTacDvDetail", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("NguyenTacDvDetails") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.NguyenTacNccDetail", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("NguyenTacNccDetails") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.NhaCungCapDetail", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("NhaCungCapDetails") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.ThauPhuDetail", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("ThauPhuDetails") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStep", b => + { + b.HasOne("SolutionErp.Domain.Contracts.WorkflowDefinition", "WorkflowDefinition") + .WithMany("Steps") + .HasForeignKey("WorkflowDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WorkflowDefinition"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStepApprover", b => + { + b.HasOne("SolutionErp.Domain.Contracts.WorkflowStep", "Step") + .WithMany("Approvers") + .HasForeignKey("WorkflowStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Step"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b => + { + b.HasOne("SolutionErp.Domain.Identity.MenuItem", "Parent") + .WithMany("Children") + .HasForeignKey("ParentKey") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Identity.Permission", b => + { + b.HasOne("SolutionErp.Domain.Identity.MenuItem", "Menu") + .WithMany("Permissions") + .HasForeignKey("MenuKey") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SolutionErp.Domain.Identity.Role", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Menu"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Identity.User", b => + { + b.HasOne("SolutionErp.Domain.Master.Department", null) + .WithMany() + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationApproval", b => + { + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation") + .WithMany("Approvals") + .HasForeignKey("PurchaseEvaluationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PurchaseEvaluation"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationAttachment", b => + { + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation") + .WithMany("Attachments") + .HasForeignKey("PurchaseEvaluationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PurchaseEvaluation"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationChangelog", b => + { + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation") + .WithMany("Changelogs") + .HasForeignKey("PurchaseEvaluationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PurchaseEvaluation"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDetail", b => + { + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation") + .WithMany("Details") + .HasForeignKey("PurchaseEvaluationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PurchaseEvaluation"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationQuote", b => + { + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDetail", "Detail") + .WithMany("Quotes") + .HasForeignKey("PurchaseEvaluationDetailId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", null) + .WithMany("Quotes") + .HasForeignKey("PurchaseEvaluationId"); + + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationSupplier", "Supplier") + .WithMany() + .HasForeignKey("PurchaseEvaluationSupplierId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Detail"); + + b.Navigation("Supplier"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationSupplier", b => + { + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation") + .WithMany("Suppliers") + .HasForeignKey("PurchaseEvaluationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PurchaseEvaluation"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStep", b => + { + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowDefinition", "Definition") + .WithMany("Steps") + .HasForeignKey("PurchaseEvaluationWorkflowDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Definition"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStepApprover", b => + { + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStep", "Step") + .WithMany("Approvers") + .HasForeignKey("PurchaseEvaluationWorkflowStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Step"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Contract", b => + { + b.Navigation("Approvals"); + + b.Navigation("Attachments"); + + b.Navigation("Changelogs"); + + b.Navigation("Comments"); + + b.Navigation("DichVuDetails"); + + b.Navigation("GiaoKhoanDetails"); + + b.Navigation("MuaBanDetails"); + + b.Navigation("NguyenTacDvDetails"); + + b.Navigation("NguyenTacNccDetails"); + + b.Navigation("NhaCungCapDetails"); + + b.Navigation("ThauPhuDetails"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowDefinition", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStep", b => + { + b.Navigation("Approvers"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b => + { + b.Navigation("Children"); + + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b => + { + b.Navigation("Approvals"); + + b.Navigation("Attachments"); + + b.Navigation("Changelogs"); + + b.Navigation("Details"); + + b.Navigation("Quotes"); + + b.Navigation("Suppliers"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDetail", b => + { + b.Navigation("Quotes"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowDefinition", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStep", b => + { + b.Navigation("Approvers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260424033449_AddPurchaseEvaluationCodeSequences.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260424033449_AddPurchaseEvaluationCodeSequences.cs new file mode 100644 index 0000000..7fd39b5 --- /dev/null +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260424033449_AddPurchaseEvaluationCodeSequences.cs @@ -0,0 +1,35 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SolutionErp.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddPurchaseEvaluationCodeSequences : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PurchaseEvaluationCodeSequences", + columns: table => new + { + Prefix = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + LastSeq = table.Column(type: "int", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PurchaseEvaluationCodeSequences", x => x.Prefix); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PurchaseEvaluationCodeSequences"); + } + } +} diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs index a0eef73..f28a665 100644 --- a/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs @@ -2165,6 +2165,23 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations b.ToTable("PurchaseEvaluationChangelogs", (string)null); }); + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationCodeSequence", b => + { + b.Property("Prefix") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("LastSeq") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Prefix"); + + b.ToTable("PurchaseEvaluationCodeSequences", (string)null); + }); + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDetail", b => { b.Property("Id") diff --git a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationCodeGenerator.cs b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationCodeGenerator.cs new file mode 100644 index 0000000..4d7b855 --- /dev/null +++ b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationCodeGenerator.cs @@ -0,0 +1,55 @@ +using System.Data; +using Microsoft.EntityFrameworkCore; +using SolutionErp.Application.Common.Interfaces; +using SolutionErp.Application.PurchaseEvaluations.Services; +using SolutionErp.Domain.PurchaseEvaluations; + +namespace SolutionErp.Infrastructure.Services; + +// Mirror ContractCodeGenerator pattern — atomic sequence per (year × type). +// Prefix = "PE/{YYYY}/{TypeLetter}". +public class PurchaseEvaluationCodeGenerator(IApplicationDbContext db, IDateTime dateTime) + : IPurchaseEvaluationCodeGenerator +{ + public async Task GenerateAsync(PurchaseEvaluation evaluation, CancellationToken ct = default) + { + var typeLetter = evaluation.Type switch + { + PurchaseEvaluationType.DuyetNcc => "A", + PurchaseEvaluationType.DuyetNccPhuongAn => "B", + _ => "X", + }; + var year = dateTime.UtcNow.Year; + var prefix = $"PE/{year}/{typeLetter}"; + + var context = (DbContext)db; + await using var tx = await context.Database + .BeginTransactionAsync(IsolationLevel.Serializable, ct); + try + { + var seq = await db.PurchaseEvaluationCodeSequences + .FirstOrDefaultAsync(s => s.Prefix == prefix, ct); + if (seq is null) + { + seq = new PurchaseEvaluationCodeSequence + { + Prefix = prefix, LastSeq = 1, UpdatedAt = dateTime.UtcNow, + }; + db.PurchaseEvaluationCodeSequences.Add(seq); + } + else + { + seq.LastSeq += 1; + seq.UpdatedAt = dateTime.UtcNow; + } + await db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + return $"{prefix}/{seq.LastSeq:D3}"; + } + catch + { + await tx.RollbackAsync(ct); + throw; + } + } +}