summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-07-21 07:14:40 +0000
committerjoonhoekim <26rote@gmail.com>2025-07-21 07:14:40 +0000
commit8165f003563e3d7f328747be3098542fe527b014 (patch)
tree928a4cd23adbde13d96ad4515a1bdf435da8a463
parent8f19c063aeb3df1eed9cab58f4bf7cac22ab13dc (diff)
Knox API 임직원 스키마 추가, 동기화 기능 추가
-rw-r--r--.env.development8
-rw-r--r--.env.production10
-rw-r--r--db/schema/knox/employee.ts53
-rw-r--r--instrumentation.ts4
-rw-r--r--lib/knox-api/employee/code-utils.ts95
-rw-r--r--lib/knox-sync/employee-sync-service.ts151
6 files changed, 320 insertions, 1 deletions
diff --git a/.env.development b/.env.development
index 2d59cd4c..d6edf7d5 100644
--- a/.env.development
+++ b/.env.development
@@ -122,6 +122,14 @@ MDG_SOAP_PASSWORD=SEW2765890 # 품질
SOAP_LOG_MAX_RECORDS=500
# === SOAP 인터페이스 설정 ===
+# KNOX API 사용을 위한 설정
+KNOX_COMPANY_CODES="P2" # 삼성중공업 회사코드 = P2
+KNOX_EMPLOYEE_SYNC_CRON="0 4 * * *" # 매일 새벽 4시
+KNOX_EMPLOYEE_SYNC_FIRST_RUN=true
+MESSENGER_ACCESS_TOKEN=""
+MESSENGER_DEVICE_ID=""
+MESSENGER_BASE_URL=""
+
# 임시 환경변수 --- 요구사항 해소되면 삭제
READONLY_DB_URL="postgresql://readonly:tempReadOnly_123@localhost:5432/evcp"
# 임시 환경변수 --- 요구사항 해소되면 삭제 \ No newline at end of file
diff --git a/.env.production b/.env.production
index 5dfb79f2..005f2fc8 100644
--- a/.env.production
+++ b/.env.production
@@ -123,7 +123,15 @@ MDG_SOAP_PASSWORD=SEW2765890 # 품질
SOAP_LOG_MAX_RECORDS=500
# === SOAP 인터페이스 설정 ===
-
+# KNOX API 사용을 위한 설정
+KNOX_COMPANY_CODES="P2" # 삼성중공업 회사코드 = P2
+KNOX_EMPLOYEE_SYNC_CRON="0 4 * * *" # 매일 새벽 4시
+KNOX_EMPLOYEE_SYNC_FIRST_RUN=true
+MESSENGER_ACCESS_TOKEN=""
+MESSENGER_DEVICE_ID=""
+MESSENGER_BASE_URL=""
+
+# NAS 경로 설정
NAS_PATH="/evcp_nas"
# 임시 환경변수 --- 요구사항 해소되면 삭제
diff --git a/db/schema/knox/employee.ts b/db/schema/knox/employee.ts
new file mode 100644
index 00000000..8c228130
--- /dev/null
+++ b/db/schema/knox/employee.ts
@@ -0,0 +1,53 @@
+import { pgSchema, varchar, timestamp, jsonb, text, index } from "drizzle-orm/pg-core";
+
+export const knoxSchema = pgSchema("knox");
+
+export const employee = knoxSchema.table("employee", {
+ epId: varchar("ep_id", { length: 25 }).primaryKey(),
+ employeeNumber: varchar("employee_number", { length: 20 }),
+ userId: varchar("user_id", { length: 50 }),
+ fullName: varchar("full_name", { length: 100 }),
+ givenName: varchar("given_name", { length: 100 }),
+ sirName: varchar("sir_name", { length: 50 }),
+ companyCode: varchar("company_code", { length: 10 }),
+ companyName: varchar("company_name", { length: 100 }),
+ departmentCode: varchar("department_code", { length: 50 }),
+ departmentName: varchar("department_name", { length: 255 }),
+ titleCode: varchar("title_code", { length: 20 }),
+ titleName: varchar("title_name", { length: 100 }),
+ emailAddress: varchar("email_address", { length: 255 }),
+ mobile: varchar("mobile", { length: 50 }),
+ employeeStatus: varchar("employee_status", { length: 2 }),
+ employeeType: varchar("employee_type", { length: 2 }),
+ accountStatus: varchar("account_status", { length: 2 }),
+ securityLevel: varchar("security_level", { length: 2 }),
+ preferredLanguage: varchar("preferred_language", { length: 5 }),
+ description: text("description"),
+ raw: jsonb("raw").notNull(),
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
+ enCompanyName: varchar("en_company_name", { length: 100 }),
+ enDepartmentName: varchar("en_department_name", { length: 255 }),
+ enDiscription: varchar("en_discription", { length: 255 }),
+ enFullName: varchar("en_full_name", { length: 100 }),
+ enGivenName: varchar("en_given_name", { length: 100 }),
+ enGradeName: varchar("en_grade_name", { length: 100 }),
+ enSirName: varchar("en_sir_name", { length: 50 }),
+ enTitleName: varchar("en_title_name", { length: 100 }),
+ gradeName: varchar("grade_name", { length: 100 }),
+ gradeTitleIndiCode: varchar("grade_title_indi_code", { length: 2 }),
+ jobName: varchar("job_name", { length: 100 }),
+ realNameYn: varchar("real_name_yn", { length: 2 }),
+ serverLocation: varchar("server_location", { length: 2 }),
+ titleSortOrder: varchar("title_sort_order", { length: 10 }),
+}, (table) => {
+ return {
+ companyDepartmentIdx: index("knox_employee_company_department_idx").on(table.companyCode, table.departmentCode),
+ employeeNumberIdx: index("knox_employee_number_idx").on(table.employeeNumber),
+ userIdIdx: index("knox_employee_user_id_idx").on(table.userId),
+ emailIdx: index("knox_employee_email_idx").on(table.emailAddress),
+ };
+});
+
+export type KnoxEmployee = typeof employee.$inferSelect;
+export type NewKnoxEmployee = typeof employee.$inferInsert; \ No newline at end of file
diff --git a/instrumentation.ts b/instrumentation.ts
index 48d013dd..7641c777 100644
--- a/instrumentation.ts
+++ b/instrumentation.ts
@@ -11,6 +11,10 @@ export async function register() {
// PLM 동기화 스케줄러인데, 1회만 가져오기로 했으므로 주석 처리
// const { startEnhancedSyncScheduler } = await import('./lib/nonsap-sync/enhanced-sync-service');
// startEnhancedSyncScheduler();
+
+ // Knox 임직원 동기화 스케줄러 시작
+ const { startKnoxEmployeeSyncScheduler } = await import('./lib/knox-sync/employee-sync-service');
+ startKnoxEmployeeSyncScheduler();
} catch (error) {
console.error('Failed to start Enhanced NONSAP sync scheduler:', error);
// 스케줄러 실패해도 애플리케이션은 계속 실행
diff --git a/lib/knox-api/employee/code-utils.ts b/lib/knox-api/employee/code-utils.ts
new file mode 100644
index 00000000..36a0e283
--- /dev/null
+++ b/lib/knox-api/employee/code-utils.ts
@@ -0,0 +1,95 @@
+// Knox 임직원/조직 코드 → 한글 설명 매핑
+// 가이드의 Note 컬럼을 하드코딩해두었다.
+
+const CODE_MAPS = {
+ accountStatus: {
+ A: '아이디 승인',
+ W: '아이디 신청',
+ M: '아이디 미발급',
+ },
+ defaultCompanyCode: {
+ O: '원소속',
+ S: '파견소속',
+ },
+ employeeStatus: {
+ B: '재직',
+ V: '휴직',
+ },
+ employeeType: {
+ N: '정규직 (@samsung.com)',
+ U: '협력직',
+ C: '자회사',
+ T: '임시직 (@partner.samsung.com)',
+ X: '협력직 (@samsung.com)',
+ Y: '자회사 (@samsung.com)',
+ Z: '임시직 (@samsung.com)',
+ },
+ gradeTitleIndiCode: {
+ G: '직위',
+ T: '직급',
+ B: '직위/직급 모두',
+ },
+ securityLevel: {
+ '1': '회장단',
+ '2': '사장단',
+ '3': '임원진',
+ '4': '간부',
+ '5': '사원',
+ '9': '협력사 임직원',
+ },
+ executiveYn: {
+ Y: '임원',
+ N: '직원',
+ },
+ realNameYn: {
+ R: '실명',
+ V: '가명',
+ },
+ localStaffYn: {
+ Y: '현채인',
+ N: '현채인 아님',
+ },
+ serverLocation: {
+ KR: '한국',
+ GB: '구주',
+ US: '미주',
+ },
+} as const;
+
+type CodeField = keyof typeof CODE_MAPS;
+type CodeValue<F extends CodeField> = keyof (typeof CODE_MAPS)[F];
+
+/**
+ * 개별 코드 설명 반환
+ * @param field 코드 필드명 (예: 'accountStatus')
+ * @param code 코드 값 (예: 'A')
+ * @returns 한글 설명 (없으면 undefined)
+ */
+export function getCodeDescription<F extends CodeField>(
+ field: F,
+ code: CodeValue<F> | string | undefined | null,
+): string | undefined {
+ if (!code) return undefined;
+ // 대소문자 구분 없음 처리
+ const normalized = String(code).toUpperCase() as CodeValue<F>;
+ const map = CODE_MAPS[field] as Record<string, string>;
+ return map[normalized as string];
+}
+
+/**
+ * Employee 객체에 *_Desc 필드로 설명을 붙여 반환
+ * (원본 객체는 수정하지 않음)
+ */
+export function withCodeDescriptions<T extends Record<string, unknown>>(employee: T) {
+ const result: Record<string, unknown> = { ...employee };
+ (Object.keys(CODE_MAPS) as CodeField[]).forEach((field) => {
+ if (field in employee) {
+ const desc = getCodeDescription(field, employee[field] as string);
+ if (desc) result[`${field}Desc`] = desc;
+ }
+ });
+ return result as T & { [K in `${CodeField}Desc`]?: string };
+}
+
+// export 전체 매핑이 필요하면 아래 내보내기
+export { CODE_MAPS }; \ No newline at end of file
diff --git a/lib/knox-sync/employee-sync-service.ts b/lib/knox-sync/employee-sync-service.ts
new file mode 100644
index 00000000..c517be14
--- /dev/null
+++ b/lib/knox-sync/employee-sync-service.ts
@@ -0,0 +1,151 @@
+'use server';
+
+import * as cron from 'node-cron';
+import db from '@/db/db';
+import { employee as employeeTable } from '@/db/schema/knox/employee';
+import {
+ searchEmployees,
+ getDepartmentsByCompany,
+ Employee,
+} from '@/lib/knox-api/employee/employee';
+import { sql } from 'drizzle-orm';
+
+// 동기화 대상 회사 코드 (쉼표로 구분된 ENV)
+const COMPANIES = (process.env.KNOX_COMPANY_CODES || 'P2')
+ .split(',')
+ .map((c) => c.trim())
+ .filter(Boolean);
+
+const CRON_STRING = process.env.KNOX_EMPLOYEE_SYNC_CRON || '0 4 * * *';
+
+const DO_FIRST_RUN = process.env.KNOX_EMPLOYEE_SYNC_FIRST_RUN === 'true';
+
+async function upsertEmployees(employees: Employee[]) {
+ if (!employees.length) return;
+
+ const rows = employees.map((e) => ({
+ epId: e.epId,
+ employeeNumber: e.employeeNumber,
+ userId: e.userId,
+ fullName: e.fullName,
+ givenName: e.givenName,
+ sirName: e.sirName,
+ companyCode: e.companyCode,
+ companyName: e.companyName,
+ departmentCode: e.departmentCode,
+ departmentName: e.departmentName,
+ titleCode: e.titleCode,
+ titleName: e.titleName,
+ emailAddress: e.emailAddress,
+ mobile: e.mobile,
+ employeeStatus: e.employeeStatus,
+ employeeType: e.employeeType,
+ accountStatus: e.accountStatus,
+ securityLevel: e.securityLevel,
+ preferredLanguage: e.preferredLanguage,
+ description: e.description,
+ raw: e as unknown as Record<string, unknown>,
+ enCompanyName: e.enCompanyName,
+ enDepartmentName: e.enDepartmentName,
+ enDiscription: e.enDiscription,
+ enFullName: e.enFullName,
+ enGivenName: e.enGivenName,
+ enGradeName: e.enGradeName,
+ enSirName: e.enSirName,
+ enTitleName: e.enTitleName,
+ gradeName: e.gradeName,
+ gradeTitleIndiCode: e.gradeTitleIndiCode,
+ jobName: e.jobName,
+ realNameYn: e.realNameYn,
+ serverLocation: e.serverLocation,
+ titleSortOrder: e.titleSortOrder,
+ }));
+
+ await db
+ .insert(employeeTable)
+ .values(rows)
+ .onConflictDoUpdate({
+ target: employeeTable.epId,
+ set: {
+ fullName: sql.raw('excluded.full_name'),
+ givenName: sql.raw('excluded.given_name'),
+ sirName: sql.raw('excluded.sir_name'),
+ companyCode: sql.raw('excluded.company_code'),
+ companyName: sql.raw('excluded.company_name'),
+ departmentCode: sql.raw('excluded.department_code'),
+ departmentName: sql.raw('excluded.department_name'),
+ titleCode: sql.raw('excluded.title_code'),
+ titleName: sql.raw('excluded.title_name'),
+ emailAddress: sql.raw('excluded.email_address'),
+ mobile: sql.raw('excluded.mobile'),
+ employeeStatus: sql.raw('excluded.employee_status'),
+ employeeType: sql.raw('excluded.employee_type'),
+ accountStatus: sql.raw('excluded.account_status'),
+ securityLevel: sql.raw('excluded.security_level'),
+ preferredLanguage: sql.raw('excluded.preferred_language'),
+ description: sql.raw('excluded.description'),
+ raw: sql.raw('excluded.raw'),
+ updatedAt: sql.raw('CURRENT_TIMESTAMP'),
+ },
+ });
+}
+
+export async function syncKnoxEmployees(): Promise<void> {
+ console.log('[KNOX-SYNC] 임직원 동기화 시작');
+
+ for (const companyCode of COMPANIES) {
+ try {
+ const departments = await getDepartmentsByCompany(companyCode);
+ console.log(`[KNOX-SYNC] ${companyCode}: 부서 ${departments.length}개`);
+
+ for (const dept of departments) {
+ let page = 1;
+ let totalPage = 1;
+ do {
+ const resp = await searchEmployees({
+ companyCode,
+ departmentCode: dept.departmentCode,
+ page: String(page),
+ resultType: 'basic',
+ });
+
+ if (resp.result === 'success') {
+ await upsertEmployees(resp.employees);
+ totalPage = resp.totalPage;
+ console.log(
+ `[KNOX-SYNC] 임직원 동기화 ${companyCode}/${dept.departmentCode} ${page}/${totalPage} 페이지 처리`
+ );
+ } else {
+ console.warn(
+ `[KNOX-SYNC] 임직원 동기화 실패: ${companyCode}/${dept.departmentCode} page ${page}`
+ );
+ break;
+ }
+
+ page += 1;
+ } while (page <= totalPage);
+ }
+ } catch (err) {
+ console.error(
+ `[KNOX-SYNC] 임직원 동기화 오류 (company ${companyCode})`,
+ err
+ );
+ }
+ }
+
+ console.log('[KNOX-SYNC] 임직원 동기화 완료');
+}
+
+export async function startKnoxEmployeeSyncScheduler() {
+ // 환경 변수에 따라 실행시 즉시 실행 여부 결정 (없으면 false)
+ if (DO_FIRST_RUN) {
+ syncKnoxEmployees().catch(console.error);
+ }
+
+ // CRON JOB 등록
+ cron.schedule(CRON_STRING, () => {
+ syncKnoxEmployees().catch(console.error);
+ });
+
+ console.log(`[KNOX-SYNC] 임직원 동기화 스케줄러 등록 (${CRON_STRING})`);
+}