diff --git a/package.json b/package.json index fb559095..ac8d5ca7 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,6 @@ "build": "tsoa spec-and-routes && tsc", "migration:generate": "typeorm-ts-node-commonjs migration:generate -d src/database/data-source.ts", "migration:run": "typeorm-ts-node-commonjs migration:run -d src/database/data-source.ts", - "seed:show": "typeorm-ts-node-commonjs migration:show -d src/seed/data-source.ts", - "seed:run": "typeorm-ts-node-commonjs migration:run -d src/seed/data-source.ts", - "seed:revert": "typeorm-ts-node-commonjs migration:revert -d src/seed/data-source.ts", "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage" diff --git a/plans/plan_cleanup-mock-seed.md b/plans/plan_cleanup-mock-seed.md deleted file mode 100644 index 4010e667..00000000 --- a/plans/plan_cleanup-mock-seed.md +++ /dev/null @@ -1,263 +0,0 @@ -# Plan + สรุปตาราง + Drop Script — Cleanup & Mock Seed `hrms_organization` - -> **ไฟล์เดียวจบ:** Part A สรุปตาราง DB · Part B plan checklist (CP0-CP7) · Part C drop script SQL -> **Status:** ✅ CP0-CP6 done (drop 54 tables + seed mock org/pos/profile) · ⏳ CP7 (Keycloak) defer -> **Seed mock:** แยกที่ `src/seed/` → `npm run seed:run` (ไม่กระทบ `migration:run` หลัก) -> **วิธีใช้:** เช็ค `- [x]` ทีละขั้น, ทุก CP destructive ต้องแจ้ง user ก่อน + รอยืนยัน (DB test `192.168.1.68` เท่านั้น) - ---- - -# Part A — สรุปตาราง DB - -| รายการ | ค่า | -|---|---| -| Database | `hrms_organization` (MySQL 8.4.6) | -| Host | test `192.168.1.68` / prod `192.168.1.63` (ห้ามแตะ prod) | -| รวมตาราง | **211 → 157** (drop 54 แล้ว @ test) | - -สถานะการใช้งาน: - -| สถานะ | จำนวน | รายละเอียด | -|---|---:|---| -| ✅ ใช้งานจริง (เก็บไว้) | **147** | ข้อมูลธุรกิจหลัก + Master Data + ตารางเสริมที่มี controller | -| 🗑️ เตรียมลบ/ลบแล้ว | **54** | ดู §5 | -| ⚙️ ระบบ/ตัวเชื่อม | **10** | junction + system tables | -| **รวม** | **211** | | - -## A.1 ข้อมูลหลัก -| กลุ่ม | ตัวอย่างตาราง | -|---|---| -| โครงสร้างองค์กร | `orgRoot`, `orgChild1`–`orgChild4`, `orgRevision` | -| ประวัติพนักงาน | `profile`, `profileEmployee`, `profileSalary`, `profile*` + `*History` | -| ตำแหน่ง/อัตรากำลัง | `position`, `posMaster`, `posMasterHistory`, `employeePosition` | -| คำสั่ง | `command`, `commandSend`, `commandRecive`, `commandOperator` | -| สิทธิ์/การเข้าถึง | `authRole`, `permissionOrg`, `permissionProfile`, `roleKeycloak` | -| อายุงาน/วิทยฐานะ | `tenure*` (5 ตาราง) | - -## A.2 Master Data (ข้อมูลอ้างอิง — lookup, เปลี่ยนน้อย) -| กลุ่ม | ตาราง (จำนวนแถว exact) | -|---|---| -| ที่อยู่ | `province` (77), `district` (1,051), `subDistrict` (8,585), `province_master`, `district_master`, `subdistrict_master` | -| ประวัติบุคคล | `gender` (3), `bloodGroup` (4), `religion` (5), `relationship` (4), `prefixe` (5), `rank` (9), `educationLevel` (14) | -| ตำแหน่ง | `posType` (4), `posLevel` (15), `posDict` (910), `posExecutive` (40), `employeePosType`, `employeePosLevel`, `employeePosDict` | -| เครื่องราช | `insignia` (14), `insigniaType` (3) | -| คำสั่ง/การลา | `commandType` (47), `commandCode` (21), `commandSys` (12), `leaveType` (11) | - -> 💡 ที่อยู่มี 2 ชุดซ้ำซ้อน: `province`/`district`/`subDistrict` (ใช้จริง) vs `*_master` (ชุดสำรอง) — ตรวจสอบว่าจำเป็นไหม - -## A.3 ตารางคำนวณอัตโนมัติ (cron เขียนทุกคืน — ห้ามแก้ด้วยมือ) -| เวลา | ตาราง | สิ่งที่ทำ | -|---|---|---| -| 00:30 ทุกคืน | `profile`, `profileEmployee` | ประมวลผลคำสั่ง | -| 04:00 ทุกคืน | `registry`, `registryEmployee`, `tenure*` | **ล้างและสร้างใหม่** (full refresh) | -| 02:00 วันที่ 1 ต.ค. | `profile`, `profileEmployee`, `profileSalary`, `posMaster` | อัปเดตสถานะเกษียณ | - -## A.4 ข้อมูลต้นทางจากระบบเดิม (MIS) -ชื่อตารางตัวใหญ่ (`HR_*`, `EMPLOYEE`, `Profile*`): -| สถานะ | จำนวน | รายละเอียด | -|---|---:|---| -| ใช้อยู่ (สำหรับนำเข้าข้อมูล) | 22 | `EMPLOYEE`, `OFFICER`, `Profile*` (ตัวใหญ่), `*Import` | -| ไม่ได้ใช้แล้ว (dead entity) | 25 | `HR_*` (24) + `IMPORT_ORG` — เคยมีข้อมูลจริง >1.4M แถว | - -> ⚠️ ทั้ง 47 ตารางอยู่ในกลุ่มเตรียมลบ (§5) — **drop แล้ว @ test** - -## A.5 กลุ่มที่เตรียมลบ/ลบแล้ว (54 ตาราง) -| กลุ่ม | จำนวน | ตัวอย่าง / เหตุผล | -|---|---:|---| -| ข้อมูลระบบเดิม (MIS) | 47 | `HR_*`, `EMPLOYEE`, `OFFICER`, `Profile*`, `*Import`, `IMPORT_ORG` | -| ตารางพักนำเข้า (ว่าง) | 2 | `HR_CHILDEN_EMPTEMP`, `HR_INSIGNIA_EMPTEMP` (0 แถว) | -| ตารางซ้ำ/ทดสอบ/เก่า | 5 | `Profiles`, `new_table`, `new_table_emp`, `entity_base`, `MET_PREFIX` | - -หลังลบครบเหลือ **157 ตาราง**. Script อยู่ Part C. - -> ⚠️ `profile*Employee*` (profileDisciplineEmployee, profileInformation, profileSalaryEmployee + *History) **ยังใช้** (มี controller: ProfileDisciplineEmployeeController, ProfileSalaryEmployeeController, ProfileEmployeeController) — อย่า drop - -## A.6 ข้อควรระวัง -- ตารางเก็บ **ชื่อ + เลขบัตรประชาชน**: ~23 ตาราง (หลัก `profile`, `profileEmployee`, `registry`) — ข้อมูลส่วนบุคคล -- `migrations`, `typeorm_metadata` เป็นตารางระบบ **ห้ามลบเด็ดขาด** -- จำนวนแถวใน §A.1/A.4 เป็นค่าประมาณ (ยกเว้น §A.2 Master Data = exact) - -## A.7 Seed / Mock data (สำหรับ dev/test) -mock data แยกที่ `src/seed/` (Node/TS) → `npm run seed:show` / `seed:run` / `seed:revert` -seed **แยกจาก migration หลัก** → `migration:run` ไม่เจอ seed → ไม่ทำลายข้อมูล prod. อ่าน `src/seed/README.md` - ---- - -# Part B — Plan checklist (CP0-CP7) - -## Context -DB `hrms_organization` (test) มีข้อมูลจริง + ตารางระบบเดิมไม่ได้ใช้ → ทำ environment สะอาด: ลดตารางเหลือใช้จริง + แทนข้อมูลจริงด้วย mock 30 คน + โครงสร้าง mock (revision 2569) + (defer) Keycloak - -## Decisions -| | | -|---|---| -| Target DB | `192.168.1.68` (test) — **แจ้ง user ทุกครั้งก่อนจัดการ data** | -| Mock | 30 คน = 20 ข้าราชการ (Profile) + 10 ลูกจ้าง (ProfileEmployee) | -| citizenId | สุ่ม 13 หลักผ่าน checksum จริง + ห้ามตรงของเดิม (block list) | -| HR_* (24) | drop เลย (ไม่รอ .NET) | -| Keycloak | clear `keycloak` column ก่อนลบ profile → CP6 clear orphan → CP7 ผูก KC ใหม่ | -| CP7 password | `ddMMyyyy` พ.ศ. จาก birthDate mock | - -## Checklist - -### CP0 — Pre-flight ✅ -- [x] หยุด service (กัน cron 00:30/03:00/04:00 ทับ) + ยืนยันต่อ DB `192.168.1.68` (clone: 211 tables, profile 20,501) -- [x] verify junction/child table names + NOT NULL columns ของ target tables - -### เตรียมไฟล์ ✅ -- [x] `src/seed/mockData.ts` (ชื่อไทย + genValidCitizenId checksum + deterministic UUID + genBirthDate) -- [x] 5 seed migrations (CP3-CP5c) + birthDate ใน CP5b; typecheck + test ผ่าน - -### CP1 — Full Backup ✅ SKIP (user มี backup อยู่แล้ว) - -### CP2 — Drop 54 unused tables (211 → 157) ✅ done -- [x] comment `ImportDataController.ts` @Route/@Security → `npm run build` → verify `/api/v1/org/upload/*` หาย -- [x] execute DROP 54 tables ใต้ `SET FOREIGN_KEY_CHECKS=0/1` (ดู Part C) -- [x] **verify**: tables = **157**; `migrations`/`typeorm_metadata` ยังอยู่ - -### CP3 — Seed orgRevision 2569 ✅ done -- [x] run `1782100000001-seed_org_revision_2569.ts` (ล้าง org/pos เดิม + INSERT "โครงสร้าง 2569") -- [x] **verify**: `orgRevision`=1, `orgRevisionIsCurrent`=1 - -### CP4 — Seed mock org structure ✅ done -- [x] run `1782100000002-seed_mock_org_structure.ts` -- [x] **verify**: สำนักA/หน่วยงานA/ฝ่ายA/กลุ่มงานA; FK chain ครบ - -### CP5a — Seed mock positions ✅ done (seed ตรง — TypeORM INSERT quirk) -- [x] run `1782100000003-seed_mock_positions.ts` (20 posMaster + 10 employeePosMaster) -- [x] **verify**: posMaster=20, employeePosMaster=10; current_holderId NULL - -### CP5b — Seed mock profiles + clear keycloak ✅ done -- [x] run `1782100000004-seed_mock_profiles.ts` (snapshot citizenId block list, clear keycloak, DELETE child + profile, INSERT 30 mock + birthDate) -- [x] **verify**: profile=20, profileEmployee=10; citizenId checksum + ไม่ตรง blocklist; birthDate ครบ - -### CP5c — Link holders ✅ done -- [x] run `1782100000005-seed_link_holders.ts` -- [x] **verify**: posMaster holder=20 join profile ครบ; employeePosMaster holder=10 - -### CP6 — Clear Keycloak orphans ✅ SKIP (จัด KC ท้ายสุดพร้อม CP7) - -### CP7 — ผูก Keycloak ให้ mock ⏳ defer -- [ ] เปลี่ยน `.env` `KC_SERVICE_ACCOUNT_*` ชี้ Keycloak **test** -- [ ] seed migration (`1782100000006-seed_keycloak_users.ts`) เรียก helper `src/keycloak/index.ts` (createUser/addUserRoles/getRoles/getUserByUsername) -- [ ] role `WHERE name='USER'` (แทน hardcode UUID `8a1a0dc9-...`) -- [ ] loop mock 30 คน: createUser(citizenId, password `ddMMyyyy` พ.ศ. จาก birthDate) + addUserRoles(USER) + เซ็ต `profile.keycloak` + junction -- [ ] **verify**: KC user = mock count; login mock ได้; super_admin ยังอยู่ - -## ไฟล์ที่สร้างแล้ว -**Seed (แยกจาก migration หลัก):** `src/seed/README.md`, `src/seed/data-source.ts`, `src/seed/mockData.ts`, `src/seed/migrations/178210000000{1..5}-seed_*.ts` -**npm scripts:** `npm run seed:show` / `seed:run` / `seed:revert` (ใช้ `src/seed/data-source.ts`) -**Code:** `src/controllers/ImportDataController.ts` (@Route/@Security comment — CP2) - -## End-to-end verify (หลัง CP6) -1. `npm run migration:run` ผ่านไม่ error (migration หลัก) + `npm run seed:show` เห็น seed 5 -2. tables = 157 -3. profile=20, profileEmployee=10, orgRevision=1 -4. citizenId ผ่าน checksum + ไม่ตรง blocklist -5. posMaster holder=20 join profile ครบ -6. cron 04:00 รอบแรก → registry/tenure rebuild จาก mock 30 คน -7. login admin ได้ (super_admin ยังอยู่) - -## Risks -- migration + `SET FOREIGN_KEY_CHECKS` session-level ไม่ rollback → **backup เป็นหลัก ไม่พึ่ง down()** -- cron ทับ → หยุด service หรือทำ CP3-5c ในกรอบเวลาแคบ -- CP7 env ต้องชี้ KC test ก่อน -- TypeORM quirk: CP5a INSERT SELECT WHERE NOT EXISTS อาจไม่ execute ใน transaction → seed ตรงถ้า posMaster ว่าง - ---- - -# Part C — Drop script SQL (CP2) - -> ⚠️⚠️⚠️ DROP TABLE ลบถาวร ย้อนกลับไม่ได้. ทุก DROP comment (`--`) ไว้ — uncomment เฉพาะที่ตรวจสอบแล้ว. -> ก่อนรัน: (1) backup DB (2) comment `ImportDataController` + `npm run build` (3) รันบน test เท่านั้น (4) ปรึกษาทีม .NET เรื่อง HR_* -> 🚫 ห้าม DROP: `migrations`, `typeorm_metadata` - -```sql -USE hrms_organization; - --- นับตารางก่อนรัน (ควรได้ 211) -SELECT COUNT(*) AS tables_before -FROM information_schema.tables -WHERE table_schema = 'hrms_organization' AND table_type = 'BASE TABLE'; - -SET FOREIGN_KEY_CHECKS = 0; - --- ============================================================ --- กลุ่ม 1 — MIS tables (47) — ⚠️ ImportDataController พังหลัง drop → comment controller ก่อน --- ============================================================ --- -- บุคลากรต้นทาง (4) --- DROP TABLE IF EXISTS EMPLOYEE; --- DROP TABLE IF EXISTS EMPLOYEETEMP; --- DROP TABLE IF EXISTS OFFICER; --- DROP TABLE IF EXISTS positionOfficer; --- -- Profile* staging ตัว P ใหญ่ (14) --- DROP TABLE IF EXISTS ProfileAbilitys; --- DROP TABLE IF EXISTS ProfileCertificates; --- DROP TABLE IF EXISTS ProfileChangeNames; --- DROP TABLE IF EXISTS ProfileChildrens; --- DROP TABLE IF EXISTS ProfileDisciplines; --- DROP TABLE IF EXISTS ProfileDutys; --- DROP TABLE IF EXISTS ProfileEducations; --- DROP TABLE IF EXISTS ProfileHonors; --- DROP TABLE IF EXISTS ProfileInsignias; --- DROP TABLE IF EXISTS ProfileLeaveSummary; --- DROP TABLE IF EXISTS ProfileNopaids; --- DROP TABLE IF EXISTS ProfileOthers; --- DROP TABLE IF EXISTS ProfileSalaries; --- DROP TABLE IF EXISTS ProfileTrainings; --- -- Import master (4) --- DROP TABLE IF EXISTS amphurImport; --- DROP TABLE IF EXISTS educationMis; --- DROP TABLE IF EXISTS provinceImport; --- DROP TABLE IF EXISTS subDistrictImport; --- -- HR_* dead entity (24) — ⛔ มีข้อมูลจริง >1.4M แถว ปรึกษาทีม .NET ก่อน! --- DROP TABLE IF EXISTS HR_CHANGENAME; --- DROP TABLE IF EXISTS HR_CHANGENAME_EMP; --- DROP TABLE IF EXISTS HR_CHANGENAME_EMPTEMP; --- DROP TABLE IF EXISTS HR_CHILDEN; --- DROP TABLE IF EXISTS HR_CHILDEN_EMP; --- DROP TABLE IF EXISTS HR_DISCIPLINE; --- DROP TABLE IF EXISTS HR_DISCIPLINE_EMP; --- DROP TABLE IF EXISTS HR_DISCIPLINE_EMPTEMP; --- DROP TABLE IF EXISTS HR_EDUCATION; --- DROP TABLE IF EXISTS HR_EDUCATION_EMP; --- DROP TABLE IF EXISTS HR_EDUCATION_EMPTEMP; --- DROP TABLE IF EXISTS HR_FUND_COURSE_CODE; --- DROP TABLE IF EXISTS HR_INSIGNIA; --- DROP TABLE IF EXISTS HR_INSIGNIA_EMP; --- DROP TABLE IF EXISTS HR_MAJOR_CODE; --- DROP TABLE IF EXISTS HR_PERSONAL_EMPTEMP_ADDRESS; --- DROP TABLE IF EXISTS HR_PERSONAL_EMPTEMP_FAMILY; --- DROP TABLE IF EXISTS HR_PERSONAL_EMP_ADDRESS; --- DROP TABLE IF EXISTS HR_PERSONAL_EMP_FAMILY; --- DROP TABLE IF EXISTS HR_PERSONAL_OFFICER_ADDRESS; --- DROP TABLE IF EXISTS HR_PERSONAL_OFFICER_FAMILY; --- DROP TABLE IF EXISTS HR_POSITION_EMPLOYEE; --- DROP TABLE IF EXISTS HR_POSITION_EMPLOYEETEMP; --- DROP TABLE IF EXISTS HR_POSITION_OFFICER; --- -- IMPORT_ORG (0 แถว) --- DROP TABLE IF EXISTS IMPORT_ORG; - --- ============================================================ --- กลุ่ม 2 — Import staging ไม่มี entity (2, 0 แถว) --- ============================================================ --- DROP TABLE IF EXISTS HR_CHILDEN_EMPTEMP; --- DROP TABLE IF EXISTS HR_INSIGNIA_EMPTEMP; - --- ============================================================ --- กลุ่ม 3 — ซ้ำ/ทดสอบ/เก่า (5) --- ⚠️ profile*Employee* ยังใช้ (มี controller) — อย่า drop --- ============================================================ --- DROP TABLE IF EXISTS Profiles; --- DROP TABLE IF EXISTS new_table; --- DROP TABLE IF EXISTS new_table_emp; --- DROP TABLE IF EXISTS entity_base; --- DROP TABLE IF EXISTS MET_PREFIX; - -SET FOREIGN_KEY_CHECKS = 1; - --- นับตารางหลังรัน (drop ครบ 54 → เหลือ 157) -SELECT COUNT(*) AS tables_after -FROM information_schema.tables -WHERE table_schema = 'hrms_organization' AND table_type = 'BASE TABLE'; -``` diff --git a/src/controllers/ExRetirementController.ts b/src/controllers/ExRetirementController.ts index 6a19ddfc..c9c45dae 100644 --- a/src/controllers/ExRetirementController.ts +++ b/src/controllers/ExRetirementController.ts @@ -43,6 +43,35 @@ class TokenCache { } } +// network/DNS error codes ที่ถือว่าเป็น transient (ควร retry) +const TRANSIENT_NETWORK_CODES = [ + "EAI_AGAIN", // DNS lookup ล้มเหลวชั่วคราว + "ENOTFOUND", + "ECONNRESET", + "ETIMEDOUT", + "ECONNREFUSED", + "EHOSTUNREACH", + "ENETUNREACH", +]; + +// ตรวจว่า error ควร retry: ครอบ network/DNS (ไม่มี response หรือ code ตรง TRANSIENT) และ HTTP 5xx +function isTransientError(error: any): boolean { + if (!error) return false; + if (!error.response || TRANSIENT_NETWORK_CODES.includes(error.code)) { + return true; + } + return error.response?.status >= 500; +} + +// หน่วงเวลา (backoff) ระหว่าง retry — ใช้รูปแบบ setTimeout เดียวกับ keycloak/index.ts +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// config retry สำหรับเรียก Exprofile +const EXPROFILE_MAX_RETRIES = 3; // จากเดิม 2 +const EXPROFILE_BACKOFF_BASE_MS = 1000; // backoff = base * 2^retryCount → 1s, 2s + @Route("api/v1/org/ex/retirement") @Tags("ExRetirement") @Security("bearerAuth") @@ -60,7 +89,7 @@ export class ExRetirementController extends Controller { }, ) { let retryCount = 0; - const maxRetries = 2; + const maxRetries = EXPROFILE_MAX_RETRIES; while (retryCount < maxRetries) { try { @@ -92,8 +121,9 @@ export class ExRetirementController extends Controller { // return res.data; return new HttpSuccess(res.data.data); } catch (error: any) { - if (error.response?.status === 500 && retryCount < maxRetries - 1) { + if (isTransientError(error) && retryCount < maxRetries - 1) { TokenCache.delete(`${clientId}:${clientSecret}`); + await sleep(EXPROFILE_BACKOFF_BASE_MS * 2 ** retryCount); retryCount++; continue; } @@ -107,7 +137,7 @@ export class ExRetirementController extends Controller { @Get("/document/{documentId}") async getDocument(@Path("documentId") officerDocumentID: string, @Request() req: any) { let retryCount = 0; - const maxRetries = 2; + const maxRetries = EXPROFILE_MAX_RETRIES; while (retryCount < maxRetries) { try { const token = await getToken(clientId, clientSecret); @@ -136,8 +166,9 @@ export class ExRetirementController extends Controller { return; } } catch (error: any) { - if (error.response?.status === 500 && retryCount < maxRetries - 1) { + if (isTransientError(error) && retryCount < maxRetries - 1) { TokenCache.delete(`${clientId}:${clientSecret}`); + await sleep(EXPROFILE_BACKOFF_BASE_MS * 2 ** retryCount); retryCount++; continue; } @@ -171,8 +202,13 @@ async function getToken(ClientID: string, ClientSecret: string): Promise TokenCache.set(cacheKey, token); return token; } catch (error) { - console.error('getToken error:', error); - return Promise.reject({ message: "Error occurred", error }); + // log แบบกระชับ (ลด log noise จากการ dump object ใหญ่ตามที่เห็นใน log จริง) + console.error( + "getToken error:", + error instanceof Error ? `${error.name}: ${error.message}` : error, + ); + // โยน AxiosError ตัวจริงออกไปให้ caller อ่าน code / response / status เพื่อตัดสินใจ retry + throw error; } } @@ -198,7 +234,7 @@ export async function PostRetireToExprofile( } let retryCount = 0; - const maxRetries = 2; + const maxRetries = EXPROFILE_MAX_RETRIES; while (retryCount < maxRetries) { try { @@ -235,8 +271,9 @@ export async function PostRetireToExprofile( return res.data; } catch (error: any) { - if (error.response?.status === 500 && retryCount < maxRetries - 1) { + if (isTransientError(error) && retryCount < maxRetries - 1) { TokenCache.delete(`${clientId}:${clientSecret}`); + await sleep(EXPROFILE_BACKOFF_BASE_MS * 2 ** retryCount); retryCount++; continue; } diff --git a/src/controllers/ImportDataController.ts b/src/controllers/ImportDataController.ts index 28cdfdb5..b424edbb 100644 --- a/src/controllers/ImportDataController.ts +++ b/src/controllers/ImportDataController.ts @@ -106,13 +106,9 @@ import { ProvinceMaster } from "../entities/ProvinceMaster"; import { SubDistrictMaster } from "../entities/SubDistrictMaster"; import { DistrictMaster } from "../entities/DistrictMaster"; import { RequestWithUser } from "../middlewares/user"; -// ⛔ DISABLED 2026-07-21 — MIS tables ทั้งหมดจะถูก DROP (ดู .claude/docs/2026-07-21-drop-unused-tables.sql) -// controller นี้เรียกใช้ mis entities (Profile*, EMPLOYEE, HR_*, ...) ผ่าน getRepository แทบทุก endpoint -// ถ้าปล่อยไว้ → API /api/v1/org/upload/* จะ error ตอน runtime หลัง drop tables -// วิธีคืนค้าง: uncomment ทั้ง 3 บรรทัดล่างนี้ + รัน `npm run build` (หลัง restore tables) -// @Route("api/v1/org/upload") -// @Tags("UPLOAD") -// @Security("bearerAuth") +@Route("api/v1/org/upload") +@Tags("UPLOAD") +@Security("bearerAuth") export class ImportDataController extends Controller { private profileEducationRepo = AppDataSource.getRepository(ProfileEducation); private profileDisciplineRepo = AppDataSource.getRepository(ProfileDiscipline); diff --git a/src/controllers/ProfileController.ts b/src/controllers/ProfileController.ts index 05ab62f8..254f5c46 100644 --- a/src/controllers/ProfileController.ts +++ b/src/controllers/ProfileController.ts @@ -11451,11 +11451,10 @@ export class ProfileController extends Controller { system?: string; }, ) { - // comment ออกก่อนเพราะยังไม่ได้ใช้ - // // ค้นหารายชื่อถ้าไม่ส่ง system มาให้ default ตามทะเบียนประวัติ - // let _system: string = "SYS_REGISTRY_OFFICER"; - // if (body.system) _system = body.system; - // let _data = await new permission().PermissionOrgList(request, _system); + // ค้นหารายชื่อถ้าไม่ส่ง system มาให้ default ตามทะเบียนประวัติ + let _system: string = "SYS_REGISTRY_OFFICER"; + if (body.system) _system = body.system; + let _data = await new permission().PermissionOrgList(request, _system); const findRevision = await this.orgRevisionRepo.findOne({ where: { orgRevisionIsCurrent: true }, }); @@ -11500,10 +11499,50 @@ export class ProfileController extends Controller { .leftJoinAndSelect("current_holders.orgChild2", "orgChild2") .leftJoinAndSelect("current_holders.orgChild3", "orgChild3") .leftJoinAndSelect("current_holders.orgChild4", "orgChild4") - .where(body.system ? "profile.isActive = :isActive" : "profile.isDelete = :isDelete", { - isActive: false, - isDelete: true, + .where("profile.isActive = :isActive AND profile.isDelete = :isDelete", { + isActive: true, + isDelete: false, }) + .andWhere( + _data.root != undefined && _data.root != null + ? _data.root[0] != null + ? `current_holders.orgRootId IN (:...root)` + : `current_holders.orgRootId is null` + : "1=1", + { root: _data.root }, + ) + .andWhere( + _data.child1 != undefined && _data.child1 != null + ? _data.child1[0] != null + ? `current_holders.orgChild1Id IN (:...child1)` + : `current_holders.orgChild1Id is null` + : "1=1", + { child1: _data.child1 }, + ) + .andWhere( + _data.child2 != undefined && _data.child2 != null + ? _data.child2[0] != null + ? `current_holders.orgChild2Id IN (:...child2)` + : `current_holders.orgChild2Id is null` + : "1=1", + { child2: _data.child2 }, + ) + .andWhere( + _data.child3 != undefined && _data.child3 != null + ? _data.child3[0] != null + ? `current_holders.orgChild3Id IN (:...child3)` + : `current_holders.orgChild3Id is null` + : "1=1", + { child3: _data.child3 }, + ) + .andWhere( + _data.child4 != undefined && _data.child4 != null + ? _data.child4[0] != null + ? `current_holders.orgChild4Id IN (:...child4)` + : `current_holders.orgChild4Id is null` + : "1=1", + { child4: _data.child4 }, + ) .andWhere( new Brackets((qb) => { qb.orWhere(body.keyword ? queryLike : "1=1", { keyword: `%${body.keyword}%` }); diff --git a/src/controllers/ProfileEmployeeController.ts b/src/controllers/ProfileEmployeeController.ts index 21eb9a5d..3dd769ed 100644 --- a/src/controllers/ProfileEmployeeController.ts +++ b/src/controllers/ProfileEmployeeController.ts @@ -6172,6 +6172,7 @@ export class ProfileEmployeeController extends Controller { */ @Post("search-personal-no-keycloak") async getProfileBySearchKeywordNoKeyCloak( + @Request() request: RequestWithUser, @Query("page") page: number = 1, @Query("pageSize") pageSize: number = 10, @Body() @@ -6181,6 +6182,10 @@ export class ProfileEmployeeController extends Controller { system?: string; }, ) { + // ค้นหารายชื่อถ้าไม่ส่ง system มาให้ default ตามทะเบียนประวัติ + let _system: string = "SYS_REGISTRY_EMP"; + if (body.system) _system = body.system; + let _data = await new permission().PermissionOrgList(request, _system); const findRevision = await this.orgRevisionRepo.findOne({ where: { orgRevisionIsCurrent: true }, }); @@ -6225,10 +6230,50 @@ export class ProfileEmployeeController extends Controller { .leftJoinAndSelect("current_holders.orgChild2", "orgChild2") .leftJoinAndSelect("current_holders.orgChild3", "orgChild3") .leftJoinAndSelect("current_holders.orgChild4", "orgChild4") - .where(body.system ? "profile.isActive = :isActive" : "profile.isDelete = :isDelete", { - isActive: false, - isDelete: true, + .where("profile.isActive = :isActive AND profile.isDelete = :isDelete", { + isActive: true, + isDelete: false, }) + .andWhere( + _data.root != undefined && _data.root != null + ? _data.root[0] != null + ? `current_holders.orgRootId IN (:...root)` + : `current_holders.orgRootId is null` + : "1=1", + { root: _data.root }, + ) + .andWhere( + _data.child1 != undefined && _data.child1 != null + ? _data.child1[0] != null + ? `current_holders.orgChild1Id IN (:...child1)` + : `current_holders.orgChild1Id is null` + : "1=1", + { child1: _data.child1 }, + ) + .andWhere( + _data.child2 != undefined && _data.child2 != null + ? _data.child2[0] != null + ? `current_holders.orgChild2Id IN (:...child2)` + : `current_holders.orgChild2Id is null` + : "1=1", + { child2: _data.child2 }, + ) + .andWhere( + _data.child3 != undefined && _data.child3 != null + ? _data.child3[0] != null + ? `current_holders.orgChild3Id IN (:...child3)` + : `current_holders.orgChild3Id is null` + : "1=1", + { child3: _data.child3 }, + ) + .andWhere( + _data.child4 != undefined && _data.child4 != null + ? _data.child4[0] != null + ? `current_holders.orgChild4Id IN (:...child4)` + : `current_holders.orgChild4Id is null` + : "1=1", + { child4: _data.child4 }, + ) .andWhere( new Brackets((qb) => { qb.orWhere(body.keyword ? queryLike : "1=1", { keyword: `%${body.keyword}%` }); diff --git a/src/seed/README.md b/src/seed/README.md deleted file mode 100644 index 5c442c09..00000000 --- a/src/seed/README.md +++ /dev/null @@ -1,59 +0,0 @@ -# Seed — Mock data สำหรับ dev/test (`src/seed/`) - -> ⚠️ **DESTRUCTIVE** — seed หลายตัวล้างข้อมูลจริง (org/pos/profile) เพื่อใส่ mock แทน -> **ห้ามรันบน production/staging** — เฉพาะ DB test เท่านั้น - -## seed คืออะไร - -ชุด mock data สำหรับทำ DB test ให้ dev เริ่มงานได้ทันทีโดยไม่มีข้อมูลจริง (PII) ปน: -- โครงสร้างองค์กร mock: สำนักA → หน่วยงานA → ฝ่ายA → กลุ่มงานA (revision "โครงสร้าง 2569") -- ตำแหน่ง mock: 20 posMaster (ข้าราชการ) + 10 employeePosMaster (ลูกจ้าง) -- พนักงาน mock: 20 Profile + 10 ProfileEmployee (ชื่อไทย + citizenId ผ่าน checksum + birthDate) -- ผูกคนเกาะตำแหน่ง (current_holderId) - -## แยกจาก migration หลัก - -seed **ไม่ได้อยู่ใน migration pipeline หลัก** (`src/migration/`) → `npm run migration:run` (หลัก) **จะไม่เจอ seed** → seed จะไม่ทำลายข้อมูลบน env อื่น - -seed มี DataSource + runner ของตัวเอง → รันด้วย `npm run seed:*` - -## คำสั่ง - -```sh -npm run seed:show # ดูสถานะ seed (applied/pending) -npm run seed:run # รัน seed ที่ยังไม่ applied -npm run seed:revert # revert seed ตัวสุดท้าย (เรียก down()) -``` - -ใช้ DataSource `src/seed/data-source.ts` (glob `src/seed/migrations/**`) - -## โครงสร้างไฟล์ - -``` -src/seed/ -├── README.md (ไฟล์นี้) -├── data-source.ts (SeedDataSource — แยกจาก src/database/data-source.ts) -├── mockData.ts (helper: ชื่อไทย + genValidCitizenId checksum + deterministic UUID + genBirthDate) -└── migrations/ - ├── 1782100000001-seed_org_revision_2569.ts (CP3: orgRevision → 1, "โครงสร้าง 2569", ล้าง org/pos เดิม) - ├── 1782100000002-seed_mock_org_structure.ts (CP4: org mock สำนักA/หน่วยงานA/ฝ่ายA/กลุ่มงานA) - ├── 1782100000003-seed_mock_positions.ts (CP5a: 20 posMaster + 10 employeePosMaster) - ├── 1782100000004-seed_mock_profiles.ts (CP5b: ล้าง profile เดิม + 20 Profile + 10 ProfileEmployee) - └── 1782100000005-seed_link_holders.ts (CP5c: ผูกคนเกาะตำแหน่ง current_holderId) -``` - -## ค่า mock - -- **citizenId**: สุ่ม 13 หลักผ่าน Thai checksum จริง + ห้ามตรงของเดิม (snapshot block list ใน CP5b ก่อนลบ) -- **birthDate**: deterministic `1970+(i%30)` → ใช้คำนวณ password Keycloak ใน CP7 (`ddMMyyyy` พ.ศ.) -- **UUID**: deterministic (`mockUUID(prefix, i)`) เพื่อ idempotent + FK อ้างข้าม migration ได้ -- **audit columns**: `createdUserId/lastUpdateUserId = 00000000-...`, `createdFullName/lastUpdateFullName = 'System Administrator'` (ตาม `EntityBase`) - -## ⚠️ ข้อควรระวัง - -1. **TypeORM quirk**: `1782100000003-seed_mock_positions.ts` (CP5a) INSERT SELECT WHERE NOT EXISTS อาจไม่ execute ใน transaction ของ TypeORM version นี้ → ถ้า `seed:run` แล้ว posMaster/employeePosMaster ว่าง ให้ seed ตรงผ่าน script (ดู plan CP5a note) -2. **ลำดับ FK**: CP3→CP4→CP5a→CP5b→CP5c (ตาม timestamp 178210000000{1..5}) — seed ต้องรันตามลำดับ -3. **CP6/CP7 (Keycloak) defer**: seed นี้ยังไม่ผูก Keycloak — mock profile `keycloak=NULL` (login ไม่ได้). ดู plan สำหรับ CP7 - -## เอกสารที่เกี่ยวข้อง -- `.claude/plans/plan_cleanup-mock-seed.md` — **ไฟล์เดียวจบ**: สรุปตาราง DB (Part A) + plan checklist CP0-CP7 (Part B) + drop script SQL (Part C) diff --git a/src/seed/data-source.ts b/src/seed/data-source.ts deleted file mode 100644 index f77aba5a..00000000 --- a/src/seed/data-source.ts +++ /dev/null @@ -1,34 +0,0 @@ -import "dotenv/config"; -import "reflect-metadata"; -import { DataSource } from "typeorm"; - -/** - * Seed DataSource — แยกจาก migration หลัก (src/database/data-source.ts) - * - * glob ชี้เฉพาะ src/seed/migrations/** ดังนั้น `npm run migration:run` (migration หลัก) - * จะไม่เจอ seed migration เหล่านี้ → seed mock จะไม่ทำลายข้อมูลบน env อื่น (prod/staging) - * - * รัน seed ผ่าน: `npm run seed:run` / `seed:show` / `seed:revert` - * - * ⚠️ seed migration หลายตัว DESTRUCTIVE (CP3 ล้าง org/pos, CP5b ล้าง profile) - * ห้ามรันบน production — เฉพาะ DB test (DB_HOST=192.168.1.68) - */ -export const SeedDataSource = new DataSource({ - type: "mysql", - database: process.env.DB_NAME, - host: process.env.DB_HOST, - port: +(process.env.DB_PORT || 3306), - username: process.env.DB_USERNAME, - password: process.env.DB_PASSWORD, - connectorPackage: "mysql2", - synchronize: false, - logging: true, - entities: [], - migrations: - process.env.NODE_ENV !== "production" - ? ["src/seed/migrations/**/*.ts"] - : ["dist/seed/migrations/**/*{.ts,.js}"], - extra: { - timezone: "+07:00", // Bangkok timezone (UTC+7) - }, -}); diff --git a/src/seed/migrations/1782100000001-seed_org_revision_2569.ts b/src/seed/migrations/1782100000001-seed_org_revision_2569.ts deleted file mode 100644 index 55e584cc..00000000 --- a/src/seed/migrations/1782100000001-seed_org_revision_2569.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; -import { AUDIT, genRevisionUUID } from "../mockData"; - -/** - * CP3 — Curate orgRevision: wipe all existing org/position structure and seed - * a single current revision "โครงสร้าง 2569". Mock org tree + positions are - * inserted by later migrations (CP4/CP5a). - * - * DESTRUCTIVE — relies on CP1 mysqldump backup for rollback. - */ -export class SeedOrgRevision25691782100000001 implements MigrationInterface { - public name = "SeedOrgRevision25691782100000001"; - - public async up(queryRunner: QueryRunner): Promise { - const REV = genRevisionUUID(0); - const audit = [AUDIT.createdUserId, AUDIT.lastUpdateUserId, AUDIT.createdFullName, AUDIT.lastUpdateFullName]; - - await queryRunner.query("SET FOREIGN_KEY_CHECKS = 0"); - - // Wipe all org/position tables (child → parent order). FK constraints are - // mostly stripped in this repo, but FK_CHECKS=0 guards the few that remain. - // Some tables may already be gone (e.g. positionOfficer was dropped in the - // cleanup step) — only DELETE tables that still exist. - const wipeTables = [ - "posMasterHistory", - "posMasterEmployeeHistory", - "posMasterEmployeeTempHistory", - "posMasterAct", - "posMasterAssign", - "positionSalaryEditHistory", - "position", - "employeePosition", - "posMaster", - "employeePosMaster", - "employeeTempPosMaster", - "orgChild4", - "orgChild3", - "orgChild2", - "orgChild1", - "permissionOrg", // FK -> orgRoot - "orgRoot", - "orgRevision", - ]; - const existingRows = (await queryRunner.query( - `SELECT TABLE_NAME AS t FROM information_schema.tables - WHERE TABLE_SCHEMA = DATABASE() - AND TABLE_NAME IN (${wipeTables.map(() => "?").join(",")})`, - wipeTables, - )) as Array<{ t: string }>; - const existing = new Set(existingRows.map((r) => r.t)); - for (const t of wipeTables) { - if (existing.has(t)) { - await queryRunner.query(`DELETE FROM \`${t}\``); - } - } - - // Seed single current revision "โครงสร้าง 2569" (idempotent). - await queryRunner.query( - `INSERT INTO orgRevision - (id, orgRevisionName, orgRevisionIsCurrent, orgRevisionIsDraft, isLock, - orgRevisionCreatedAt, createdAt, lastUpdatedAt, createdUserId, lastUpdateUserId, createdFullName, lastUpdateFullName) - SELECT ?, 'โครงสร้าง 2569', true, false, false, NOW(), NOW(), NOW(), ?, ?, ?, ? - WHERE NOT EXISTS (SELECT 1 FROM orgRevision WHERE id = ?)`, - [REV, ...audit, REV], - ); - - await queryRunner.query("SET FOREIGN_KEY_CHECKS = 1"); - } - - public async down(queryRunner: QueryRunner): Promise { - // Best-effort: remove only the seeded revision row. Full restore = CP1 backup. - await queryRunner.query("SET FOREIGN_KEY_CHECKS = 0"); - await queryRunner.query(`DELETE FROM orgRevision WHERE id = ?`, [genRevisionUUID(0)]); - await queryRunner.query("SET FOREIGN_KEY_CHECKS = 1"); - } -} diff --git a/src/seed/migrations/1782100000002-seed_mock_org_structure.ts b/src/seed/migrations/1782100000002-seed_mock_org_structure.ts deleted file mode 100644 index a3c1e61e..00000000 --- a/src/seed/migrations/1782100000002-seed_mock_org_structure.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; -import { - AUDIT, - genRevisionUUID, - genRootUUID, - genChild1UUID, - genChild2UUID, - genChild3UUID, -} from "../mockData"; - -/** - * CP4 — Seed mock org structure: สำนักA (DEPARTMENT) → หน่วยงานA (OFFICE) → - * ฝ่ายA (DIVISION) → กลุ่มงานA (SECTION), all under revision 2569. - */ -export class SeedMockOrgStructure1782100000002 implements MigrationInterface { - public name = "SeedMockOrgStructure1782100000002"; - - public async up(queryRunner: QueryRunner): Promise { - const REV = genRevisionUUID(0); - const ROOT = genRootUUID(0); - const C1 = genChild1UUID(0); - const C2 = genChild2UUID(0); - const C3 = genChild3UUID(0); - const audit = [AUDIT.createdUserId, AUDIT.lastUpdateUserId, AUDIT.createdFullName, AUDIT.lastUpdateFullName]; - const auditCols = "createdAt, lastUpdatedAt, createdUserId, lastUpdateUserId, createdFullName, lastUpdateFullName"; - const auditPlace = "NOW(), NOW(), ?, ?, ?, ?"; - - // orgRoot: สำนักA (DEPARTMENT) - await queryRunner.query( - `INSERT INTO orgRoot (id, orgRootName, orgRootShortName, orgRootRank, orgRootOrder, orgRevisionId, ${auditCols}) - SELECT ?, 'สำนักA', 'สน.A', 'DEPARTMENT', 1, ?, ${auditPlace} - WHERE NOT EXISTS (SELECT 1 FROM orgRoot WHERE id = ?)`, - [ROOT, REV, ...audit, ROOT], - ); - - // orgChild1: หน่วยงานA (OFFICE) — FK root+rev - await queryRunner.query( - `INSERT INTO orgChild1 (id, orgChild1Name, orgChild1ShortName, orgChild1Rank, orgChild1Order, orgRootId, orgRevisionId, ${auditCols}) - SELECT ?, 'หน่วยงานA', 'น.A', 'OFFICE', 1, ?, ?, ${auditPlace} - WHERE NOT EXISTS (SELECT 1 FROM orgChild1 WHERE id = ?)`, - [C1, ROOT, REV, ...audit, C1], - ); - - // orgChild2: ฝ่ายA (DIVISION) — FK root+c1+rev - await queryRunner.query( - `INSERT INTO orgChild2 (id, orgChild2Name, orgChild2ShortName, orgChild2Rank, orgChild2Order, orgRootId, orgChild1Id, orgRevisionId, ${auditCols}) - SELECT ?, 'ฝ่ายA', 'ฝ.A', 'DIVISION', 1, ?, ?, ?, ${auditPlace} - WHERE NOT EXISTS (SELECT 1 FROM orgChild2 WHERE id = ?)`, - [C2, ROOT, C1, REV, ...audit, C2], - ); - - // orgChild3: กลุ่มงานA (SECTION) — FK root+c1+c2+rev - await queryRunner.query( - `INSERT INTO orgChild3 (id, orgChild3Name, orgChild3ShortName, orgChild3Rank, orgChild3Order, orgRootId, orgChild1Id, orgChild2Id, orgRevisionId, ${auditCols}) - SELECT ?, 'กลุ่มงานA', 'กล.A', 'SECTION', 1, ?, ?, ?, ?, ${auditPlace} - WHERE NOT EXISTS (SELECT 1 FROM orgChild3 WHERE id = ?)`, - [C3, ROOT, C1, C2, REV, ...audit, C3], - ); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DELETE FROM orgChild3 WHERE id = ?`, [genChild3UUID(0)]); - await queryRunner.query(`DELETE FROM orgChild2 WHERE id = ?`, [genChild2UUID(0)]); - await queryRunner.query(`DELETE FROM orgChild1 WHERE id = ?`, [genChild1UUID(0)]); - await queryRunner.query(`DELETE FROM orgRoot WHERE id = ?`, [genRootUUID(0)]); - } -} diff --git a/src/seed/migrations/1782100000003-seed_mock_positions.ts b/src/seed/migrations/1782100000003-seed_mock_positions.ts deleted file mode 100644 index f32ae514..00000000 --- a/src/seed/migrations/1782100000003-seed_mock_positions.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; -import { - AUDIT, - genRevisionUUID, - genRootUUID, - genChild1UUID, - genChild2UUID, - genChild3UUID, - genPosUUID, - genEmpPosUUID, -} from "../mockData"; - -/** - * CP5a — Seed mock positions: 20 posMaster (ข้าราชการ) + 10 employeePosMaster - * (ลูกจ้าง) under กลุ่มงานA (orgChild3). current_holderId = NULL here, linked - * in CP5c. - */ -export class SeedMockPositions1782100000003 implements MigrationInterface { - public name = "SeedMockPositions1782100000003"; - - public async up(queryRunner: QueryRunner): Promise { - const REV = genRevisionUUID(0); - const ROOT = genRootUUID(0); - const C1 = genChild1UUID(0); - const C2 = genChild2UUID(0); - const C3 = genChild3UUID(0); - const audit = [AUDIT.createdUserId, AUDIT.lastUpdateUserId, AUDIT.createdFullName, AUDIT.lastUpdateFullName]; - const insertPos = async (table: string, uuid: string, no: number) => { - await queryRunner.query( - `INSERT INTO \`${table}\` - (id, posMasterNo, orgRootId, orgChild1Id, orgChild2Id, orgChild3Id, - orgRevisionId, current_holderId, next_holderId, - createdAt, lastUpdatedAt, createdUserId, lastUpdateUserId, createdFullName, lastUpdateFullName) - SELECT ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NOW(), NOW(), ?, ?, ?, ? - WHERE NOT EXISTS (SELECT 1 FROM \`${table}\` WHERE id = ?)`, - [uuid, no, ROOT, C1, C2, C3, REV, ...audit, uuid], - ); - }; - - for (let i = 0; i < 20; i++) { - await insertPos("posMaster", genPosUUID(i), i + 1); - } - for (let i = 0; i < 10; i++) { - await insertPos("employeePosMaster", genEmpPosUUID(i), i + 1); - } - } - - public async down(queryRunner: QueryRunner): Promise { - for (let i = 0; i < 20; i++) { - await queryRunner.query(`DELETE FROM posMaster WHERE id = ?`, [genPosUUID(i)]); - } - for (let i = 0; i < 10; i++) { - await queryRunner.query(`DELETE FROM employeePosMaster WHERE id = ?`, [genEmpPosUUID(i)]); - } - } -} diff --git a/src/seed/migrations/1782100000004-seed_mock_profiles.ts b/src/seed/migrations/1782100000004-seed_mock_profiles.ts deleted file mode 100644 index 10de481d..00000000 --- a/src/seed/migrations/1782100000004-seed_mock_profiles.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; -import { - AUDIT, - genProfileUUID, - genEmpProfileUUID, - genValidCitizenId, - genBirthDate, - mockName, - MOCK_GENDERS, - MOCK_RELIGIONS, - MOCK_BLOOD, -} from "../mockData"; - -/** - * CP5b — Replace real profile/profileEmployee data with 30 mock people - * (20 Profile / 10 ProfileEmployee). Mock citizenId passes the Thai checksum, - * never collides with any previously-existing citizenId (block list). - * - * DESTRUCTIVE — relies on CP1 mysqldump backup for rollback. - * - * Per user: clear profile.keycloak / profileEmployee.keycloak BEFORE deleting, - * so the Keycloak orphan-clear step (CP6) treats old KC users as orphans. - */ -export class SeedMockProfiles1782100000004 implements MigrationInterface { - public name = "SeedMockProfiles1782100000004"; - - public async up(queryRunner: QueryRunner): Promise { - // 1. Snapshot existing citizenIds → block list (mock ids must not collide) - const realIdRows = (await queryRunner.query( - `SELECT citizenId FROM profile WHERE citizenId IS NOT NULL AND citizenId <> '' - UNION - SELECT citizenId FROM profileEmployee WHERE citizenId IS NOT NULL AND citizenId <> ''`, - )) as Array<{ citizenId: string }>; - const blockList = new Set(realIdRows.map((r) => r.citizenId)); - - await queryRunner.query("SET FOREIGN_KEY_CHECKS = 0"); - - // 2. Clear Keycloak pointers BEFORE delete (per user requirement) - await queryRunner.query(`UPDATE profile SET keycloak = NULL`); - await queryRunner.query(`UPDATE profileEmployee SET keycloak = NULL`); - - // 3. Detach any holder references - await queryRunner.query(`UPDATE posMaster SET current_holderId = NULL, next_holderId = NULL`); - await queryRunner.query(`UPDATE employeePosMaster SET current_holderId = NULL, next_holderId = NULL`); - - // 4. Delete child tables that FK profile/profileEmployee (dynamic discovery) - const childRows = (await queryRunner.query( - `SELECT DISTINCT TABLE_NAME AS t FROM information_schema.KEY_COLUMN_USAGE - WHERE TABLE_SCHEMA = DATABASE() - AND REFERENCED_TABLE_NAME IN ('profile','profileEmployee')`, - )) as Array<{ t: string }>; - for (const c of childRows) { - await queryRunner.query(`DELETE FROM \`${c.t}\``); - } - - // 5. Delete real profile data - await queryRunner.query(`DELETE FROM profile`); - await queryRunner.query(`DELETE FROM profileEmployee`); - - const audit = [AUDIT.createdUserId, AUDIT.lastUpdateUserId, AUDIT.createdFullName, AUDIT.lastUpdateFullName]; - - // 6. Insert 20 mock Profile (ข้าราชการ) - for (let i = 0; i < 20; i++) { - const nm = mockName(i); - const cid = genValidCitizenId(i, blockList); - await queryRunner.query( - `INSERT INTO profile - (id, citizenId, firstName, lastName, prefix, gender, religion, bloodGroup, - birthDate, isActive, isDelete, isProbation, isLeave, isRetirement, statusCheckEdit, keycloak, - createdAt, lastUpdatedAt, createdUserId, lastUpdateUserId, createdFullName, lastUpdateFullName) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, true, false, false, false, false, 'PENDING', NULL, NOW(), NOW(), ?, ?, ?, ?)`, - [ - genProfileUUID(i), - cid, - nm.firstName, - nm.lastName, - nm.prefix, - MOCK_GENDERS[i % MOCK_GENDERS.length], - MOCK_RELIGIONS[i % MOCK_RELIGIONS.length], - MOCK_BLOOD[i % MOCK_BLOOD.length], - genBirthDate(i), - ...audit, - ], - ); - } - - // 7. Insert 10 mock ProfileEmployee (ลูกจ้าง, alternate PERM/TEMP) - for (let i = 0; i < 10; i++) { - const idx = i + 20; - const nm = mockName(idx); - const cid = genValidCitizenId(idx, blockList); - const empClass = i % 2 === 0 ? "PERM" : "TEMP"; - await queryRunner.query( - `INSERT INTO profileEmployee - (id, citizenId, firstName, lastName, prefix, gender, religion, bloodGroup, - birthDate, employeeClass, isActive, isDelete, isLeave, statusCheckEdit, keycloak, - createdAt, lastUpdatedAt, createdUserId, lastUpdateUserId, createdFullName, lastUpdateFullName) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, true, false, false, 'PENDING', NULL, NOW(), NOW(), ?, ?, ?, ?)`, - [ - genEmpProfileUUID(i), - cid, - nm.firstName, - nm.lastName, - nm.prefix, - MOCK_GENDERS[idx % MOCK_GENDERS.length], - MOCK_RELIGIONS[idx % MOCK_RELIGIONS.length], - MOCK_BLOOD[idx % MOCK_BLOOD.length], - genBirthDate(idx), - empClass, - ...audit, - ], - ); - } - - await queryRunner.query("SET FOREIGN_KEY_CHECKS = 1"); - } - - public async down(): Promise { - // No safe in-place rollback — restore from CP1 mysqldump backup. - } -} diff --git a/src/seed/migrations/1782100000005-seed_link_holders.ts b/src/seed/migrations/1782100000005-seed_link_holders.ts deleted file mode 100644 index 8f048fa7..00000000 --- a/src/seed/migrations/1782100000005-seed_link_holders.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; -import { - genPosUUID, - genEmpPosUUID, - genProfileUUID, - genEmpProfileUUID, -} from "../mockData"; - -/** - * CP5c — Link mock people to mock positions: set current_holderId so the 30 - * mock people are attached to the mock org structure. - * posMaster[i].current_holderId = profile[i] (0..19) - * employeePosMaster[i].current_holderId = profileEmployee[i] (0..9) - */ -export class SeedLinkHolders1782100000005 implements MigrationInterface { - public name = "SeedLinkHolders1782100000005"; - - public async up(queryRunner: QueryRunner): Promise { - for (let i = 0; i < 20; i++) { - await queryRunner.query( - `UPDATE posMaster SET current_holderId = ? WHERE id = ?`, - [genProfileUUID(i), genPosUUID(i)], - ); - } - for (let i = 0; i < 10; i++) { - await queryRunner.query( - `UPDATE employeePosMaster SET current_holderId = ? WHERE id = ?`, - [genEmpProfileUUID(i), genEmpPosUUID(i)], - ); - } - } - - public async down(queryRunner: QueryRunner): Promise { - const posIds = Array.from({ length: 20 }, (_, i) => `'${genPosUUID(i)}'`).join(","); - const empPosIds = Array.from({ length: 10 }, (_, i) => `'${genEmpPosUUID(i)}'`).join(","); - await queryRunner.query(`UPDATE posMaster SET current_holderId = NULL WHERE id IN (${posIds})`); - await queryRunner.query(`UPDATE employeePosMaster SET current_holderId = NULL WHERE id IN (${empPosIds})`); - } -} diff --git a/src/seed/mockData.ts b/src/seed/mockData.ts deleted file mode 100644 index 77e3a339..00000000 --- a/src/seed/mockData.ts +++ /dev/null @@ -1,111 +0,0 @@ -// Mock data helpers for seed migrations. -// Filename starts with underscore "_" so TypeORM migration runner skips it -// (only files exporting a class implementing MigrationInterface are loaded). -// -// All generators are DETERMINISTIC (same index -> same value) so seed migrations -// stay idempotent across re-runs, and foreign keys between mock rows can be -// computed up-front (e.g. PosMaster.current_holderId <- genProfileUUID(i)). - -// --------------------------------------------------------------------------- -// Audit columns — must match src/entities/base/Base.ts defaults -// --------------------------------------------------------------------------- -export const AUDIT = { - createdUserId: "00000000-0000-0000-0000-000000000000", - lastUpdateUserId: "00000000-0000-0000-0000-000000000000", - createdFullName: "System Administrator", - lastUpdateFullName: "System Administrator", -} as const; - -// --------------------------------------------------------------------------- -// Deterministic UUID generator (valid UUID format, no external dep) -// 8 hex from djb2 hash of prefix | 4 | 4 (v4-ish) | 4 | 12 hex from idx -// unique per (prefix, idx); different prefixes -> different leading 8 hex -// --------------------------------------------------------------------------- -export function mockUUID(prefix: string, idx: number): string { - let h = 5381; - for (let k = 0; k < prefix.length; k++) h = ((h << 5) + h + prefix.charCodeAt(k)) >>> 0; - const head = h.toString(16).padStart(8, "0").slice(-8); - const tail = (idx >>> 0).toString(16).padStart(12, "0").slice(-12); - return `${head}-0000-4000-8000-${tail}`; -} - -export const genRevisionUUID = (i = 0) => mockUUID("orgRevision", i); -export const genRootUUID = (i: number) => mockUUID("orgRoot", i); -export const genChild1UUID = (i: number) => mockUUID("orgChild1", i); -export const genChild2UUID = (i: number) => mockUUID("orgChild2", i); -export const genChild3UUID = (i: number) => mockUUID("orgChild3", i); -export const genChild4UUID = (i: number) => mockUUID("orgChild4", i); -export const genPosUUID = (i: number) => mockUUID("posMaster", i); -export const genEmpPosUUID = (i: number) => mockUUID("employeePosMaster", i); -export const genProfileUUID = (i: number) => mockUUID("profile", i); -export const genEmpProfileUUID = (i: number) => mockUUID("profileEmployee", i); - -// --------------------------------------------------------------------------- -// Thai citizen ID (13 digits) — passes real checksum, but never collides with -// existing IDs in `blockList` (snapshot of real citizenIds taken before delete). -// Deterministic pseudo-random from idx so re-runs are stable. -// --------------------------------------------------------------------------- -export function genValidCitizenId(idx: number, blockList: Set): string { - for (let attempt = 0; attempt < 100000; attempt++) { - // LCG-style 12-digit base, seeded by (idx, attempt) -> looks random, reproducible - const seed = (idx * 2654435761 + attempt * 40503 + 123456789012) >>> 0; - const seed2 = (seed ^ (idx * 2246822519)) >>> 0; - const base = - (seed % 100000000).toString().padStart(8, "0") + - (seed2 % 10000).toString().padStart(4, "0"); // 12 digits - let sum = 0; - for (let k = 0; k < 12; k++) sum += parseInt(base[k], 10) * (13 - k); - const check = (11 - (sum % 11)) % 10; - const id = base + check; - if (!blockList.has(id)) return id; - } - throw new Error(`genValidCitizenId: cannot find non-colliding id for idx=${idx}`); -} - -// --------------------------------------------------------------------------- -// Thai mock names -// --------------------------------------------------------------------------- -const FIRST_M = [ - "สมชาย", "วิชาญ", "ประยุทธ", "อนุชา", "ธีรพงษ์", "นิรันดร", "อดุลย์", "เกริก", - "ภาคิน", "วีระ", "สมศักดิ์", "กิตติ", "ชัยรัตน์", "ปิยะ", "ธนภัทร", -]; -const FIRST_F = [ - "สมหญิง", "มาลี", "ปนัดดา", "อรุณี", "ภัทรา", "กัลยา", "วรรณี", "สุภาพร", - "อัจฉรา", "นันทิดา", "พรทิพา", "จันทร์เพ็ญ", "อรทัย", "สมศรี", "ประภาพร", -]; -const LAST = [ - "ใจดี", "รักไทย", "ทองคำ", "ศรีสุข", "แก้วใส", "สุขุม", "มั่นคง", "เจริญสุข", - "อยู่สบาย", "ทรงศักดิ์", "บุญมี", "วงศ์ไพศาล", "เกียรติศักดิ์", "ชัยมงคล", - "พงษ์ไพจิตร", "รัตนชัย", "สีลา", "นวลละออง", "ตั้งตรงจิต", "อินทรพงศ์", -]; - -export interface MockName { - prefix: string; - firstName: string; - lastName: string; -} - -export function mockName(i: number): MockName { - const male = i % 2 === 0; // alternate gender - const firstName = male ? FIRST_M[i % FIRST_M.length] : FIRST_F[i % FIRST_F.length]; - const lastName = LAST[(i * 7) % LAST.length]; - const prefix = male ? "นาย" : i % 3 === 0 ? "นาง" : "นางสาว"; - return { prefix, firstName, lastName }; -} - -// Mock demographic master values (Profile stores these as plain strings, not FK) -export const MOCK_GENDERS = ["ชาย", "หญิง"] as const; -export const MOCK_RELIGIONS = ["พุทธ", "คริสต์", "อิสลาม"] as const; -export const MOCK_BLOOD = ["O", "A", "B", "AB"] as const; - -// Deterministic birthDate (datetime string) for mock people. -// CP7 derives the Keycloak password from this via ddMM(yyyy+543) format, -// matching UserController.createUserImport (L1063-1071). -export function genBirthDate(i: number): string { - const year = 1970 + (i % 30); // 1970-1999 - const month = (i % 12) + 1; // 1-12 - const day = (i % 28) + 1; // 1-28 (safe for all months) - const mm = month.toString().padStart(2, "0"); - const dd = day.toString().padStart(2, "0"); - return `${year}-${mm}-${dd} 00:00:00`; -}