From 8201ac51af1a46806fe6b527639ce4f15d4e966d Mon Sep 17 00:00:00 2001 From: harid Date: Wed, 22 Jul 2026 18:01:45 +0700 Subject: [PATCH 1/3] =?UTF-8?q?Plan=20+=20=E0=B8=AA=E0=B8=A3=E0=B8=B8?= =?UTF-8?q?=E0=B8=9B=E0=B8=95=E0=B8=B2=E0=B8=A3=E0=B8=B2=E0=B8=87=20+=20Dr?= =?UTF-8?q?op=20Script=20=E2=80=94=20Cleanup=20&=20Mock=20Seed=20hrms=5For?= =?UTF-8?q?ganization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 + plans/plan_cleanup-mock-seed.md | 263 ++++++++++++++++++ src/controllers/ImportDataController.ts | 10 +- src/seed/README.md | 59 ++++ src/seed/data-source.ts | 34 +++ .../1782100000001-seed_org_revision_2569.ts | 76 +++++ .../1782100000002-seed_mock_org_structure.ts | 67 +++++ .../1782100000003-seed_mock_positions.ts | 56 ++++ .../1782100000004-seed_mock_profiles.ts | 121 ++++++++ .../1782100000005-seed_link_holders.ts | 39 +++ src/seed/mockData.ts | 111 ++++++++ 11 files changed, 836 insertions(+), 3 deletions(-) create mode 100644 plans/plan_cleanup-mock-seed.md create mode 100644 src/seed/README.md create mode 100644 src/seed/data-source.ts create mode 100644 src/seed/migrations/1782100000001-seed_org_revision_2569.ts create mode 100644 src/seed/migrations/1782100000002-seed_mock_org_structure.ts create mode 100644 src/seed/migrations/1782100000003-seed_mock_positions.ts create mode 100644 src/seed/migrations/1782100000004-seed_mock_profiles.ts create mode 100644 src/seed/migrations/1782100000005-seed_link_holders.ts create mode 100644 src/seed/mockData.ts diff --git a/package.json b/package.json index ac8d5ca7..fb559095 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,9 @@ "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 new file mode 100644 index 00000000..4010e667 --- /dev/null +++ b/plans/plan_cleanup-mock-seed.md @@ -0,0 +1,263 @@ +# 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/ImportDataController.ts b/src/controllers/ImportDataController.ts index b424edbb..28cdfdb5 100644 --- a/src/controllers/ImportDataController.ts +++ b/src/controllers/ImportDataController.ts @@ -106,9 +106,13 @@ import { ProvinceMaster } from "../entities/ProvinceMaster"; import { SubDistrictMaster } from "../entities/SubDistrictMaster"; import { DistrictMaster } from "../entities/DistrictMaster"; import { RequestWithUser } from "../middlewares/user"; -@Route("api/v1/org/upload") -@Tags("UPLOAD") -@Security("bearerAuth") +// ⛔ 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") export class ImportDataController extends Controller { private profileEducationRepo = AppDataSource.getRepository(ProfileEducation); private profileDisciplineRepo = AppDataSource.getRepository(ProfileDiscipline); diff --git a/src/seed/README.md b/src/seed/README.md new file mode 100644 index 00000000..5c442c09 --- /dev/null +++ b/src/seed/README.md @@ -0,0 +1,59 @@ +# 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 new file mode 100644 index 00000000..f77aba5a --- /dev/null +++ b/src/seed/data-source.ts @@ -0,0 +1,34 @@ +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 new file mode 100644 index 00000000..55e584cc --- /dev/null +++ b/src/seed/migrations/1782100000001-seed_org_revision_2569.ts @@ -0,0 +1,76 @@ +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 new file mode 100644 index 00000000..a3c1e61e --- /dev/null +++ b/src/seed/migrations/1782100000002-seed_mock_org_structure.ts @@ -0,0 +1,67 @@ +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 new file mode 100644 index 00000000..f32ae514 --- /dev/null +++ b/src/seed/migrations/1782100000003-seed_mock_positions.ts @@ -0,0 +1,56 @@ +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 new file mode 100644 index 00000000..10de481d --- /dev/null +++ b/src/seed/migrations/1782100000004-seed_mock_profiles.ts @@ -0,0 +1,121 @@ +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 new file mode 100644 index 00000000..8f048fa7 --- /dev/null +++ b/src/seed/migrations/1782100000005-seed_link_holders.ts @@ -0,0 +1,39 @@ +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 new file mode 100644 index 00000000..77e3a339 --- /dev/null +++ b/src/seed/mockData.ts @@ -0,0 +1,111 @@ +// 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`; +} From 22ea54aeb375e4cfac3a6840de746b6ce8265539 Mon Sep 17 00:00:00 2001 From: waruneeauy Date: Wed, 22 Jul 2026 22:20:07 +0700 Subject: [PATCH 2/3] fixed getaddrinfo EAI_AGAIN exprofile.bangkok.go.th --- src/controllers/ExRetirementController.ts | 53 +++++++++++++++++++---- 1 file changed, 45 insertions(+), 8 deletions(-) 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; } From 02c30c558ae5d254e93e2aac63dd3c70aefb71ec Mon Sep 17 00:00:00 2001 From: harid Date: Thu, 23 Jul 2026 13:53:05 +0700 Subject: [PATCH 3/3] fix --- src/controllers/ProfileController.ts | 55 +++++++++++++++++--- src/controllers/ProfileEmployeeController.ts | 51 ++++++++++++++++-- 2 files changed, 95 insertions(+), 11 deletions(-) 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}%` });