1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
|
import { debugLog, debugSuccess, debugError } from '@/lib/debug-utils';
import db from '@/db/db';
import {
procurementRfqs,
prItems,
procurementRfqDetails,
} from '@/db/schema/procurementRFQ';
import {
PR_INFORMATION_T_BID_HEADER,
PR_INFORMATION_T_BID_ITEM,
} from '@/db/schema/ECC/ecc';
import { users } from '@/db/schema';
import { employee } from '@/db/schema/knox/employee';
import { eq, inArray } from 'drizzle-orm';
// NON-SAP 데이터 처리
import { oracleKnex } from '@/lib/oracle-db/db';
import { findUserIdByEmployeeNumber } from '../users/knox-service';
// ECC 데이터 타입 정의
export type ECCBidHeader = typeof PR_INFORMATION_T_BID_HEADER.$inferInsert;
export type ECCBidItem = typeof PR_INFORMATION_T_BID_ITEM.$inferInsert;
// 비즈니스 테이블 데이터 타입 정의
export type ProcurementRfqData = typeof procurementRfqs.$inferInsert;
export type PrItemData = typeof prItems.$inferInsert;
export type ProcurementRfqDetailData =
typeof procurementRfqDetails.$inferInsert;
/**
* 시리즈 판단: 관련 PR 아이템들의 PSPID를 기반으로 결정
* - 아이템이 1개 이하: null
* - 아이템이 2개 이상이고 PSPID가 모두 동일: "SS"
* - 아이템이 2개 이상이고 PSPID가 서로 다름: "||"
*/
function computeSeriesFromItems(items: ECCBidItem[]): string | null {
if (items.length <= 1) return null;
const normalize = (v: unknown): string | null => {
if (typeof v !== 'string') return null;
const trimmed = v.trim();
return trimmed.length > 0 ? trimmed : null;
};
const uniquePspids = new Set<string | null>(
items.map((it) => normalize(it.PSPID as string | null | undefined))
);
return uniquePspids.size === 1 ? 'SS' : '||';
}
/**
* PERNR(사번)을 기준으로 사용자 ID를 찾는 함수
*/
async function findUserIdByPernr(pernr: string): Promise<number | null> {
try {
debugLog('PERNR로 사용자 ID 찾기 시작', { pernr });
// 현재 users 테이블에 사번을 따로 저장하지 않으므로 knox 기준으로 사번 --> epId --> user.id 순으로 찾기
// 1. employee 테이블에서 employeeNumber로 epId 찾기
const employeeResult = await db
.select({ epId: employee.epId })
.from(employee)
.where(eq(employee.employeeNumber, pernr))
.limit(1);
if (employeeResult.length === 0) {
debugError('사번에 해당하는 직원 정보를 찾을 수 없음', { pernr });
return null;
}
const epId = employeeResult[0].epId;
debugLog('직원 epId 찾음', { pernr, epId });
// 2. users 테이블에서 epId로 사용자 ID 찾기
const userResult = await db
.select({ id: users.id })
.from(users)
.where(eq(users.epId, epId))
.limit(1);
if (userResult.length === 0) {
debugError('epId에 해당하는 사용자 정보를 찾을 수 없음', { epId });
return null;
}
const userId = userResult[0].id;
debugSuccess('사용자 ID 찾음', { pernr, epId, userId });
return userId;
} catch (error) {
debugError('사용자 ID 찾기 중 오류 발생', { pernr, error });
return null;
}
}
/**
* 담당자 찾는 함수 (Non-SAP 데이터를 기준으로 찾음)
* Puchasing Group을 CMCTB_CD 에서 CD_CLF='MMA070' 조건으로, CD=EKGRP 조건으로 찾으면, USR_DF_CHAR_9 컬럼이 담당자 사번임. 기준으로 유저를 넣어줄 예정임
*/
async function findInChargeUserIdByEKGRP(EKGRP: string | null): Promise<number | null> {
try {
debugLog('담당자 찾기 시작', { EKGRP });
// NonSAP에서 담당자 사번 찾기
const result = await oracleKnex
.select('USR_DF_CHAR_9')
.from('CMCTB_CD')
.where('CD_CLF', 'MMA070')
.andWhere('CD', EKGRP)
.limit(1);
if (result.length === 0) {
debugError('담당자 찾기 중 오류 발생', { EKGRP });
return null;
}
const employeeNumber = result[0].USR_DF_CHAR_9;
// 임시 : Knox API 기준으로 사번에 해당하는 userId 찾기 (nonsap에서 제공하는 유저테이블로 변경 예정)
const userId = await findUserIdByEmployeeNumber(employeeNumber);
debugSuccess('담당자 찾음', { EKGRP, userId });
return userId;
} catch (error) {
debugError('담당자 찾기 중 오류 발생', { EKGRP, error });
return null;
}
}
// *****************************mapping functions*********************************
/**
* ECC RFQ 헤더 데이터를 비즈니스 테이블로 매핑
*/
export async function mapECCRfqHeaderToBusiness(
eccHeader: ECCBidHeader
): Promise<ProcurementRfqData> {
debugLog('ECC RFQ 헤더 매핑 시작', { anfnr: eccHeader.ANFNR });
// 날짜 파싱 (실패시 현재 Date 들어감)
let interfacedAt: Date = new Date();
if (eccHeader.ZRFQ_TRS_DT != null && eccHeader.ZRFQ_TRS_TM != null) {
try {
// SAP 날짜 형식 (YYYYMMDD) 파싱
const dateStr = eccHeader.ZRFQ_TRS_DT;
if (dateStr.length === 8) {
const year = parseInt(dateStr.substring(0, 4));
const month = parseInt(dateStr.substring(4, 6)) - 1; // 0-based
const day = parseInt(dateStr.substring(6, 8));
const hour = parseInt(eccHeader.ZRFQ_TRS_TM.substring(0, 2));
const minute = parseInt(eccHeader.ZRFQ_TRS_TM.substring(2, 4));
const second = parseInt(eccHeader.ZRFQ_TRS_TM.substring(4, 6));
interfacedAt = new Date(year, month, day, hour, minute, second);
}
} catch (error) {
debugError('날짜 파싱 오류', {
date: eccHeader.ZRFQ_TRS_DT,
time: eccHeader.ZRFQ_TRS_TM,
error,
});
}
}
// 담당자 찾기
const inChargeUserId = await findInChargeUserIdByEKGRP(eccHeader.EKGRP || null);
// 시리즈는 3가지의 케이스만 온다고 가정한다. (다른 케이스는 잘못된 것)
// 케이스 1. 한 RFQ에서 PR Item이 여러 개고, PSPID 값이 모두 같은 경우 => series 값은 "SS"
// 케이스 2. 한 RFQ에서 PR Item이 여러 개고, PSPID 값이 여러개인 경우 => series 값은 "||""
// 케이스 3. 한 RFQ에서 PR Item이 하나인 경우 => seires 값은 null
// 만약 위 케이스에 모두 속하지 않는 경우 케이스 3처럼 null 값을 넣는다.
// 매핑
const mappedData: ProcurementRfqData = {
rfqCode: eccHeader.ANFNR, // rfqCode=rfqNumber(ANFNR), 혼선이 있을 수 있으나 ECC에는 rfqCode라는 게 별도로 없음. rfqCode=rfqNumber
projectId: null, // PR 아이템 처리 후 업데이트. (로직은 추후 작성)
series: null, // PR 아이템 처리 후 업데이트. (로직은 추후 작성)
// itemGroup: null, // 대표 자재그룹: PR 아이템 처리 후 업데이트. (로직은 추후 작성)
itemCode: null, // 대표 자재코드: PR 아이템 처리 후 업데이트. (로직은 추후 작성)
itemName: null, // 대표 자재명: PR 아이템 처리 후 업데이트. (로직은 추후 작성)
dueDate: null, // eVCP에서 사용하는 컬럼이므로 불필요.
rfqSendDate: null, // eVCP에서 사용하는 컬럼이므로 불필요.
createdAt: interfacedAt,
status: 'RFQ Created', // eVCP에서 사용하는 컬럼, 기본값 RFQ Created 처리
rfqSealedYn: false, // eVCP에서 사용하는 컬럼, 기본값 false 처리
picCode: eccHeader.EKGRP || null, // Purchasing Group을 PIC로 사용 (구매측 임직원과 연계된 코드임)
remark: null, // remark 컬럼은 담당자 메모용으로 넣어줄 필요 없음.
sentBy: null, // 보내기 전의 RFQ를 대상으로 하므로 넣어줄 필요 없음.
createdBy: inChargeUserId || 1,
updatedBy: inChargeUserId || 1,
};
debugSuccess('ECC RFQ 헤더 매핑 완료', { anfnr: eccHeader.ANFNR });
return mappedData;
}
/**
* ECC RFQ 아이템 데이터를 비즈니스 테이블로 매핑
*/
export function mapECCRfqItemToBusiness(
eccItem: ECCBidItem,
rfqId: number
): PrItemData {
debugLog('ECC RFQ 아이템 매핑 시작', {
anfnr: eccItem.ANFNR,
anfps: eccItem.ANFPS,
});
// 날짜 파싱
let deliveryDate: Date | null = null;
if (eccItem.LFDAT) {
try {
const dateStr = eccItem.LFDAT;
if (dateStr.length === 8) {
const year = parseInt(dateStr.substring(0, 4));
const month = parseInt(dateStr.substring(4, 6)) - 1;
const day = parseInt(dateStr.substring(6, 8));
deliveryDate = new Date(year, month, day);
}
} catch (error) {
debugError('아이템 날짜 파싱 오류', { date: eccItem.LFDAT, error });
}
}
// TODO: 시리즈인 경우 EBELP(Series PO Item Seq) 를 참조하는 로직 필요? 이 컬럼의 의미 확인 필요
const mappedData: PrItemData = {
procurementRfqsId: rfqId, // PR Item의 부모 RFQ ID [ok]
rfqItem: eccItem.ANFPS || null, // itemNo [ok]
prItem: eccItem.BANPO || null, // ECC PR No [ok]
prNo: eccItem.BANFN || null, // ECC PR No [ok]
materialCode: eccItem.MATNR || null, // ECC Material Number [ok]
materialCategory: eccItem.MATKL || null, // ECC Material Group [ok]
acc: eccItem.SAKTO || null, // ECC G/L Account Number [ok]
materialDescription: eccItem.TXZ01 || null, // ECC Short Text [ok] // TODO: 자재 테이블 참조해서 자재명 넣어주기 ?
size: null, // ECC에서 해당 정보 없음 // TODO: 이시원 프로에게 확인
deliveryDate, // ECC PR Delivery Date (parsed)
quantity: eccItem.MENGE ? Number(eccItem.MENGE) : null, // ECC PR Quantity [ok]
uom: eccItem.MEINS || null, // ECC PR UOM [ok]
grossWeight: eccItem.BRGEW ? Number(eccItem.BRGEW) : null, // ECC PR Gross Weight [ok]
gwUom: eccItem.GEWEI || null, // ECC PR Gross Weight UOM [ok]
specNo: null, // ECC에서 해당 정보 없음, TODO: 이시원 프로 - material 참조해서 넣어주는건지, PR 마다 고유한건지 확인
specUrl: null, // ECC에서 해당 정보 없음, TODO: 이시원 프로에게 material 참조해서 넣어주는건지, PR 마다 고유한건지 확인
trackingNo: null, // TODO: 이시원 프로에게 확인 필요. I/F 정의서 어느 항목인지 추정 불가
majorYn: false, // 기본값 false 할당, 필요시 eVCP에서 수정
projectDef: eccItem.PSPID || null, // Project Key 로 처리. // TODO: 프로젝트 테이블 참조해 코드로 처리하기
projectSc: null, // ECC에서 해당 정보 없음 // TODO: pspid 기준으로 찾아 넣어주기
projectKl: null, // ECC에서 해당 정보 없음 // TODO: pspid 기준으로 찾아 넣어주기
projectLc: null, // ECC에서 해당 정보 없음 // TODO: pspid 기준으로 찾아 넣어주기
projectDl: null, // ECC에서 해당 정보 없음 // TODO: pspid 기준으로 찾아 넣어주기
remark: null, // remark 컬럼은 담당자 메모용으로 넣어줄 필요 없음.
};
debugSuccess('ECC RFQ 아이템 매핑 완료', {
rfqItem: eccItem.ANFPS,
materialCode: eccItem.MATNR,
});
return mappedData;
}
/**
* ECC 데이터를 비즈니스 테이블로 일괄 매핑 및 저장
*/
export async function mapAndSaveECCRfqData(
eccHeaders: ECCBidHeader[],
eccItems: ECCBidItem[]
): Promise<{ success: boolean; message: string; processedCount: number }> {
debugLog('ECC 데이터 일괄 매핑 및 저장 시작', {
headerCount: eccHeaders.length,
itemCount: eccItems.length,
});
try {
const result = await db.transaction(async (tx) => {
// 1) 헤더별 관련 아이템 그룹핑 + 시리즈 계산 + 헤더 매핑을 병렬로 수행
const rfqGroups = await Promise.all(
eccHeaders.map(async (eccHeader) => {
const relatedItems = eccItems.filter((item) => item.ANFNR === eccHeader.ANFNR);
const series = computeSeriesFromItems(relatedItems);
const rfqData = await mapECCRfqHeaderToBusiness(eccHeader);
rfqData.series = series;
return { rfqCode: rfqData.rfqCode, rfqData, relatedItems };
})
);
const rfqRecords = rfqGroups.map((g) => g.rfqData);
// 2) RFQ 다건 삽입 (중복은 무시). 반환된 레코드로 일부 ID 매핑
const inserted = await tx
.insert(procurementRfqs)
.values(rfqRecords)
.onConflictDoNothing()
.returning({ id: procurementRfqs.id, rfqCode: procurementRfqs.rfqCode });
const rfqCodeToId = new Map<string, number>();
for (const row of inserted) {
if (row.rfqCode) {
rfqCodeToId.set(row.rfqCode, row.id);
}
}
// 3) 반환되지 않은 기존 RFQ 들의 ID 조회하여 매핑 보완
const allCodes = rfqRecords
.map((r) => r.rfqCode)
.filter((c): c is string => typeof c === 'string' && c.length > 0);
const missingCodes = allCodes.filter((c) => !rfqCodeToId.has(c));
if (missingCodes.length > 0) {
const existing = await tx
.select({ id: procurementRfqs.id, rfqCode: procurementRfqs.rfqCode })
.from(procurementRfqs)
.where(inArray(procurementRfqs.rfqCode, missingCodes));
for (const row of existing) {
if (row.rfqCode) {
rfqCodeToId.set(row.rfqCode, row.id);
}
}
}
// 4) 모든 아이템을 한 번에 생성할 데이터로 변환
const allItemsToInsert: PrItemData[] = [];
for (const group of rfqGroups) {
const rfqCode = group.rfqCode;
if (!rfqCode) continue;
const rfqId = rfqCodeToId.get(rfqCode);
if (!rfqId) {
debugError('RFQ ID 매핑 누락', { rfqCode });
throw new Error(`RFQ ID를 찾을 수 없습니다: ${rfqCode}`);
}
for (const eccItem of group.relatedItems) {
const itemData = mapECCRfqItemToBusiness(eccItem, rfqId);
allItemsToInsert.push(itemData);
}
}
// 5) 아이템 일괄 삽입 (chunk 처리로 파라미터 제한 회피)
const ITEM_CHUNK_SIZE = 1000;
for (let i = 0; i < allItemsToInsert.length; i += ITEM_CHUNK_SIZE) {
const chunk = allItemsToInsert.slice(i, i + ITEM_CHUNK_SIZE);
await tx.insert(prItems).values(chunk);
}
return { processedCount: rfqRecords.length };
});
debugSuccess('ECC 데이터 일괄 처리 완료', {
processedCount: result.processedCount,
});
return {
success: true,
message: `${result.processedCount}개의 RFQ 데이터가 성공적으로 처리되었습니다.`,
processedCount: result.processedCount,
};
} catch (error) {
debugError('ECC 데이터 처리 중 오류 발생', error);
return {
success: false,
message:
error instanceof Error
? error.message
: '알 수 없는 오류가 발생했습니다.',
processedCount: 0,
};
}
}
/**
* ECC 데이터 유효성 검증
*/
export function validateECCRfqData(
eccHeaders: ECCBidHeader[],
eccItems: ECCBidItem[]
): { isValid: boolean; errors: string[] } {
const errors: string[] = [];
// 헤더 데이터 검증
for (const header of eccHeaders) {
if (!header.ANFNR) {
errors.push(`필수 필드 누락: ANFNR (Bidding/RFQ Number)`);
}
if (!header.ZBSART) {
errors.push(
`필수 필드 누락: ZBSART (Bidding Type) - ANFNR: ${header.ANFNR}`
);
}
}
// 아이템 데이터 검증
for (const item of eccItems) {
if (!item.ANFNR) {
errors.push(
`필수 필드 누락: ANFNR (Bidding/RFQ Number) - Item: ${item.ANFPS}`
);
}
if (!item.ANFPS) {
errors.push(`필수 필드 누락: ANFPS (Item Number) - ANFNR: ${item.ANFNR}`);
}
if (!item.BANFN) {
errors.push(
`필수 필드 누락: BANFN (Purchase Requisition Number) - ANFNR: ${item.ANFNR}, ANFPS: ${item.ANFPS}`
);
}
if (!item.BANPO) {
errors.push(
`필수 필드 누락: BANPO (Item Number of Purchase Requisition) - ANFNR: ${item.ANFNR}, ANFPS: ${item.ANFPS}`
);
}
}
// 헤더와 아이템 간의 관계 검증
const headerAnfnrs = new Set(eccHeaders.map((h) => h.ANFNR));
const itemAnfnrs = new Set(eccItems.map((i) => i.ANFNR));
for (const anfnr of itemAnfnrs) {
if (!headerAnfnrs.has(anfnr)) {
errors.push(`아이템의 ANFNR이 헤더에 존재하지 않음: ${anfnr}`);
}
}
return {
isValid: errors.length === 0,
errors,
};
}
|