Skip to content

3.3.4 · Service layer + SQL patterns

บทนี้เจาะ service layer ของ centralize-api — รูปแบบการใช้ raw SQL ผ่าน entityManager.query() ทั้งระบบ, CTE ~80 บรรทัดที่ copy-paste ซ้ำ 4 ครั้ง, การใช้ WITH (NOLOCK) dirty read, และ error handling anti-pattern พร้อม business logic จริงเพียงจุดเดียวของ centralize-api (getShiftTimeWithWork)


Service layer pattern

ทุก service inject EntityManager ผ่าน @InjectEntityManager('center') หรือ 'nbc' แล้วเรียก entityManager.query(sql, params) เพื่อรัน raw SQL

centralize-api/src/modules/employee/employee.service.ts:22-31

typescript
@Injectable()
export class EmployeeService {
  constructor(
    @InjectEntityManager('center')
    private readonly entityManager: EntityManager,
    private readonly technicalPositionRepository: TechnicalPositionRepository,
  ) {}

  private logger = new I3GatewayLogger(EmployeeService.name);
  // ...
}

จุดสำคัญ

  • ไม่มี TypeORM query builder หรือ repository pattern จริง — ใช้แค่ entityManager.query() ส่ง SQL string + params (ส่วน TechnicalPositionRepository/EmployeeGroupRepository/EmployeeLevelRepository เป็น class ธรรมดาที่ใช้ BaseEntity.find() แบบ active record ไม่ใช่ TypeORM Repository จริง)
  • ไม่มี transaction — ไม่มี queryRunner, @Transaction(), entityManager.transaction() ใด ๆ (OK เพราะ read-only)
  • try/catch ทุก method → throw BadRequestException (400) สำหรับทุก error รวมถึง DB failure (anti-pattern — ดู §"Error handling")
  • logger ทุก method — inject ผ่าน private logger = new I3GatewayLogger(ClassName.name)

Raw SQL pattern — parameterized queries

ทุก query ใช้ @0, @1, ... เป็น positional placeholder แล้วส่ง params เป็น array ที่ตำแหน่งตรงกัน

ตัวอย่างจาก hrpayslip.service.ts

centralize-api/src/modules/hrPaySlip/hrpayslip.service.ts:20-32

typescript
async getPaySlipDT(body: EmpCodeListDto) {
  try {
    return this.entityManager.query(
      `SELECT empCode , incomeCode ,incomeName,income,sourceDB  from  uv_i3_PaySlipDT_reStructure WHERE EmpCode  in (${body.empCode.map(
        (_, index) => `@${index}`,
      )})`,
      [...body.empCode],
    );
  } catch (error) {
    this.logger.error(error);
    throw new BadRequestException(error);
  }
}

วิเคราะห์

  • ${body.empCode.map((_, index) => '@' + index)} สร้าง placeholder names แบบ dynamic — ถ้า empCode มี 3 ตัวจะได้ IN (@0, @1, @2) แล้วส่ง [empCode1, empCode2, empCode3] เป็น params
  • ปลอดภัยจาก SQL injection เพราะ placeholder @0 @1 เป็นเพียงชื่อ parameter ไม่ใช่ value interpolation — driver ของ MSSQL (tedious) จะ bind value แยก
  • pattern เดียวกันใช้ใน centralize-api/src/modules/hrTimeTemp/hrtimetemp.service.ts:38-70 สำหรับ workingDate array (@${index + 1} เพราะ @0 สำหรับ empCode)
  • สังเกต [...body.empCode] (spread) — เทียบเท่า body.empCode.slice() คือสร้าง array ใหม่ copy จากตัวเดิม เป็นการป้องกัน mutation ของ array ต้นทาง

ข้อยกเว้น — เงื่อนไข dynamic WHERE ที่ปลอดภัย

centralize-api/src/modules/employee/employee.service.ts:272-322 แสดง pattern ที่สร้าง WHERE clause แบบ dynamic

