3 · Persistence — TypeORM + MSSQL (38 entities)
บทนี้อธิบายชั้นข้อมูลของ recruitment-api (branch: prod): connection ทั้ง 4 ชุด, entity ทั้งหมด 38 class (+ base.ts), ความสัมพันธ์, raw SQL / stored procedure บน DB ai, และจุดเสี่ยง SQL injection ที่พิสูจน์ได้จากโค้ด
Database topology
recruitment-api/src/app.module.ts (branch: prod):30-53 ลงทะเบียน TypeORM 4 connection; factory อยู่ที่ recruitment-api/config/configuration.ts (branch: prod):11-74
| Connection name | Config key | Env prefix | Database (env) | autoLoadEntities | synchronize | ใช้จริงใน src/ |
|---|---|---|---|---|---|---|
main | db | DB_* | DB_NAME (เช่น recruitment) | NODE_ENV === 'dev' | NODE_ENV === 'dev' | ใช่ — ทุก feature module |
ai | dbAi | DB_AI_* | DB_AI_NAME (เช่น recruitment_ai) | false | false | ใช่ — entityManagerAi.query(EXEC ...) |
vtrc | vtrcDB | VTRC_DB_* | VTRC_DB_NAME | false | false | ไม่พบ @InjectEntityManager('vtrc') |
workforce | workforceDB | WORKFORCE_DB_* | WORKFORCE_DB_NAME | false | false | ไม่พบ @InjectEntityManager('workforce') |
Driver / type มาจาก env (DB_TYPE ฯลฯ) — production ใช้ MSSQL ผ่าน package mssql (ดูบท 1 / package.json)
Extra options ร่วมกัน (configuration.ts:22-25,37-42,...):
extra: {
trustServerCertificate: true,
encrypt: false,
}Connection ai เพิ่ม connectTimeout / requestTimeout = 30000 ms (configuration.ts:41-42)
Schema change management
- Dev:
synchronize: trueเมื่อNODE_ENV === 'dev'เท่านั้น - Prod: เปลี่ยน schema ผ่าน manual SQL ใต้
recruitment-api/sql/(เช่นRC-*.sql,phase_2.3/,prod/,restructure/) — ไม่ใช้ TypeORM migration / Flyway
Logging: logging: ['error', 'query'] ทั้ง 4 connection — query log เปิดในทุก env ตาม config นี้ (ระวังข้อมูลส่วนบุคคลใน log)
Base entity
recruitment-api/src/entities/base.ts (branch: prod):3-27 — class BaseEntity (ไม่ใช่ @Entity) ให้ field ร่วม:
| Field | Type | หมายเหตุ |
|---|---|---|
id | PK auto-increment | @PrimaryGeneratedColumn |
createdAt / updatedAt | datetime | default CURRENT_TIMESTAMP |
createdBy / updatedBy | varchar(50) | |
createdByName / updatedByName | varchar(500) | |
isDeleted | bit | soft delete |
เกือบทุกตาราง business extends BaseEntity ยกเว้นบางตัวที่ประกาศ PK/audit เอง (เช่น JobReqRestructureHistory, PermissionConfigEntity)
Entity inventory — 38 classes (39 ไฟล์รวม base.ts)
นับจาก recruitment-api/src/entities/ (branch: prod): 39 ไฟล์ = 38 @Entity class + base.ts (แก้เอกสารเก่าที่บอก 36)
กลุ่ม Job Request (11)
| Entity class | ตาราง | หนึ่งบรรทัด | ไฟล์ |
|---|---|---|---|
JobRequestHd | jobRequestHd | หัวเอกสารคำขอเปิดรับ (documentNo RCM, status, isPublish, isQualified, ช่วงเปิดรับ) | jobRequestHd.entity.ts |
JobReqPosition | jobReqPosition | ตำแหน่ง/อัตรา — manpowerNo, remainingManpower, org/position codes | jobReqPosition.entity.ts |
JobReqCapability | jobReqCapability | ทักษะที่ต้องการ | jobReqCapability.entity.ts |
JobReqCapabilityLang | jobReqCapabilityLang | ทักษะภาษาที่ต้องการ | jobReqCapabilityLang.entity.ts |
JobReqDegree | jobReqDegree | คุณวุฒิที่ต้องการ | jobReqDegree.entity.ts |
JobReqField | jobReqField | สาขาวิชาที่ต้องการ | jobReqField.entity.ts |
JobReqProLicense | jobReqProLicense | ใบอนุญาตวิชาชีพที่ต้องการ | jobReqProLicense.entity.ts |
JobReqHistory | jobReqHistory | ประวัติเปลี่ยนสถานะคำขอ | jobReqHistory.entity.ts |
JobReqAdvertisement | jobReqAdvertisement | เนื้อหาประกาศ (draft-js) + ไฟล์แนบโฆษณา | jobReqAdvertisement.entity.ts |
JobReqExtendHistory | jobReqExtendHistory | ประวัติขยายเวลารับสมัคร | jobReqExtendHistory.entity.ts |
JobReqRestructureHistory | jobReqRestructureHistory | ประวัติปรับโครงสร้างตำแหน่งระหว่างทาง (ไม่ extend BaseEntity) | jobReqRestructureHistory.entity.ts |
ความสัมพันธ์หลัก (jobRequestHd.entity.ts:88-138):
JobRequestHd
1:1 JobReqPosition
1:1 JobReqAdvertisement
1:N JobReqDegree | JobReqField | JobReqCapability | JobReqCapabilityLang
JobReqProLicense | JobReqHistory | JobReqExtendHistory | JobReqRestructureHistory
1:N RcAttachfile (via relatedId / polymorphic)Cascade: เด็กส่วนใหญ่ cascade: true บน OneToMany/OneToOne ของ JobRequestHd — save(JobRequestHd) ใน create จึงพาลูกไปด้วย
กลุ่ม Candidate + AppProfile (11)
| Entity class | ตาราง | หนึ่งบรรทัด | ไฟล์ |
|---|---|---|---|
CandidateHd | candidateHd | ใบสมัคร 1 ใบต่อตำแหน่ง (documentNo RCC, status, jobReqDocumentNo, client, appProfileId) | candidateHd.entity.ts |
CandidateHistory | candidateHistory | บันทึก remark/กิจกรรมต่อใบสมัคร | candidateHistory.entity.ts |
CandidateStatus | candidateStatus | สถานะละเอียด + score/interviewScore/blacklist/decline | candidateStatus.entity.ts |
AppProfileHd | appProfileHd | โปรไฟล์กลาง 1 คน (PII, email, empCode, profileKey, blacklist flags) | appProfileHd.entity.ts |
AppProfileEdu | appProfileEdu | ประวัติการศึกษา | appProfileEdu.entity.ts |
AppProfileExp | appProfileExp | ประสบการณ์ทำงาน (isCurrent สำคัญต่อ contract) | appProfileExp.entity.ts |
AppProfileCapability | appProfileCapability | ทักษะของผู้สมัคร | appProfileCapability.entity.ts |
AppProfileCapabilityLang | appProfileCapabilityLang | ทักษะภาษา | appProfileCapabilityLang.entity.ts |
AppProfileProLicense | appProfileProLicense | ใบอนุญาตวิชาชีพของผู้สมัคร | appProfileProLicense.entity.ts |
AppProfileReference | appProfileReference | บุคคลอ้างอิง | appProfileReference.entity.ts |
AppProfilePersonality | appProfilePersonality | ผล/บันทึกบุคลิกภาพ | appProfilePersonality.entity.ts |
ความสัมพันธ์:
AppProfileHd 1:N (edu|exp|capability|capabilityLang|proLicense|reference|personality|candidateHd|rcAttachFile)
CandidateHd N:1 AppProfileHd
CandidateHd 1:N CandidateHistory | CandidateStatus | RcAttachfileField สำคัญบน CandidateHd (candidateHd.entity.ts:17-99): documentNo, jobRequestNo, jobReqDocumentNo, status, email, client, manpowerNo, ชุด applyPosition*, org/bu/ward
กลุ่ม Qualified / Announcement (4)
| Entity class | ตาราง | หนึ่งบรรทัด | ไฟล์ |
|---|---|---|---|
QualifiedHd | qualifiedHd | รอบประกาศผล (recordType exam/interview/passSelection, วันเวลา สถานที่, คำถาม/ข้อความอีเมล) | qualifiedHd.entity.ts |
QualifiedCandidate | qualifiedCandidate | ผลต่อผู้สมัคร (score, interviewScore, isPass, candidateDocumentNo) | qualifiedCandidate.entity.ts |
QualifiedCandidateBackup | qualifiedCandidateBackup | สำรองรายชื่อ / snapshot ก่อน overwrite | qualifiedCandidateBackup.entity.ts |
QualifiedHistory | qualifiedHistory | ประวัติแก้ประกาศ + flag isSaveScore | qualifiedHistory.entity.ts |
QualifiedHd 1:N QualifiedCandidate | QualifiedCandidateBackup | QualifiedHistory | RcAttachfileQualifiedCandidate เชื่อมใบสมัครด้วย candidateDocumentNo / candidateId เป็นค่าบนแถว (ไม่ใช่ TypeORM relation ไป CandidateHd โดยตรงใน entity)
กลุ่ม Contract (4)
| Entity class | ตาราง | หนึ่งบรรทัด | ไฟล์ |
|---|---|---|---|
ContractHd | contractHd | หัวสัญญา (status draft/confirm/..., exportToHRMiDate) | contractHd.entity.ts |
ContractEmployment | contractEmployment | รายละเอียดจ้าง + รหัสภายใน + รหัส *HRMI ทั้งชุด | contractEmployment.entity.ts |
ContractDt | contractDt | รายละเอียดผู้รับจ้าง/เงื่อนไข (รวม candidateDocumentNo, empCode) | contractDt.entity.ts |
ContractHistory | contractHistory | ประวัติ create / exportToHRMi / cancel / decline | contractHistory.entity.ts |
Field HRMI บน ContractEmployment (contractEmployment.entity.ts:131-147):
orgUnitCodeHRMIpositionCareerCodeHRMIpositionManageCodeHRMIpositionTechnicalCodeHRMIemploymentTypeHRMIemployeeLevelCodeHRMI
นี่คือสะพานรหัสระหว่างระบบสรรหา กับระบบ HRMI ผ่าน mapping จาก workforce-api (ไม่ใช่ FK ข้าม DB)
กลุ่ม Utility / cross-cutting (8)
| Entity class | ตาราง | หนึ่งบรรทัด | ไฟล์ |
|---|---|---|---|
AuthData | authData | server-side store ของ OTP / JWT / refresh ต่อ email | authData.entity.ts |
TbBlacklist | tbBlacklist | บัญชีดำ (ไฟล์ชื่อ backlist.entity.ts — typo) | backlist.entity.ts |
DeleteHistory | deleteHistory | คำขอลบข้อมูล PDPA | deleteHistory.entity.ts |
EmailConfigEntity | emailConfig | ผู้รับ/subject ต่อ emailType (export/cancel/decline ฯลฯ) | emailConfig.entity.ts |
MDropdown | mDropdown | master dropdown กลาง (dropdownType / dropdownCode) | mDropdown.entity.ts |
RcAttachfile | rcAttachFile | ไฟล์แนบ polymorphic หลาย parent | rcAttachFile.entity.ts |
PermissionConfigEntity | permissionConfig | ตั้งค่าสิทธิ์ menu (อ่านจาก blacklist module) | permissionConfigEntity.entity.ts |
CheckEmpRedCrossEntity | checkEmpRedCross | cache/ผลตรวจพนักงานสภากาชาด | checkRedcross.entity.ts |
Polymorphic attachments (rcAttachFile)
recruitment-api/src/entities/rcAttachFile.entity.ts (branch: prod):11-38 มี @ManyToOne หลายตัวไปยัง parent คนละชนิด พร้อม FK column คนละชื่อ:
| Join column | Parent |
|---|---|
relatedId | JobRequestHd |
appProfileRelatedId | AppProfileHd |
proLicenseRelatedId | AppProfileProLicense |
qualifiedRelatedId | QualifiedHd |
contractRelatedId | ContractHd |
candidateRelatedId | CandidateHd |
jobReqAdsRelatedId | JobReqAdvertisement |
แอปยังเก็บ relatedTable เป็น string (เช่น 'candidateHd', 'contractHd') ในตอน upload/create — ใช้บอก context ตอน pack ข้อมูลแม้ ORM จะมีหลาย JoinColumn
Field ไฟล์: fileName, fileType, fileSize, filePath, documentType, description, sort
ไฟล์จริงอยู่บน disk ภายใต้ /uploads/... (ดูบท 1) — ตารางเก็บ metadata + path เท่านั้น
Query patterns บน connection main
รูปแบบหลักใน repositories/services:
- TypeORM
createQueryBuilder— ส่วนใหญ่ใช้ named parameters (:id,:email) เช่นcandidate.repositories.ts:1300-1303 entityManager.save/findOne/update— cascade ตาม relation- Soft delete — set
isDeleted = true(เช่น announcement delete) มากกว่า hard delete ยกเว้นauthDataที่deleteจริงตอน logout/OTP rotate
ตัวอย่าง parameterized (ปลอดภัย):
.where('hd.id IN (:...id)', { id: arrWhere })
.andWhere('hd.isDeleted = 0')จาก candidate.repositories.ts:1296-1304
ตัวอย่างที่ยังเป็น string ใน where ของ QueryBuilder (เสี่ยงถ้า input ไม่ถูก sanitize):
announcement.service.ts:1027-1037 ส่ง where เข้า generateRunningNo:
const whereCaseInterview = body.recordType === 'interview' ? ` and type = '${body.type}'` : '';
documentNo: await this.running.generateRunningNo(
QualifiedHd, 'documentNo', '0001', prefix, 't',
`isDeleted = 0 and recordType = '${body.recordType}' ${whereCaseInterview}`,
),running.util.ts:26 แปะเป็น .where(`${tableAlias}.${whereClause}`) — body.recordType / body.type จาก request ถูก interpolate ตรง ๆ → SQL injection surface ที่ยืนยันได้ (harden โดย whitelist enum หรือ named parameters)
Connection ai — stored procedures + injection points
Inject: @InjectEntityManager('ai') ใน
candidate.repositories.ts:55jobList.repositories.ts:28appProfile.repositories.ts:34
จุดเรียก entityManagerAi.query ทั้งระบบ (grep ทั้ง src/):
1) Candidate compare by AI
recruitment-api/src/modules/candidate/candidate.repositories.ts (branch: prod):1324-1338
let param = '@Input_Job_ID = ' + query.jobId + ', ';
for (let i = 0; i < 5; i++) {
let id = multipleId[i] ? `'${multipleId[i]}'` : null;
param = param.concat('@Input_Candidate_ID' + (i + 1) + ' = ' + `${id}` + ...);
}
const querySelect = `EXEC Module3_3_Get_Selected_Candidate_Per_Job ${param}`;
await this.entityManagerAi.query(querySelect);jobId และ candidate ids ถูกต่อเป็น string — ไม่ใช่ parameterized RPC → SQL injection risk หาก query string มาจาก client โดยตรง (GetCandidateCompareByAIDto)
2) Job recommend by AI
recruitment-api/src/modules/jobList/jobList.repositories.ts (branch: prod):700-703
const querySelect = `EXEC dbo.Module3_4_Get_All_Job_Per_Candidate @Input_Candidate_ID='${param.id}'`;
await this.entityManagerAi.query(querySelect);param.id มาจาก path :id — มีการเช็ค ownership ที่ service ชั้นบน (jobList.service.ts:175-185) แต่ SQL ยังเป็น string interpolation
3) Candidate list per job by AI
recruitment-api/src/modules/appProfile/appProfile.repositories.ts (branch: prod):335-338
const querySelect = `EXEC dbo.Module3_2_Get_All_Candidate_Per_Job @Input_Job_ID='${param.id.toString()}'`;
await this.entityManagerAi.query(querySelect);สรุป severity (จากหลักฐานโค้ด)
| จุด | Parameterized? | หมายเหตุ |
|---|---|---|
TypeORM QB ส่วนใหญ่บน main | ใช่ (:param) | มาตรฐานปลอดภัยกว่า |
3× EXEC ... ${...} บน ai | ไม่ | ยืนยัน string concat — ต้อง sanitize หรือใช้ proper parameter binding ของ mssql |
generateRunningNo where จาก announcement | สงสัย | interpolate recordType/type |
ไม่พบ raw SQL ข้าม schema ไป vtrc/workforce ใน Nest source — การดึงข้อมูลภายนอกทำผ่าน HTTP axios แทน
Unused DB connections
แม้ vtrc และ workforce จะถูกประกาศใน AppModule และมี credential ใน .env:
- ไม่พบ
@InjectEntityManager('vtrc')หรือ'workforce'ใน tree ที่สำรวจ - การคุยกับ VTRC / workforce ทำผ่าน HTTP (
VTRC_SERVER,WORKFORCE_SERVER) ไม่ผ่าน connection เหล่านี้
ถือเป็น dead config / debt — อาจเคยวางแผน read-only join แล้วเปลี่ยนไปใช้ API
Manual SQL folder layout
recruitment-api/sql/ (branch: prod) อย่างน้อยมี:
RC-20260216-01.sql,RC-20260318-01.sql— ticket-based patchesphase_2.3/— phase scriptsprod/— production historical scriptsrestructure/— งานปรับโครงสร้างตำแหน่ง
อย่าสมมติว่า TypeORM entity ตรงกับ prod 100% โดยไม่เทียบสคริปต์ล่าสุด — โดยเฉพาะ field ที่เพิ่มหลัง synchronize ถูกปิด
Relationship map (ย่อ)
jobRequestHd ──1:1── jobReqPosition (remainingManpower)
│
├── published ──► jobList reads (no separate jobList table)
│
└── referenced by candidateHd.jobReqDocumentNo (string, not ORM FK)
appProfileHd ──1:N── candidateHd
candidateHd ──1:N── candidateStatus / candidateHistory
candidateHd.documentNo ──► contractDt.candidateDocumentNo (string)
qualifiedCandidate.candidateId ──► candidateHd.id (app-level)
contractHd ──1:1── contractEmployment (*HRMI codes)
──1:1── contractDt
──1:N── contractHistory
authData.email ──► portal session (OTP/JWT) — ไม่ใช่ FK ไป appProfile
tbBlacklist.idCard ──► match กับ appProfile / import ExcelSoft delete และ hard delete
| พื้นที่ | พฤติกรรม |
|---|---|
| Job request / candidate / announcement / contract / appProfile | soft (isDeleted) |
authData | hard delete ตอน OTP ใหม่ / logout (auth.service.ts:110-127) |
| ไฟล์ disk | removeFileFromPath แยกจาก soft delete แถว DB |
Running number persistence
recruitment-api/libs/utils/running.util.ts (branch: prod):8-55 อ่านค่าล่าสุดจากตาราง entity เอง (ไม่ใช่ sequence ของ MSSQL):
.select(columnName)
.where(`${tableAlias}.${whereClause}`)
.andWhere(`${columnName} LIKE :prefix`, { prefix: `${currentPrefix}-%` })
.orderBy(`CAST(SUBSTRING(...) AS INT)`, 'DESC')
.take(1);- Default
whereClause = 'isDeleted = 0'→ กลายเป็นt.isDeleted = 0(ถูกต้อง) - Announcement ส่ง where ยาว:
`isDeleted = 0 and recordType = '{recordType}' ...`→ กลายเป็นt.isDeleted = 0 and recordType = '...'(prefixt.ติดเฉพาะต้นสตริง) LIKE :prefixใช้ named parameter — ส่วนนี้ปลอดภัย- ส่วนที่ interpolate คือ
whereClause/andWhereที่ caller ส่งเข้ามา
รูปแบบเลขที่: {LETTER}{YY}{MM}-{NNNN} เช่น RCM2607-0001, RCC2607-0003, WTT2607-0002
Entity field deep-dives (ตารางที่โหลดบ่อย)
authData — session store
recruitment-api/src/entities/authData.entity.ts (branch: prod):4-16
| Column | Length | ใช้เก็บ |
|---|---|---|
email | 255 | key หา session |
otp | 10 | OTP ระหว่าง login |
token | 4000 | access JWT ล่าสุด |
refreshToken | 4000 | refresh JWT ล่าสุด |
ไม่มี unique constraint ใน entity definition — uniqueness พึ่ง logic delete ก่อน create ใน jobList.service.sendOtp
emailConfig — routing อีเมล HRMI / announcement
recruitment-api/src/entities/emailConfig.entity.ts (branch: prod):3-30
| Column | หน้าที่ |
|---|---|
emailType | ตรงกับ status export (export / cancel / decline ฯลฯ) |
emailTo / emailCc | รายชื่อผู้รับ |
subject | subject template |
isActive / isDeleted | เปิด-ปิดแถว |
contract.service.ts:1677-1679 โหลดด้วย findOne({ emailType: param.status, isActive: true, isDeleted: false })
deleteHistory — PDPA
recruitment-api/src/entities/deleteHistory.entity.ts (branch: prod):4-47 — ไม่ extend BaseEntity; มี appProfileId FK ไป AppProfileHd, สถานะ delAccStatus, เหตุผล/หมายเหตุ, เวลา approve/cancel
contractEmployment — รหัสคู่ขนาน
นอกจาก field องค์กร/ตำแหน่งภายใน ยังมี:
manpowerNo,salary,promoteType,sourceDb,guarantor,bank/bankBranch,adjustmentType- ชุด
*HRMI6 ฟิลด์ (ดูตารางกลุ่ม Contract ด้านบน)
ค่า *HRMI ถูกเขียนตอน create/update contract เท่านั้น — ไม่ใช่ generated column ของ DB
jobReqPosition — โควต้า
Field หลักที่ pipeline พึ่ง:
manpowerNo— เลขอัตรา (อาจ comma-separated หลายเลข)remainingManpower— ลดตอน hired / เพิ่มตอน decline- ชุดรหัสตำแหน่ง career / manage / technical / org / bu / ward / employment type / level
ไม่มี unique constraint ใน entity ที่บังคับ manpowerNo ต่อ job request — validation อยู่ที่ service validateData
Publish filter ที่ jobList อ่านจาก main
jobList.repositories.ts (ตัวอย่าง :716-723) กรองประกาศสาธารณะด้วยเงื่อนไขประมาณ:
jobRequestHd.isPublish = 1jobReqPosition.remainingManpower != 0jobRequestHd.status != 'draft'- วันที่ปัจจุบันอยู่ในช่วง
startDate–endDateหรือisNoEndDate = 1
ไม่มีตาราง jobList แยก — เป็น read model จาก jobReqPosition ⋈ jobRequestHd
InjectEntityManager map (ใครถือ connection อะไร)
| File | main | ai |
|---|---|---|
candidate.repositories.ts | ใช่ | ใช่ |
candidate.service.ts | ใช่ | ไม่ |
jobList.repositories.ts | ใช่ | ใช่ |
appProfile.repositories.ts | ใช่ | ใช่ |
jobRequest.* / announcement.* / contract.* / auth.* / blacklist.* / deleteHistory.* | ใช่ | ไม่ |
ทุก write path ของ business เอกสารอยู่บน main — ai เป็น read-only SP สำหรับคะแนนแนะนำ
N+1 และ query shape ที่พบบ่อย
Announcement getById (announcement.service.ts:382-448) วน qualifiedCandidate แล้ว:
getAllProfile(allEmail)ครั้งเดียว (ดี)- ต่อแถว:
candidateRepo.getByDocumentNo— N+1 ต่อผู้สมัคร - ถ้าไม่ใช่ exam: QueryBuilder ดึง score จากรอบ exam/interview อีกครั้งต่อแถว
Contract getByCandidateId ดึง candidate → contract → job request → filter manpower ที่ถูกใช้แล้วจาก getContractManpowerNo — หลาย round-trip แต่ไม่ใช่ N+1 loop
เมื่อ profile performance ของหน้าประกาศผล ให้เริ่มที่ loop ใน getById ก่อน
เคล็ดลับตอนทำงานจริง
- นับ entity ให้ถูก: 38 class + base — เอกสารเก่าที่บอก 36 ผิด
- อย่าพึ่ง connection ชื่อ
vtrc/workforceใน TypeORM จนกว่าจะมี inject จริง — ใช้ HTTP แทน - ก่อนเพิ่ม column production ให้เพิ่มสคริปต์ใน
sql/และอัปเดต entity ให้ตรงกัน (synchronize ปิดใน prod) - AI SP ทั้ง 3 จุดควรแก้เป็น parameterized ในรอบ security harden
- เวลา join ใบสมัครกับประกาศผล ใช้
candidateDocumentNo/jobReqDocumentNoเป็น business key ไม่ใช่เฉพาะ PK - อย่าสร้าง MSSQL SEQUENCE ใหม่โดยไม่ไล่
RunningUtil— ระบบพึ่ง max+1 จากตาราง - ไฟล์ entity ชื่อ
backlist.entity.tsแต่ตารางคือtbBlacklist— import ผิดชื่อไฟล์ง่ายเมื่อค้นหา