Compare commits

...
Sign in to create a new pull request.

1 commit

11 changed files with 836 additions and 3 deletions

View file

@ -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"

View file

@ -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';
```

View file

@ -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);

59
src/seed/README.md Normal file
View file

@ -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)

34
src/seed/data-source.ts Normal file
View file

@ -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)
},
});

View file

@ -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<void> {
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<void> {
// 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");
}
}

View file

@ -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<void> {
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<void> {
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)]);
}
}

View file

@ -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<void> {
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<void> {
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)]);
}
}
}

View file

@ -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<void> {
// 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<void> {
// No safe in-place rollback — restore from CP1 mysqldump backup.
}
}

View file

@ -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<void> {
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<void> {
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})`);
}
}

111
src/seed/mockData.ts Normal file
View file

@ -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>): 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`;
}