typescript
async getEmployees(query: GetEmployeeQueryDto) {
  const queryParam = [];
  if (query.department) {
    queryParam.push('DeptCode = @0');
  }
  if (query.sourceDB) {
    queryParam.push('SourceDB = @1');
  }
  // ... 9 conditions
  queryParam.push('WorkingStatus = @9');
  try {
    return await this.entityManager.query(
      `select ... from vtrcMainProfile
       ${queryParam.length > 0 ? 'where' : ''}
       ${queryParam.join(' and ')}`,
      [query.department, query.sourceDB, /* ... 9 params */],
    );
  }
}

ลักษณะ

  • array queryParam เก็บเฉพาะ ชื่อเงื่อนไข (string literal DeptCode = @0) ไม่ใช่ value ของ user
  • params array ส่งค่าจริงเข้าไปในตำแหน่งที่ตรงกับ placeholder
  • ปลอดภัยจาก SQL injection เพราะ ${queryParam.join(' and ')} แทรกเฉพาะ string literal ที่เขียนโดย dev ไม่ใช่ user input

⚠️ pattern นี้มี gotcha — params array ส่งค่า undefined สำหรับ field ที่ user ไม่ส่งเข้ามา (เช่น query.department ถ้าไม่มี) แต่ placeholder @0 ยังถูกแทรกใน SQL เฉพาะเมื่อ query.department truthy ดังนั้นตำแหน่งของ value กับ placeholder ยังตรงกัน


~80-line org hierarchy CTE — copy-paste 4 ครั้ง

ปัญหาใหญ่ที่สุดใน service layer คือ SQL block ~80 บรรทัดที่ query org/division/department hierarchy ถูก copy-paste เหมือนกันทุกประการ 4 ครั้ง ในไฟล์ต่อไปนี้

Methodไฟล์:บรรทัด
AppService.getHellocentralize-api/src/app.service.ts:12-143
EmployeeService.getOrganizationscentralize-api/src/modules/employee/employee.service.ts:32-108
EmployeeService.getDivisionscentralize-api/src/modules/employee/employee.service.ts:110-189
EmployeeService.getDepartmentscentralize-api/src/modules/employee/employee.service.ts:191-270

โครงสร้างคร่าว ๆ (เน้นส่วนสำคัญจาก app.service.ts:12-143)

sql
select
  CASE
    WHEN orgMain.SourceDB = 'dbHRMI_CU_SC_rep' THEN '02CS'
    WHEN orgMain.SourceDB = 'dbHRMI_CU_H_rep' THEN '02'
    WHEN orgMain.SourceDB = 'dbHRMI_RC_C_rep' THEN '03'
    WHEN orgMain.SourceDB = 'dbHRMI_RC_B_rep' THEN '00'
    WHEN orgMain.SourceDB = 'dbHRMI_RC_NBC_rep' THEN '07'
    WHEN org.OrgUnitCode is not null THEN LEFT(org.OrgUnitCode, 2)
    ELSE LEFT(org2.OrgUnitCode, 2)
  END AS OrgCode,
  -- ... derive OrgUnitCode, OrgName, DiviCode, DiviName, DeptCode, DeptName, SourceDB
from (
  select * from dbHRMI_Center.dbo.emOrgUnit_new_reStructure WITH (NOLOCK)
  UNION ALL
  select * from dbHRMI_Center_NBC.dbo.emOrgUnit_new_reStructure WITH (NOLOCK)
) as orgMain
LEFT JOIN dbHRMI_Center.dbo.emOrgUnit_new_reStructure AS dept WITH (NOLOCK)
  ON dept.OrgUnitID = orgMain.OrgUnitID AND dept.SourceDB = orgMain.SourceDB AND dept.IsDeleted = 0
LEFT JOIN dbHRMI_Center.dbo.emOrgUnit_new_reStructure AS org WITH (NOLOCK)
  ON org.OrgUnitCode = LEFT(dept.OrgUnitCode, 2) AND orgMain.SourceDB = org.SourceDB AND org.IsDeleted = 0
