summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-21 07:20:21 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-21 07:20:21 +0000
commit194bd4bd7e6144d5c09c5e3f5476d254234dce72 (patch)
treec97d0b9d53edceba89b2597f14cbffe5717deb96 /lib
parent9da494b0e3bbe7b513521d0915510fe9ee376b8b (diff)
parent8165f003563e3d7f328747be3098542fe527b014 (diff)
Merge remote-tracking branch 'origin/ECC-SOAP-INTERFACE' into dujinkim
Diffstat (limited to 'lib')
-rw-r--r--lib/knox-api/employee/code-utils.ts95
-rw-r--r--lib/knox-sync/employee-sync-service.ts151
-rw-r--r--lib/rfqs/table/attachment-rfq-sheet.tsx37
-rw-r--r--lib/soap/mdg/utils.ts111
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' },
});
}