summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-07-28 12:10:39 +0000
committerjoonhoekim <26rote@gmail.com>2025-07-28 12:10:39 +0000
commit75249e6fa46864f49d4eb91bd755171b6b65eaae (patch)
treef2c021f0fe10b3513d29f05ca15b82e460d79d20 /lib
parentc228a89c2834ee63b209bad608837c39643f350e (diff)
(김준회) 공통모듈 - Knox 결재 모듈 구현, 유저 선택기 구현, 상신 결재 저장을 위한 DB 스키마 및 서비스 추가, spreadjs 라이센스 환경변수 통일, 유저 테이블에 epId 컬럼 추가
Diffstat (limited to 'lib')
-rw-r--r--lib/knox-api/approval/approval.ts74
-rw-r--r--lib/knox-api/approval/service.ts140
-rw-r--r--lib/knox-api/common.ts1
-rw-r--r--lib/knox-sync/employee-sync-service.ts3
-rw-r--r--lib/knox-sync/master-sync-service.ts2
-rw-r--r--lib/users/service.ts95
6 files changed, 304 insertions, 11 deletions
diff --git a/lib/knox-api/approval/approval.ts b/lib/knox-api/approval/approval.ts
index 75066478..5e62382d 100644
--- a/lib/knox-api/approval/approval.ts
+++ b/lib/knox-api/approval/approval.ts
@@ -1,6 +1,8 @@
"use server"
import { getKnoxConfig, createJsonHeaders, createFormHeaders } from '../common';
+import { randomUUID } from 'crypto';
+import { saveApprovalToDatabase, deleteApprovalFromDatabase } from './service';
// Knox API Approval 서버 액션들
// 가이드: lib/knox-api/approval/guide.html
@@ -15,7 +17,7 @@ export interface BaseResponse {
// 결재 경로 타입
export interface ApprovalLine {
epId?: string;
- userId?: string;
+ userId?: string; // eVCP ID라서 사용하지 않음!
emailAddress?: string;
seq: string;
role: string; // 기안(0), 결재(1), 합의(2), 후결(3), 병렬합의(4), 병렬결재(7), 통보(9)
@@ -132,7 +134,8 @@ export interface ApprovalIdsResponse extends BaseResponse {
* POST /approval/api/v2.0/approvals/submit
*/
export async function submitApproval(
- request: SubmitApprovalRequest
+ request: SubmitApprovalRequest,
+ userInfo: { userId: string; epId: string; emailAddress: string }
): Promise<SubmitApprovalResponse> {
try {
const config = await getKnoxConfig();
@@ -149,8 +152,8 @@ export async function submitApproval(
timeZone: request.timeZone,
docMngSaveCode: request.docMngSaveCode,
subject: request.subject,
- sbmLang: request.sbmLang,
- apInfId: request.apInfId,
+ sbmLang: request.sbmLang || 'ko',
+ apInfId: request.apInfId, // 고정값, 환경변수로 설정해 common 에서 가져오기
importantYn: request.importantYn,
aplns: request.aplns
};
@@ -174,7 +177,28 @@ export async function submitApproval(
throw new Error(`결재 상신 실패: ${response.status}`);
}
- return await response.json();
+ const result = await response.json();
+
+ // Knox API 성공 시 데이터베이스에 저장
+ if (result.result === 'SUCCESS') {
+ try {
+ await saveApprovalToDatabase(
+ request.apInfId,
+ userInfo.userId,
+ userInfo.epId,
+ userInfo.emailAddress,
+ request.subject,
+ request.contents,
+ request.aplns
+ );
+ } catch (dbError) {
+ console.error('데이터베이스 저장 실패:', dbError);
+ // 데이터베이스 저장 실패는 Knox API 성공을 무효화하지 않음
+ // 필요시 별도 처리 로직 추가
+ }
+ }
+
+ return result;
} catch (error) {
console.error('결재 상신 오류:', error);
throw error;
@@ -426,7 +450,20 @@ export async function cancelApproval(
throw new Error(`상신 취소 실패: ${response.status}`);
}
- return await response.json();
+ const result = await response.json();
+
+ // Knox API 성공 시 데이터베이스에서 삭제
+ if (result.result === 'SUCCESS') {
+ try {
+ await deleteApprovalFromDatabase(apInfId);
+ } catch (dbError) {
+ console.error('데이터베이스 삭제 실패:', dbError);
+ // 데이터베이스 삭제 실패는 Knox API 성공을 무효화하지 않음
+ // 필요시 별도 처리 로직 추가
+ }
+ }
+
+ return result;
} catch (error) {
console.error('상신 취소 오류:', error);
throw error;
@@ -501,10 +538,29 @@ export async function createSubmitApprovalRequest(
approvalLines: ApprovalLine[],
options: Partial<SubmitApprovalRequest> = {}
): Promise<SubmitApprovalRequest> {
- const config = await getKnoxConfig();
+
+ // 요구하는 날짜 형식으로 변환 (YYYYMMDDHHMMSS) - UTC 기준 (타임존 정보를 GTC+9 로 제공하고 있음)
const now = new Date();
- const sbmDt = now.toISOString().replace(/[-:T]/g, '').slice(0, 14);
- const apInfId = `${config.systemId}${sbmDt}${Math.random().toString(36).substr(2, 9)}`.padEnd(32, '0');
+ const formatter = new Intl.DateTimeFormat('en-CA', {
+ timeZone: 'UTC',
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hour12: false,
+ });
+
+ const parts = formatter.formatToParts(now);
+ const sbmDt = parts
+ .filter((part) => part.type !== 'literal')
+ .map((part) => part.value)
+ .join('');
+
+
+ // EVCP 접두어 뒤에 28자리 무작위 문자열을 붙여 32byte 고유 ID 생성
+ const apInfId = `EVCP${randomUUID().replace(/-/g, '').slice(0, 28)}`;
return {
contents,
diff --git a/lib/knox-api/approval/service.ts b/lib/knox-api/approval/service.ts
new file mode 100644
index 00000000..6ef1b1f6
--- /dev/null
+++ b/lib/knox-api/approval/service.ts
@@ -0,0 +1,140 @@
+"use server"
+import db from '@/db/db';
+import { ApprovalLine } from "./approval";
+import { approval } from '@/db/schema/knox/approvals';
+import { eq, and } from 'drizzle-orm';
+
+// ========== 데이터베이스 서비스 함수들 ==========
+
+
+
+/**
+ * 결재 상신 데이터를 데이터베이스에 저장
+ */
+export async function saveApprovalToDatabase(
+ apInfId: string,
+ userId: string,
+ epId: string,
+ emailAddress: string,
+ subject: string,
+ content: string,
+ aplns: ApprovalLine[]
+): Promise<void> {
+ try {
+ await db.insert(approval).values({
+ apInfId,
+ userId,
+ epId,
+ emailAddress,
+ subject,
+ content,
+ status: '1', // 진행중 상태로 초기 설정
+ aplns,
+ isDeleted: false,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ });
+ } catch (error) {
+ console.error('결재 데이터 저장 실패:', error);
+ throw new Error(
+ '결재 데이터를 데이터베이스에 저장하는 중 오류가 발생했습니다.'
+ );
+ }
+}
+
+/**
+ * 결재 상태 업데이트
+ */
+export async function updateApprovalStatus(
+ apInfId: string,
+ status: string
+): Promise<void> {
+ try {
+ await db
+ .update(approval)
+ .set({
+ status,
+ updatedAt: new Date(),
+ })
+ .where(eq(approval.apInfId, apInfId));
+ } catch (error) {
+ console.error('결재 상태 업데이트 실패:', error);
+ throw new Error('결재 상태를 업데이트하는 중 오류가 발생했습니다.');
+ }
+}
+
+/**
+ * 결재 상세 정보 조회
+ */
+export async function getApprovalFromDatabase(
+ apInfId: string,
+ includeDeleted: boolean = false
+): Promise<typeof approval.$inferSelect | null> {
+ try {
+ const whereCondition = includeDeleted
+ ? eq(approval.apInfId, apInfId)
+ : and(eq(approval.apInfId, apInfId), eq(approval.isDeleted, false));
+
+ const result = await db
+ .select()
+ .from(approval)
+ .where(whereCondition)
+ .limit(1);
+
+ return result[0] || null;
+ } catch (error) {
+ console.error('결재 데이터 조회 실패:', error);
+ throw new Error('결재 데이터를 조회하는 중 오류가 발생했습니다.');
+ }
+}
+
+/**
+ * 사용자별 결재 목록 조회
+ */
+export async function getApprovalsByUser(
+ userId: string,
+ limit: number = 50,
+ offset: number = 0,
+ includeDeleted: boolean = false
+): Promise<typeof approval.$inferSelect[]> {
+ try {
+ const whereCondition = includeDeleted
+ ? eq(approval.userId, userId)
+ : and(eq(approval.userId, userId), eq(approval.isDeleted, false));
+
+ const result = await db
+ .select()
+ .from(approval)
+ .where(whereCondition)
+ .orderBy(approval.createdAt)
+ .limit(limit)
+ .offset(offset);
+
+ return result;
+ } catch (error) {
+ console.error('사용자 결재 목록 조회 실패:', error);
+ throw new Error('사용자 결재 목록을 조회하는 중 오류가 발생했습니다.');
+ }
+}
+
+/**
+ * 결재 삭제 (상신 취소 시) - Soft Delete
+ */
+export async function deleteApprovalFromDatabase(
+ apInfId: string
+): Promise<void> {
+ try {
+ await db
+ .update(approval)
+ .set({
+ isDeleted: true,
+ updatedAt: new Date(),
+ })
+ .where(eq(approval.apInfId, apInfId));
+ } catch (error) {
+ console.error('결재 데이터 삭제 실패:', error);
+ throw new Error('결재 데이터를 삭제하는 중 오류가 발생했습니다.');
+ }
+}
+
+
diff --git a/lib/knox-api/common.ts b/lib/knox-api/common.ts
index 4c037e56..db6910f2 100644
--- a/lib/knox-api/common.ts
+++ b/lib/knox-api/common.ts
@@ -6,6 +6,7 @@
export interface KnoxConfig {
baseUrl: string;
systemId: string;
+ apInfId?: string; // 환경변수에서 주입 (고정값)
bearerToken: string;
}
diff --git a/lib/knox-sync/employee-sync-service.ts b/lib/knox-sync/employee-sync-service.ts
index 3e8b048e..b7f2a323 100644
--- a/lib/knox-sync/employee-sync-service.ts
+++ b/lib/knox-sync/employee-sync-service.ts
@@ -198,6 +198,7 @@ async function syncEmployeesToUsers(): Promise<void> {
departmentCode: employeeTable.departmentCode,
departmentName: employeeTable.departmentName,
companyCode: employeeTable.companyCode,
+ epId: employeeTable.epId,
})
.from(employeeTable)
.where(
@@ -271,6 +272,7 @@ async function syncEmployeesToUsers(): Promise<void> {
deptCode: employee.departmentCode,
deptName: employee.departmentName,
domain: assignedDomain as UserDomainType,
+ epId: employee.epId,
updatedAt: new Date(),
})
.where(eq(users.id, existingUsers[0].id));
@@ -295,6 +297,7 @@ async function syncEmployeesToUsers(): Promise<void> {
deptCode: employee.departmentCode,
deptName: employee.departmentName,
domain: assignedDomain as UserDomainType,
+ epId: employee.epId,
});
insertCount++;
diff --git a/lib/knox-sync/master-sync-service.ts b/lib/knox-sync/master-sync-service.ts
index 5cabe9ed..ed77a3fd 100644
--- a/lib/knox-sync/master-sync-service.ts
+++ b/lib/knox-sync/master-sync-service.ts
@@ -67,6 +67,6 @@ export async function startKnoxMasterSyncScheduler() {
syncAllKnoxData().catch(console.error);
});
+ // 직급 정보 기반으로, 각 직급마다 임직원 조회 (Knox API 구조상 제한으로 직급 및 부서마다 조회 가능하며, 직급이 수가 더 적음(600 vs 2400))
logSchedulerInfo('통합(직급→조직→임직원)', CRON_STRING);
- console.log('[KNOX-SYNC] 💡 순차 실행으로 의존성 문제 해결 (직급 완료 → 조직 완료 → 임직원 완료)');
} \ No newline at end of file
diff --git a/lib/users/service.ts b/lib/users/service.ts
index 80c346fa..90ee170e 100644
--- a/lib/users/service.ts
+++ b/lib/users/service.ts
@@ -13,7 +13,7 @@ import db from "@/db/db";
import { getErrorMessage } from "@/lib/handle-error";
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/app/api/auth/[...nextauth]/route"
-import { and, or, desc, asc, ilike, eq, isNull, sql, count, inArray, ne } from "drizzle-orm";
+import { and, or, desc, asc, ilike, eq, isNull, isNotNull, sql, count, inArray, ne, not } from "drizzle-orm";
import { SaveFileResult, saveFile } from '../file-stroage';
interface AssignUsersArgs {
@@ -1012,3 +1012,96 @@ export async function getUserRoles(userId: number): Promise<string[]> {
}
}
+/**
+ * 사용자 선택기용 간단한 사용자 검색 함수
+ */
+export async function searchUsersForSelector(
+ query: string,
+ page: number = 1,
+ perPage: number = 10,
+ domainFilter?: { type: "exclude" | "include"; domains: string[] } | null
+) {
+ try {
+ const offset = (page - 1) * perPage;
+
+ // 이름 검색 조건
+ let searchWhere;
+ if (query.trim()) {
+ const searchPattern = `%${query.trim()}%`;
+ searchWhere = ilike(users.name, searchPattern);
+ }
+
+ // 도메인 필터 조건
+ let domainWhere;
+ if (domainFilter && domainFilter.domains.length > 0) {
+ if (domainFilter.type === "include") {
+ domainWhere = inArray(users.domain, domainFilter.domains);
+ } else if (domainFilter.type === "exclude") {
+ domainWhere = not(inArray(users.domain, domainFilter.domains));
+ }
+ }
+
+ // 활성 사용자만
+ const activeWhere = eq(users.isActive, true);
+
+ // 최종 조건
+ const finalWhere = and(searchWhere, domainWhere, activeWhere);
+
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await tx
+ .select({
+ id: users.id,
+ epId: users.epId,
+ name: users.name,
+ email: users.email,
+ deptCode: users.deptCode,
+ deptName: users.deptName,
+ imageUrl: users.imageUrl,
+ domain: users.domain,
+ })
+ .from(users)
+ .where(finalWhere)
+ .orderBy(asc(users.name))
+ .limit(perPage)
+ .offset(offset);
+
+ const totalResult = await tx
+ .select({ count: count() })
+ .from(users)
+ .where(finalWhere);
+
+ return { data, total: totalResult[0].count };
+ });
+
+ const pageCount = Math.ceil(total / perPage);
+
+ return {
+ success: true,
+ data,
+ pagination: {
+ page,
+ perPage,
+ total,
+ pageCount,
+ hasNextPage: page < pageCount,
+ hasPrevPage: page > 1,
+ },
+ };
+ } catch (error) {
+ console.error("사용자 검색 오류:", error);
+ return {
+ success: false,
+ data: [],
+ pagination: {
+ page: 1,
+ perPage,
+ total: 0,
+ pageCount: 0,
+ hasNextPage: false,
+ hasPrevPage: false,
+ },
+ error: "사용자 검색 중 오류가 발생했습니다.",
+ };
+ }
+}
+