Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node

; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
This commit is contained in:
DDD1542
2026-02-12 16:33:00 +09:00
13 changed files with 1668 additions and 223 deletions

View File

@@ -78,6 +78,7 @@ export interface ExcelUploadResult {
masterInserted: number;
masterUpdated: number;
detailInserted: number;
detailUpdated: number;
detailDeleted: number;
errors: string[];
}
@@ -517,11 +518,6 @@ class MasterDetailExcelService {
params
);
logger.info(`채번 컬럼 조회 결과: ${tableName}.${columnName}`, {
rowCount: result.length,
rows: result.map((r: any) => ({ input_type: r.input_type, company_code: r.company_code })),
});
// 채번 타입인 행 찾기 (회사별 우선)
for (const row of result) {
if (row.input_type === "numbering") {
@@ -530,13 +526,11 @@ class MasterDetailExcelService {
: row.detail_settings;
if (settings?.numberingRuleId) {
logger.info(`채번 컬럼 감지: ${tableName}.${columnName} → 규칙 ID: ${settings.numberingRuleId} (company: ${row.company_code})`);
return { numberingRuleId: settings.numberingRuleId };
}
}
}
logger.info(`채번 컬럼 아님: ${tableName}.${columnName}`);
return null;
} catch (error) {
logger.error(`채번 컬럼 감지 실패: ${tableName}.${columnName}`, error);
@@ -544,6 +538,118 @@ class MasterDetailExcelService {
}
}
/**
* 특정 테이블의 모든 채번 컬럼을 한 번에 조회
* 회사별 설정 우선, 공통(*) 설정 fallback
* @returns Map<columnName, numberingRuleId>
*/
private async detectAllNumberingColumns(
tableName: string,
companyCode?: string
): Promise<Map<string, string>> {
const numberingCols = new Map<string, string>();
try {
const companyCondition = companyCode && companyCode !== "*"
? `AND company_code IN ($2, '*')`
: `AND company_code = '*'`;
const params = companyCode && companyCode !== "*"
? [tableName, companyCode]
: [tableName];
const result = await query<any>(
`SELECT column_name, detail_settings, company_code
FROM table_type_columns
WHERE table_name = $1 AND input_type = 'numbering' ${companyCondition}
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
params
);
// 컬럼별로 회사 설정 우선 적용
for (const row of result) {
if (numberingCols.has(row.column_name)) continue; // 이미 회사별 설정이 있으면 스킵
const settings = typeof row.detail_settings === "string"
? JSON.parse(row.detail_settings || "{}")
: row.detail_settings;
if (settings?.numberingRuleId) {
numberingCols.set(row.column_name, settings.numberingRuleId);
}
}
if (numberingCols.size > 0) {
logger.info(`테이블 ${tableName} 채번 컬럼 감지:`, Object.fromEntries(numberingCols));
}
} catch (error) {
logger.error(`테이블 ${tableName} 채번 컬럼 감지 실패:`, error);
}
return numberingCols;
}
/**
* 디테일 테이블의 고유 키 컬럼 감지 (UPSERT 매칭용)
* PK가 비즈니스 키이면 사용, auto-increment 'id'만이면 유니크 인덱스 탐색
* @returns 고유 키 컬럼 배열 (빈 배열이면 매칭 불가 → INSERT만 수행)
*/
private async detectUniqueKeyColumns(
client: any,
tableName: string
): Promise<string[]> {
try {
// 1. PK 컬럼 조회
const pkResult = await client.query(
`SELECT array_agg(a.attname ORDER BY x.n) AS columns
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
CROSS JOIN LATERAL unnest(c.conkey) WITH ORDINALITY AS x(attnum, n)
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum
WHERE n.nspname = 'public' AND t.relname = $1 AND c.contype = 'p'`,
[tableName]
);
if (pkResult.rows.length > 0 && pkResult.rows[0].columns) {
const pkCols: string[] = typeof pkResult.rows[0].columns === "string"
? pkResult.rows[0].columns.replace(/[{}]/g, "").split(",").map((s: string) => s.trim())
: pkResult.rows[0].columns;
// PK가 'id' 하나만 있으면 auto-increment이므로 사용 불가
if (!(pkCols.length === 1 && pkCols[0] === "id")) {
logger.info(`디테일 테이블 ${tableName} 고유 키 (PK): ${pkCols.join(", ")}`);
return pkCols;
}
}
// 2. PK가 'id'뿐이면 유니크 인덱스 탐색
const uqResult = await client.query(
`SELECT array_agg(a.attname ORDER BY x.n) AS columns
FROM pg_index ix
JOIN pg_class t ON t.oid = ix.indrelid
JOIN pg_class i ON i.oid = ix.indexrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
CROSS JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS x(attnum, n)
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum
WHERE n.nspname = 'public' AND t.relname = $1
AND ix.indisunique = true AND ix.indisprimary = false
GROUP BY i.relname
LIMIT 1`,
[tableName]
);
if (uqResult.rows.length > 0 && uqResult.rows[0].columns) {
const uqCols: string[] = typeof uqResult.rows[0].columns === "string"
? uqResult.rows[0].columns.replace(/[{}]/g, "").split(",").map((s: string) => s.trim())
: uqResult.rows[0].columns;
logger.info(`디테일 테이블 ${tableName} 고유 키 (UNIQUE INDEX): ${uqCols.join(", ")}`);
return uqCols;
}
logger.info(`디테일 테이블 ${tableName} 고유 키 없음 → INSERT 전용`);
return [];
} catch (error) {
logger.error(`디테일 테이블 ${tableName} 고유 키 감지 실패:`, error);
return [];
}
}
/**
* 마스터-디테일 데이터 업로드 (엑셀 업로드용)
*
@@ -551,7 +657,7 @@ class MasterDetailExcelService {
* 1. 마스터 키 컬럼이 채번 타입인지 확인
* 2-A. 채번인 경우: 다른 마스터 컬럼 값으로 그룹화 → 키 자동 생성 → INSERT
* 2-B. 채번 아닌 경우: 마스터 키 값으로 그룹화 → UPSERT
* 3. 디테일 데이터 INSERT
* 3. 디테일 데이터 개별 행 UPSERT (고유 키 기반)
*/
async uploadJoinedData(
relation: MasterDetailRelation,
@@ -564,6 +670,7 @@ class MasterDetailExcelService {
masterInserted: 0,
masterUpdated: 0,
detailInserted: 0,
detailUpdated: 0,
detailDeleted: 0,
errors: [],
};
@@ -633,30 +740,78 @@ class MasterDetailExcelService {
logger.info(`일반 모드 그룹화 완료: ${groupedData.size}개 마스터 그룹`);
}
// 디테일 테이블의 채번 컬럼 사전 감지 (1회 쿼리로 모든 채번 컬럼 조회)
const detailNumberingCols = await this.detectAllNumberingColumns(detailTable, companyCode);
// 마스터 테이블의 비-키 채번 컬럼도 감지
const masterNumberingCols = await this.detectAllNumberingColumns(masterTable, companyCode);
// 디테일 테이블의 고유 키 컬럼 감지 (UPSERT 매칭용)
// PK가 비즈니스 키인 경우 사용, auto-increment 'id'만 있으면 유니크 인덱스 탐색
const detailUniqueKeyCols = await this.detectUniqueKeyColumns(client, detailTable);
// 각 그룹 처리
for (const [groupKey, rows] of groupedData.entries()) {
try {
// 마스터 키 결정 (채번이면 자동 생성, 아니면 그룹 키 자체가 마스터 키)
let masterKey: string;
let existingMasterKey: string | null = null;
// 마스터 데이터 추출 (첫 번째 행에서, 키 제외)
const masterDataWithoutKey: Record<string, any> = {};
for (const col of masterColumns) {
if (col.name === masterKeyColumn) continue;
if (rows[0][col.name] !== undefined) {
masterDataWithoutKey[col.name] = rows[0][col.name];
}
}
if (isAutoNumbering) {
// 채번 규칙으로 마스터 키 자동 생성
masterKey = await this.generateNumberWithRule(client, numberingInfo!.numberingRuleId, companyCode);
logger.info(`채번 생성: ${masterKey}`);
// 채번 모드: 동일한 마스터가 이미 DB에 있는지 먼저 확인
// 마스터 키 제외한 다른 컬럼들로 매칭 (예: dept_name이 같은 부서가 있는지)
const matchCols = Object.keys(masterDataWithoutKey)
.filter(k => k !== "company_code" && k !== "writer" && k !== "created_date" && k !== "updated_date" && k !== "id"
&& masterDataWithoutKey[k] !== undefined && masterDataWithoutKey[k] !== null && masterDataWithoutKey[k] !== "");
if (matchCols.length > 0) {
const whereClause = matchCols.map((col, i) => `"${col}" = $${i + 1}`).join(" AND ");
const companyIdx = matchCols.length + 1;
const matchResult = await client.query(
`SELECT "${masterKeyColumn}" FROM "${masterTable}" WHERE ${whereClause} AND company_code = $${companyIdx} LIMIT 1`,
[...matchCols.map(k => masterDataWithoutKey[k]), companyCode]
);
if (matchResult.rows.length > 0) {
existingMasterKey = matchResult.rows[0][masterKeyColumn];
logger.info(`채번 모드: 기존 마스터 발견 → ${masterKeyColumn}=${existingMasterKey} (매칭: ${matchCols.map(c => `${c}=${masterDataWithoutKey[c]}`).join(", ")})`);
}
}
if (existingMasterKey) {
// 기존 마스터 사용 (UPDATE)
masterKey = existingMasterKey;
const updateKeys = matchCols.filter(k => k !== masterKeyColumn);
if (updateKeys.length > 0) {
const setClauses = updateKeys.map((k, i) => `"${k}" = $${i + 1}`);
const setValues = updateKeys.map(k => masterDataWithoutKey[k]);
const updatedDateClause = masterExistingCols.has("updated_date") ? `, updated_date = NOW()` : "";
await client.query(
`UPDATE "${masterTable}" SET ${setClauses.join(", ")}${updatedDateClause} WHERE "${masterKeyColumn}" = $${setValues.length + 1} AND company_code = $${setValues.length + 2}`,
[...setValues, masterKey, companyCode]
);
}
result.masterUpdated++;
} else {
// 새 마스터 생성 (채번)
masterKey = await this.generateNumberWithRule(client, numberingInfo!.numberingRuleId, companyCode);
logger.info(`채번 생성: ${masterKey}`);
}
} else {
masterKey = groupKey;
}
// 마스터 데이터 추출 (첫 번째 행에서)
// 마스터 데이터 조립
const masterData: Record<string, any> = {};
// 마스터 키 컬럼은 항상 설정 (분할패널 컬럼 목록에 없어도)
masterData[masterKeyColumn] = masterKey;
for (const col of masterColumns) {
if (col.name === masterKeyColumn) continue; // 이미 위에서 설정
if (rows[0][col.name] !== undefined) {
masterData[col.name] = rows[0][col.name];
}
}
Object.assign(masterData, masterDataWithoutKey);
// 회사 코드, 작성자 추가 (테이블에 해당 컬럼이 있을 때만)
if (masterExistingCols.has("company_code")) {
@@ -666,6 +821,16 @@ class MasterDetailExcelService {
masterData.writer = userId;
}
// 마스터 비-키 채번 컬럼 자동 생성 (매핑되지 않은 경우)
for (const [colName, ruleId] of masterNumberingCols) {
if (colName === masterKeyColumn) continue;
if (!masterData[colName] || masterData[colName] === "") {
const generatedValue = await this.generateNumberWithRule(client, ruleId, companyCode);
masterData[colName] = generatedValue;
logger.info(`마스터 채번 생성: ${masterTable}.${colName} = ${generatedValue}`);
}
}
// INSERT SQL 생성 헬퍼 (created_date 존재 시만 추가)
const buildInsertSQL = (table: string, data: Record<string, any>, existingCols: Set<string>) => {
const cols = Object.keys(data);
@@ -680,12 +845,12 @@ class MasterDetailExcelService {
};
};
if (isAutoNumbering) {
// 채번 모드: 항상 INSERT (새 마스터 생성)
if (isAutoNumbering && !existingMasterKey) {
// 채번 모드 + 새 마스터: INSERT
const { sql, values } = buildInsertSQL(masterTable, masterData, masterExistingCols);
await client.query(sql, values);
result.masterInserted++;
} else {
} else if (!isAutoNumbering) {
// 일반 모드: UPSERT (있으면 UPDATE, 없으면 INSERT)
const existingMaster = await client.query(
`SELECT 1 FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`,
@@ -716,15 +881,9 @@ class MasterDetailExcelService {
result.masterInserted++;
}
// 일반 모드에서만 기존 디테일 삭제 (채번 모드는 새 마스터이므로 삭제할 디테일 없음)
const deleteResult = await client.query(
`DELETE FROM "${detailTable}" WHERE "${detailFkColumn}" = $1 AND company_code = $2`,
[masterKey, companyCode]
);
result.detailDeleted += deleteResult.rowCount || 0;
}
// 디테일 INSERT
// 디테일 개별 행 UPSERT 처리
for (const row of rows) {
const detailData: Record<string, any> = {};
@@ -737,16 +896,105 @@ class MasterDetailExcelService {
detailData.writer = userId;
}
// 디테일 컬럼 데이터 추출
// 디테일 컬럼 데이터 추출 (분할 패널 설정 컬럼 기준)
for (const col of detailColumns) {
if (row[col.name] !== undefined) {
detailData[col.name] = row[col.name];
}
}
const { sql, values } = buildInsertSQL(detailTable, detailData, detailExistingCols);
await client.query(sql, values);
result.detailInserted++;
// 분할 패널에 없지만 엑셀에서 매핑된 디테일 컬럼도 포함
// (user_id 등 화면에 표시되지 않지만 NOT NULL인 컬럼 처리)
const detailColNames = new Set(detailColumns.map(c => c.name));
const skipCols = new Set([
detailFkColumn, masterKeyColumn,
"company_code", "writer", "created_date", "updated_date", "id",
]);
for (const key of Object.keys(row)) {
if (!detailColNames.has(key) && !skipCols.has(key) && detailExistingCols.has(key) && row[key] !== undefined && row[key] !== null && row[key] !== "") {
const isMasterCol = masterColumns.some(mc => mc.name === key);
if (!isMasterCol) {
detailData[key] = row[key];
}
}
}
// 디테일 채번 컬럼 자동 생성 (매핑되지 않은 채번 컬럼에 값 주입)
for (const [colName, ruleId] of detailNumberingCols) {
if (!detailData[colName] || detailData[colName] === "") {
const generatedValue = await this.generateNumberWithRule(client, ruleId, companyCode);
detailData[colName] = generatedValue;
logger.info(`디테일 채번 생성: ${detailTable}.${colName} = ${generatedValue}`);
}
}
// 고유 키 기반 UPSERT: 존재하면 UPDATE, 없으면 INSERT
const hasUniqueKey = detailUniqueKeyCols.length > 0;
const uniqueKeyValues = hasUniqueKey
? detailUniqueKeyCols.map(col => detailData[col])
: [];
// 고유 키 값이 모두 있어야 매칭 가능 (채번으로 생성된 값도 포함)
const canMatch = hasUniqueKey && uniqueKeyValues.every(v => v !== undefined && v !== null && v !== "");
if (canMatch) {
// 기존 행 존재 여부 확인
const whereClause = detailUniqueKeyCols
.map((col, i) => `"${col}" = $${i + 1}`)
.join(" AND ");
const companyParam = detailExistingCols.has("company_code")
? ` AND company_code = $${detailUniqueKeyCols.length + 1}`
: "";
const checkParams = detailExistingCols.has("company_code")
? [...uniqueKeyValues, companyCode]
: uniqueKeyValues;
const existingRow = await client.query(
`SELECT 1 FROM "${detailTable}" WHERE ${whereClause}${companyParam} LIMIT 1`,
checkParams
);
if (existingRow.rows.length > 0) {
// UPDATE: 고유 키와 시스템 컬럼 제외한 나머지 업데이트
const updateExclude = new Set([
...detailUniqueKeyCols, "id", "company_code", "created_date",
]);
const updateKeys = Object.keys(detailData).filter(k => !updateExclude.has(k));
if (updateKeys.length > 0) {
const setClauses = updateKeys.map((k, i) => `"${k}" = $${i + 1}`);
const setValues = updateKeys.map(k => detailData[k]);
const updatedDateClause = detailExistingCols.has("updated_date") ? `, updated_date = NOW()` : "";
const whereParams = detailUniqueKeyCols.map((col, i) => `"${col}" = $${setValues.length + i + 1}`);
const companyWhere = detailExistingCols.has("company_code")
? ` AND company_code = $${setValues.length + detailUniqueKeyCols.length + 1}`
: "";
const allValues = [
...setValues,
...uniqueKeyValues,
...(detailExistingCols.has("company_code") ? [companyCode] : []),
];
await client.query(
`UPDATE "${detailTable}" SET ${setClauses.join(", ")}${updatedDateClause} WHERE ${whereParams.join(" AND ")}${companyWhere}`,
allValues
);
result.detailUpdated = (result.detailUpdated || 0) + 1;
logger.info(`디테일 UPDATE: ${detailUniqueKeyCols.map((c, i) => `${c}=${uniqueKeyValues[i]}`).join(", ")}`);
}
} else {
// INSERT: 새로운 행
const { sql, values } = buildInsertSQL(detailTable, detailData, detailExistingCols);
await client.query(sql, values);
result.detailInserted++;
logger.info(`디테일 INSERT: ${detailUniqueKeyCols.map((c, i) => `${c}=${uniqueKeyValues[i]}`).join(", ")}`);
}
} else {
// 고유 키가 없거나 값이 없으면 INSERT 전용
const { sql, values } = buildInsertSQL(detailTable, detailData, detailExistingCols);
await client.query(sql, values);
result.detailInserted++;
}
}
} catch (error: any) {
result.errors.push(`그룹 처리 실패: ${error.message}`);
@@ -761,7 +1009,7 @@ class MasterDetailExcelService {
masterInserted: result.masterInserted,
masterUpdated: result.masterUpdated,
detailInserted: result.detailInserted,
detailDeleted: result.detailDeleted,
detailUpdated: result.detailUpdated,
errors: result.errors.length,
});