LEFT JOIN dbHRMI_Center.dbo.emOrgUnit_new_reStructure AS divi WITH (NOLOCK)
  ON divi.OrgUnitCode = LEFT(dept.OrgUnitCode, 4) AND divi.SourceDB = orgMain.SourceDB AND divi.IsDeleted = 0
LEFT JOIN dbHRMI_Center_NBC.dbo.emOrgUnit_new_reStructure AS dept2 WITH (NOLOCK) ...
LEFT JOIN dbHRMI_Center_NBC.dbo.emOrgUnit_new_reStructure AS org2 WITH (NOLOCK) ...
LEFT JOIN dbHRMI_Center_NBC.dbo.emOrgUnit_new_reStructure AS divi2 WITH (NOLOCK) ...
WHERE orgMain.IsDeleted = 0

ลักษณะเฉพาะของ CTE นี้

  • union 2 databases (dbHRMI_Center + dbHRMI_Center_NBC) เป็น subquery orgMain
  • 6 LEFT JOIN ระหว่าง emOrgUnit_new_reStructure (alias dept, org, divi, dept2, org2, divi2) เพื่อ derive org/divi/dept code จาก OrgUnitCode prefix
  • LEFT(OrgUnitCode, 2) = org code, LEFT(OrgUnitCode, 4) = divi code, LEFT(OrgUnitCode, 6+) = dept code
  • CASE WHEN เพื่อแมป SourceDB raw value → รหัส 2 หลัก (02CS, 02, 03, 00, 07)

ผลกระทบ

  • แก้ logic ต้องแก้ 4 ที่ — ถ้า schema HRMI เปลี่ยน (เช่นเพิ่ม field ใหม่ใน emOrgUnit) ต้องแก้ 4 methods ให้ตรงกัน ถ้าลืมที่ใดที่หนึ่งจะได้ผลต่างกัน
  • ภาระการดูแลสูง — ไม่มี test ครอบคลุม ดังนั้นถ้า query ที่ 1 คืน row 100 แถว แต่ที่ 2 คืน 99 แถว จะไม่มีใครสังเกตเห็น
  • Performance — query plan cache ของ MSSQL จะเก็บแผนแยก 4 ชุด ถึงแม้ SQL จะเหมือนกัน เพราะ connection pool แยก และ SQL hash ต่างกันเพราะ white-space ไม่เหมือนกัน (4 สำเนา format ต่างกัน)
  • ตัวแปร fuckQuery ใน getDivisionscentralize-api/src/modules/employee/employee.service.ts:112 ใช้ชื่อตัวแปรที่ไม่เหมาะสม ควรแก้เป็นชื่อทางการ เช่น divisionQuery

การแก้ — สร้าง method private buildOrgHierarchyCTE(): string ใน service ที่ return SQL string แล้วเรียกใช้ในทุก method ที่ต้องการ


WITH (NOLOCK) — dirty read hint

ทุกที่ที่ join HRMI tables ใช้ WITH (NOLOCK) hint เสมอ

sql
-- ตัวอย่างจาก centralize-api/src/app.service.ts:99-104
select * from dbHRMI_Center.dbo.emOrgUnit_new_reStructure WITH (NOLOCK)
UNION ALL
select * from dbHRMI_Center_NBC.dbo.emOrgUnit_new_reStructure WITH (NOLOCK)

NOLOCK (หรือ READUNCOMMITTED) เป็น isolation level ที่อนุญาตให้อ่าน row ที่ transaction อื่นกำลังแก้ไขอยู่โดยไม่รอ → เรียกว่า dirty read

ข้อดีข้อเสีย
เร็วมาก — ไม่ต้องรอ lock releaseอาจได้ข้อมูล inconsistent — row ที่อยู่ระหว่าง UPDATE อาจคืนค่าใหม่บาง column ค่าเก่าบาง column
ลด contention บน HRMI ที่มี write traffic สูงอาจได้ duplicate rows หรือ missing rows ในบางสถานการณ์ (page split)
เหมาะสำหรับ read-only report ที่ทนความคลาดเคลื่อนได้ไม่เหมาะสำหรับ transactional read ที่ต้องการ accuracy

