diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/knox-api/employee/code-utils.ts | 95 | ||||
| -rw-r--r-- | lib/knox-sync/employee-sync-service.ts | 151 | ||||
| -rw-r--r-- | lib/rfqs/table/attachment-rfq-sheet.tsx | 37 | ||||
| -rw-r--r-- | lib/soap/mdg/utils.ts | 111 |
4 files changed, 349 insertions, 45 deletions
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})`); +} diff --git a/lib/rfqs/table/attachment-rfq-sheet.tsx b/lib/rfqs/table/attachment-rfq-sheet.tsx index 75235b32..fdfb5e9a 100644 --- a/lib/rfqs/table/attachment-rfq-sheet.tsx +++ b/lib/rfqs/table/attachment-rfq-sheet.tsx @@ -24,7 +24,7 @@ import { FormMessage, FormDescription } from "@/components/ui/form" -import { Trash2, Plus, Loader, Download, X, Eye, AlertCircle } from "lucide-react" +import { Loader, Download, X, Eye, AlertCircle } from "lucide-react" import { useToast } from "@/hooks/use-toast" import { Badge } from "@/components/ui/badge" @@ -45,15 +45,15 @@ import { FileListInfo, FileListItem, FileListName, - FileListSize, } from "@/components/ui/file-list" import prettyBytes from "pretty-bytes" import { processRfqAttachments } from "../service" -import { format } from "path" import { formatDate } from "@/lib/utils" import { RfqType } from "../validations" import { RfqWithItemCount } from "@/db/schema/rfq" +import { quickDownload } from "@/lib/file-download" +import { type FileRejection } from "react-dropzone" const MAX_FILE_SIZE = 6e8 // 600MB @@ -116,7 +116,7 @@ export function RfqAttachmentsSheet({ const { toast } = useToast() const [isPending, startUpdate] = React.useTransition() const rfqId = rfq?.rfqId ?? 0; - + // 편집 가능 여부 확인 - DRAFT 상태일 때만 편집 가능 const isEditable = rfq?.status === "DRAFT"; @@ -167,7 +167,7 @@ export function RfqAttachmentsSheet({ async function onSubmit(data: AttachmentsFormValues) { // 편집 불가능한 상태에서는 제출 방지 if (!isEditable) return; - + startUpdate(async () => { try { const removedExistingIds = findRemovedExistingIds(data) @@ -225,10 +225,10 @@ export function RfqAttachmentsSheet({ } /** 드롭존에서 파일 거부(에러) */ - function handleDropRejected(fileRejections: any[]) { + function handleDropRejected(fileRejections: FileRejection[]) { // 편집 불가능한 상태에서는 무시 if (!isEditable) return; - + fileRejections.forEach((rej) => { toast({ variant: "destructive", @@ -245,8 +245,8 @@ export function RfqAttachmentsSheet({ <SheetTitle className="flex items-center gap-2"> {isEditable ? "Manage Attachments" : "View Attachments"} {rfq?.status && ( - <Badge - variant={rfq.status === "DRAFT" ? "outline" : "secondary"} + <Badge + variant={rfq.status === "DRAFT" ? "outline" : "secondary"} className="ml-1" > {rfq.status} @@ -298,15 +298,14 @@ export function RfqAttachmentsSheet({ <div className="flex items-center gap-2"> {/* 1) Download button (if filePath) */} {field.filePath && ( - <a - href={`/api/rfq-download?path=${encodeURIComponent(field.filePath)}`} - download={field.fileName} - className="text-sm" + <Button + variant="ghost" + size="icon" + type="button" + onClick={() => quickDownload(field.filePath, field.fileName)} > - <Button variant="ghost" size="icon" type="button"> - <Download className="h-4 w-4" /> - </Button> - </a> + <Download className="h-4 w-4" /> + </Button> )} {/* 2) Remove button - 편집 가능할 때만 표시 */} {isEditable && ( @@ -413,8 +412,8 @@ export function RfqAttachmentsSheet({ </Button> </SheetClose> {isEditable && ( - <Button - type="submit" + <Button + type="submit" disabled={isPending || (form.getValues().newUploads.length === 0 && defaultAttachments.length === form.getValues().existing.length)} > {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />} diff --git a/lib/soap/mdg/utils.ts b/lib/soap/mdg/utils.ts index 02dd088e..52c82d47 100644 --- a/lib/soap/mdg/utils.ts +++ b/lib/soap/mdg/utils.ts @@ -5,6 +5,7 @@ import { join } from "path"; import { eq } from "drizzle-orm"; import db from "@/db/db"; import { soapLogs, type LogDirection, type SoapLogInsert } from "@/db/schema/SOAP/soap"; +import { XMLBuilder } from 'fast-xml-parser'; // for object→XML 변환 // XML 파싱용 타입 유틸리티: 스키마에서 XML 타입 생성 export type ToXMLFields<T> = { @@ -203,42 +204,100 @@ export function processNestedArray<T, U>( return items.map(item => converter(item, fkData)); } +// Helper: SOAP Envelope 빌더 +function buildSoapEnvelope(namespace: string, bodyContent: string = ''): string { + return `<?xml version="1.0" encoding="UTF-8"?> +<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:tns="${namespace}"> + <soap:Body> + ${bodyContent} + </soap:Body> +</soap:Envelope>`; +} + +// Generic: JS object → XML string 변환 +function objectToXML(obj: Record<string, unknown>): string { + const builder = new XMLBuilder({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + format: false, + suppressEmptyNode: true, + }); + return builder.build(obj); +} + +// 범용 SOAP 응답 생성 함수 +// body는 XML string이거나 JS 객체(자동으로 XML 변환) +export function createSoapResponse( + namespace: string, + body: string | Record<string, unknown> +): NextResponse { + const bodyXml = typeof body === 'string' ? body : objectToXML(body); + return new NextResponse(buildSoapEnvelope(namespace, bodyXml), { + headers: { 'Content-Type': 'text/xml; charset=utf-8' }, + }); +} + // 에러 응답 생성 -export function createErrorResponse(error: unknown): NextResponse { +// 기본: 기존 SOAP Fault 유지 +// 추가: namespace & elementName 전달 시 <EV_TYPE>E</EV_TYPE> 구조로 응답 (100자 제한) +export function createErrorResponse( + error: unknown, + namespace?: string, + elementName?: string +): NextResponse { console.error('API Error:', error); - - const errorResponse = `<?xml version="1.0" encoding="UTF-8"?> -<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"> - <soap:Body> - <soap:Fault> + + if (namespace && elementName) { + const rawMessage = error instanceof Error ? error.message : 'Unknown error'; + const truncatedMsg = rawMessage.length > 100 ? rawMessage.slice(0, 100) : rawMessage; + const body = `<${elementName}> + <EV_TYPE>E</EV_TYPE> + <EV_MESSAGE>${truncatedMsg}</EV_MESSAGE> + </${elementName}>`; + + return new NextResponse(buildSoapEnvelope(namespace, body), { + headers: { 'Content-Type': 'text/xml; charset=utf-8' }, + }); + } + + // Fallback: SOAP Fault (기존 호환) + const errorResponse = buildSoapEnvelope( + namespace || '', + `<soap:Fault> <faultcode>soap:Server</faultcode> <faultstring>${error instanceof Error ? ('[from eVCP]: ' + error.message) : 'Unknown error'}</faultstring> - </soap:Fault> - </soap:Body> -</soap:Envelope>`; - + </soap:Fault>` + ); + return new NextResponse(errorResponse, { status: 500, - headers: { - 'Content-Type': 'text/xml; charset=utf-8', - }, + headers: { 'Content-Type': 'text/xml; charset=utf-8' }, }); } // 성공 응답 생성 -export function createSuccessResponse(namespace: string): NextResponse { - const xmlResponse = `<?xml version="1.0" encoding="UTF-8"?> -<soap:Envelope - xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" - xmlns:tns="${namespace}"> - <soap:Body> - </soap:Body> -</soap:Envelope>`; - - return new NextResponse(xmlResponse, { - headers: { - 'Content-Type': 'text/xml; charset=utf-8', - }, +// 기본: Body 비어있는 기존 형태 유지 +// elementName 전달 시 EV_TYPE(S/E) 및 EV_MESSAGE 포함 +export function createSuccessResponse( + namespace: string, + elementName?: string, + evType: 'S' | 'E' = 'S', + evMessage?: string +): NextResponse { + if (elementName) { + const msgTag = evMessage ? `<EV_MESSAGE>${evMessage}</EV_MESSAGE>` : ''; + const body = `<${elementName}> + <EV_TYPE>${evType}</EV_TYPE> + ${msgTag} + </${elementName}>`; + return new NextResponse(buildSoapEnvelope(namespace, body), { + headers: { 'Content-Type': 'text/xml; charset=utf-8' }, + }); + } + + // 기본(빈 Body) 응답 + return new NextResponse(buildSoapEnvelope(namespace), { + headers: { 'Content-Type': 'text/xml; charset=utf-8' }, }); } |