เหมาะสมไหม — สำหรับ centralize-api ที่เป็น HR data facade NOLOCK ยอมรับได้เพราะ

  • ข้อมูล HR เปลี่ยนไม่บ่อย (org structure, manager) — dirty read ไม่กระทบมาก
  • Perf สำคัญกว่า consistency ใน use case ส่วนใหญ่
  • เป็น pattern มาตรฐานของ MSSQL read-only report

แต่ต้องเขียนไว้ในคู่มือ — ถ้าได้รับแจ้งว่า "ข้อมูล org ของพนักงาน X ไม่ตรง บางครั้ง" อาจเป็น dirty read ไม่ใช่ bug


Error handling anti-pattern

ทุก service method ใช้ pattern เดียวกัน

typescript
try {
  return await this.entityManager.query(sql, params);
} catch (error) {
  this.logger.error(error);
  throw new BadRequestException(error);
}

ปัญหา — BadRequestException (HTTP 400) หมายถึง "client ส่งข้อมูลผิด" แต่ error ที่เกิดจาก DB (เช่น connection timeout, SQL syntax error, dead lock) ไม่ใช่ความผิดของ client ควรจะเป็น InternalServerErrorException (500)

ผลกระทบ

  • Client สับสน — ได้ 400 ทั้งที่ request ถูกต้อง ทำให้ debug ผิดทาง
  • Monitoring พลาด — alert ที่กรองด้วย status code 5xx จะไม่ตรวจจับ error ของ centralize-api
  • Retry logic พัง — ถ้า client มี retry เฉพาะ 5xx จะไม่ retry เมื่อเจอ 400 ทั้งที่น่าจะ retry

การแก้ — แยก error type

typescript
} catch (error) {
  this.logger.error(error);
  if (error instanceof QueryFailedError) {
    throw new InternalServerErrorException({
      message: 'Database operation failed',
      detail: error.message,
    });
  }
  throw new InternalServerErrorException(error);
}

Business logic จริงเพียงจุดเดียวของ centralize-api — getShiftTimeWithWork

HrTimeTempService.getShiftTimeWithWork (centralize-api/src/modules/hrTimeTemp/hrtimetemp.service.ts:27-128) เป็น business logic แท้จริงเพียงจุดเดียวในระบบ — ทุก service อื่นเป็น thin SQL wrapper

Flow

1. Query hrTimeTempImport บน dbHRMI_Center + dbHRMI_Center_NBC แบบ parallel (Promise.all)
   - WHERE EmpCardNo = @0 AND convert(varchar, DateTimeStamp, 102) in (@1, @2, ...)
   - ใช้ convert(varchar, ..., 102) = format YYYY.MM.DD เพื่อเทียบเฉพาะ date


2. Merge results จาก 2 databases ด้วย [...timeCenter[0], ...timeCenter[1]]


3. Sort workingDate ascending (ที่ฝั่ง JS ไม่ใช่ SQL)


4. For each day:
   │   4.1 filter check-ins ของวันนั้น (format YYYY-MM-DD เทียบ)
   │   4.2 sort by timestamp ascending
   │   4.3 ถ้ามีหลาย stamp:
   │         - แรก = timeIn (format HH:mm)
   │         - สุดท้าย = timeOut (format HH:mm)
   │   4.4 ถ้ามี stamp เดียว:
   │         - timeIn only
   │         - timeOut = null
   │   4.5 ถ้าไม่มี stamp ในวันนั้น: ข้ามวัน (ไม่ push เข้า data)


5. Build format array ครบทุก workingDate (รวมวันที่ไม่มีข้อมูล → timeIn='', timeOut='')


6. Return shape: { date, timeIn, timeOut }[]

จุดสังเกต

  • parallel query ใช้ Promise.all — เร็วกว่า query ทีละ database
  • merge logic ที่ฝั่ง application ไม่ใช่ SQL — เพราะถ้า merge ใน SQL ต้อง UNION ALL cross-database ที่ MSSQL ทำได้แต่อ่านยาก
  • timeIn/timeOut derivation คือ business rule ที่ไม่ได้เก็บใน HRMI โดยตรง — HRMI เก็บแค่ DateTimeStamp (timestamp ของการแตะบัตร) ส่วนการตีความว่าคือเข้าหรือออกอยู่ที่ logic นี้
  • format สุดท้าย (hrtimetemp.service.ts:113-122) return ทุก workingDate แม้ไม่มีข้อมูล โดยใส่ timeIn='' และ timeOut='' → เปลี่ยนจาก null เป็น empty string ในขั้นตอนสุดท้าย

Edge cases ที่ logic นี้ไม่ cover

กรณีผลลัพธ์ปัญหา
พนักงานแตะบัตร 1 ครั้ง → timeIn = X, timeOut = nullทำงานครึ่งวัน? ลืมแตะออก?ไม่ระบุ — format สุดท้ายใส่ '' ไม่ใช่ null ทำให้ client แยกไม่ออกว่า "ไม่มีข้อมูล" หรือ "มีเข้าไม่มีออก"
พนักงานแตะบัตร 5 ครั้งในวันเดียวกันtimeIn = แรก, timeOut = สุดท้ายครั้งที่ 2-4 คืออะไร? (ออกกินข้าว? ธุระ?)
พนักงานแตะบัตรข้ามวัน (เช่นเข้า 23:00, ออก 07:00 วันถัดไป)จัดวันไหน?ไม่ระบุ — ใช้ workingDate param เป็นตัวกรอง ถ้าไม่ส่งทั้งสองวันจะไม่เห็นครบ
Overnight shiftดูเหมือนจะถูกตั้งแต่กำหนด — workingDate เป็น arrayแต่ถ้าพนักงานทำงานข้ามคืน 2 วัน row จะอยู่ในผลลัพธ์ของวันไหน?

→ โดยทั่วไปจะเริ่ม debug "เวลาออกงานของพนักงาน X ผิด" ที่ method นี้ก่อน เพราะเป็น logic เดียวที่ตีความ timestamp


getEmployeeDisaster — disaster recovery lookup

centralize-api/src/modules/employee/employee.service.ts:434-485

sql
select c.order_row, o.EmpCode as empCode, ...
from vtrcAllProfile as o
left join centralizedProfileOrdering_reStructure_all_cdb_nbc as c
  on o.EmpID = c.EmpID and o.EmpCode = c.EmpCode and o.SourceDB = c.SourceDB
left join (
  select * from dbHRMI_Center.dbo.hrEmpWorkProfile
  union all
  select * from dbHRMI_Center_NBC.dbo.hrEmpWorkProfile
) as w on o.EmpID = w.EmpID and o.SourceDB = w.SourceDB and w.IsDeleted = 0
where o.EmpCode = @0
and @1 between convert(date, w.StartDate, 103)
          and ISNULL(CONVERT(date, w.EndDate, 103), '9999-12-31')
order by c.order_row asc;

query นี้ check ว่า disasterStartDate (เช่นวันเกิดเหตุการณ์ฉุกเฉิน) ตกในช่วง StartDate - EndDate ของงานใดหรือไม่ → ใช้สำหรับ disaster recovery benefit lookup

จุดที่น่าสนใจ

  • ISNULL(CONVERT(date, w.EndDate, 103), '9999-12-31') — ถ้า EndDate เป็น NULL (พนักงานยังทำงานอยู่) ให้ใช้ 9999-12-31 เป็น upper bound
  • convert(date, ..., 103) — format 103 คือ DD/MM/YYYY (British/French) ซึ่งตรงกับ locale ของ HRMI

getBankAccount — cross-database UNION ALL pattern

centralize-api/src/modules/employee/employee.service.ts:545-586 เป็นตัวอย่างของ query ที่ union ข้อมูลจาก dbHRMI_Center + dbHRMI_Center_NBC ในแต่ละ join ก่อนที่จะเชื่อมกับ vtrcAllProfile

sql
LEFT JOIN (
  SELECT * FROM dbHRMI_Center.dbo.hrEmpBankBook_reStructure
  UNION ALL
  SELECT * FROM dbHRMI_Center_NBC.dbo.hrEmpBankBook_reStructure
) AS hrebb ON tpot.EmpID = hrebb.EmpID AND tpot.SourceDB = hrebb.SourceDB
  AND hrebb.IsDeleted = '0' AND hrebb.PercentageSend = '100'

pattern นี้ซ้ำใน emBank_reStructure และ emBankBranch_reStructure → ทั้ง 3 join ใช้วิธีเดียวกัน


repository pattern — ใช้บางส่วน

employeeGroup, employeeLevel, technicalPosition modules มี repository class แยก (3 files, 80 LOC รวม)

centralize-api/src/modules/employeeGroup/employeeGroup.repository.ts:1-29

typescript
@Injectable()
export class EmployeeGroupRepository {
  async findEmployeeGroupBySourceDB(
    sourceDB?: string,
  ): Promise<EmployeeGroup[]> {
    const where: any = {
      isDeleted: false,
      isInactive: false,
    };
    if (sourceDB) {
      where.sourceDB = sourceDB;
    }

    const [employeeGroups, employeeGroupsNBC] = await Promise.all([
      EmployeeGroup.find({ where, order: { emplGrupName: 'ASC' } }),
      EmployeeGroupNBC.find({ where, order: { emplGrupName: 'ASC' } }),
    ]);
    return employeeGroups.concat(employeeGroupsNBC);
  }
}

แต่ repository เหล่านี้ ไม่ได้ใช้ TypeORM Repository pattern จริง — เป็น class ธรรมดาที่ใช้ BaseEntity.find() แบบ active record (เพราะ entity extends BaseEntity) ไม่ได้ inject EntityManager เหมือน service ใช้ชื่อว่า "repository" เฉย ๆ

ผล — pattern ไม่ consistent — EmployeeService inject repository แทนที่จะเรียก SQL เอง แต่ service อื่น (hrPaySlip, hrTimeTemp) เรียก SQL ตรง ๆ ทำให้ codebase อ่านยาก

ทั้งสาม repository ใช้ pattern เดียวกัน — Promise.all query center + NBC แล้ว concat ผล ไม่มี field IsDeleted/IsInactive filter ใน repos บางตัว

RepositoryFilter IsDeleted/IsInactive?Order by
EmployeeGroupRepositoryใช่ (where.isDeleted = false, isInactive = false)emplGrupName ASC
EmployeeLevelRepositoryไม่ใช่ (เฉพาะ sourceDB)emplLevelName ASC
TechnicalPositionRepositoryใช่posTechnicalName ASC

ความไม่ consistent ของ filter บ่งบอกว่า EmployeeLevelRepository อาจ return ข้อมูลที่ถูก soft-delete ด้วย → correctness bug เงียบ ๆ


สรุปประเด็นสำคัญ

  • Service layer ใช้ raw SQL string ผ่าน entityManager.query() ทั้งระบบ — TypeORM ใช้แค่ connection management
  • Parameterized queries (@0, @1) ใช้ถูกต้อง → ปลอดภัยจาก SQL injection (รวมถึง dynamic WHERE clause pattern)
  • ~80-line CTE ถูก copy-paste 4 ครั้ง — ภาระการดูแลสูง ควรดึงออกเป็น method ร่วม
  • WITH (NOLOCK) ทุกที่ — dirty read ยอมรับได้สำหรับ HR facade แต่ต้องเขียนไว้ใน doc
  • Error handling anti-patternBadRequestException สำหรับทุก error รวม DB failure → ควรแยก type
  • Business logic จริงเพียงจุดเดียวgetShiftTimeWithWork time attendance merge ที่มี edge cases หลายจุด (overnight, แตะบัตรหลายครั้ง) และ format สุดท้ายใส่ empty string ทำให้ client แยกไม่ออกจากข้อมูลจริง
  • Repository pattern ใช้บางส่วนไม่ consistent — 3 sub-module มี repository ส่วน service อื่นเรียก SQL ตรง ๆ และ filter IsDeleted ไม่เท่ากัน (EmployeeLevelRepository ไม่ filter)