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:
@@ -2447,3 +2447,260 @@ export async function getReferencedByTables(
|
||||
res.status(500).json(response);
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// PK / 인덱스 관리 API
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* PK/인덱스 상태 조회
|
||||
* GET /api/table-management/tables/:tableName/constraints
|
||||
*/
|
||||
export async function getTableConstraints(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
|
||||
if (!tableName) {
|
||||
res.status(400).json({ success: false, message: "테이블명이 필요합니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
// PK 조회
|
||||
const pkResult = await query<any>(
|
||||
`SELECT tc.conname AS constraint_name,
|
||||
array_agg(a.attname ORDER BY x.n) AS columns
|
||||
FROM pg_constraint tc
|
||||
JOIN pg_class c ON tc.conrelid = c.oid
|
||||
JOIN pg_namespace ns ON c.relnamespace = ns.oid
|
||||
CROSS JOIN LATERAL unnest(tc.conkey) WITH ORDINALITY AS x(attnum, n)
|
||||
JOIN pg_attribute a ON a.attrelid = tc.conrelid AND a.attnum = x.attnum
|
||||
WHERE ns.nspname = 'public' AND c.relname = $1 AND tc.contype = 'p'
|
||||
GROUP BY tc.conname`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
// array_agg 결과가 문자열로 올 수 있으므로 안전하게 배열로 변환
|
||||
const parseColumns = (cols: any): string[] => {
|
||||
if (Array.isArray(cols)) return cols;
|
||||
if (typeof cols === "string") {
|
||||
// PostgreSQL 배열 형식: {col1,col2}
|
||||
return cols.replace(/[{}]/g, "").split(",").filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const primaryKey = pkResult.length > 0
|
||||
? { name: pkResult[0].constraint_name, columns: parseColumns(pkResult[0].columns) }
|
||||
: { name: "", columns: [] };
|
||||
|
||||
// 인덱스 조회 (PK 인덱스 제외)
|
||||
const indexResult = await query<any>(
|
||||
`SELECT i.relname AS index_name,
|
||||
ix.indisunique AS is_unique,
|
||||
array_agg(a.attname ORDER BY x.n) AS columns
|
||||
FROM pg_index ix
|
||||
JOIN pg_class t ON ix.indrelid = t.oid
|
||||
JOIN pg_class i ON ix.indexrelid = i.oid
|
||||
JOIN pg_namespace ns ON t.relnamespace = ns.oid
|
||||
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 ns.nspname = 'public' AND t.relname = $1
|
||||
AND ix.indisprimary = false
|
||||
GROUP BY i.relname, ix.indisunique
|
||||
ORDER BY i.relname`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
const indexes = indexResult.map((row: any) => ({
|
||||
name: row.index_name,
|
||||
columns: parseColumns(row.columns),
|
||||
isUnique: row.is_unique,
|
||||
}));
|
||||
|
||||
logger.info(`제약조건 조회: ${tableName} - PK: ${primaryKey.columns.join(",")}, 인덱스: ${indexes.length}개`);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: { primaryKey, indexes },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("제약조건 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "제약조건 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PK 설정
|
||||
* PUT /api/table-management/tables/:tableName/primary-key
|
||||
*/
|
||||
export async function setTablePrimaryKey(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const { columns } = req.body;
|
||||
|
||||
if (!tableName || !columns || !Array.isArray(columns) || columns.length === 0) {
|
||||
res.status(400).json({ success: false, message: "테이블명과 PK 컬럼 배열이 필요합니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`PK 설정: ${tableName} → [${columns.join(", ")}]`);
|
||||
|
||||
// 기존 PK 제약조건 이름 조회
|
||||
const existingPk = await query<any>(
|
||||
`SELECT conname FROM pg_constraint tc
|
||||
JOIN pg_class c ON tc.conrelid = c.oid
|
||||
JOIN pg_namespace ns ON c.relnamespace = ns.oid
|
||||
WHERE ns.nspname = 'public' AND c.relname = $1 AND tc.contype = 'p'`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
// 기존 PK 삭제
|
||||
if (existingPk.length > 0) {
|
||||
const dropSql = `ALTER TABLE "public"."${tableName}" DROP CONSTRAINT "${existingPk[0].conname}"`;
|
||||
logger.info(`기존 PK 삭제: ${dropSql}`);
|
||||
await query(dropSql);
|
||||
}
|
||||
|
||||
// 새 PK 추가
|
||||
const colList = columns.map((c: string) => `"${c}"`).join(", ");
|
||||
const addSql = `ALTER TABLE "public"."${tableName}" ADD PRIMARY KEY (${colList})`;
|
||||
logger.info(`새 PK 추가: ${addSql}`);
|
||||
await query(addSql);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: `PK가 설정되었습니다: ${columns.join(", ")}`,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("PK 설정 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "PK 설정 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 인덱스 토글 (생성/삭제)
|
||||
* POST /api/table-management/tables/:tableName/indexes
|
||||
*/
|
||||
export async function toggleTableIndex(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const { columnName, indexType, action } = req.body;
|
||||
|
||||
if (!tableName || !columnName || !indexType || !action) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "tableName, columnName, indexType(index|unique), action(create|drop)이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const indexName = `idx_${tableName}_${columnName}${indexType === "unique" ? "_uq" : ""}`;
|
||||
|
||||
logger.info(`인덱스 ${action}: ${indexName} (${indexType})`);
|
||||
|
||||
if (action === "create") {
|
||||
const uniqueClause = indexType === "unique" ? "UNIQUE " : "";
|
||||
const sql = `CREATE ${uniqueClause}INDEX "${indexName}" ON "public"."${tableName}" ("${columnName}")`;
|
||||
logger.info(`인덱스 생성: ${sql}`);
|
||||
await query(sql);
|
||||
} else if (action === "drop") {
|
||||
const sql = `DROP INDEX IF EXISTS "public"."${indexName}"`;
|
||||
logger.info(`인덱스 삭제: ${sql}`);
|
||||
await query(sql);
|
||||
} else {
|
||||
res.status(400).json({ success: false, message: "action은 create 또는 drop이어야 합니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: action === "create"
|
||||
? `인덱스가 생성되었습니다: ${indexName}`
|
||||
: `인덱스가 삭제되었습니다: ${indexName}`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("인덱스 토글 오류:", error);
|
||||
|
||||
// 중복 데이터로 인한 UNIQUE 인덱스 생성 실패 안내
|
||||
const errorMsg = error.message?.includes("duplicate key")
|
||||
? "중복 데이터가 있어 UNIQUE 인덱스를 생성할 수 없습니다. 중복 데이터를 먼저 정리해주세요."
|
||||
: "인덱스 설정 중 오류가 발생했습니다.";
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: errorMsg,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NOT NULL 토글
|
||||
* PUT /api/table-management/tables/:tableName/columns/:columnName/nullable
|
||||
*/
|
||||
export async function toggleColumnNullable(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName, columnName } = req.params;
|
||||
const { nullable } = req.body;
|
||||
|
||||
if (!tableName || !columnName || typeof nullable !== "boolean") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "tableName, columnName, nullable(boolean)이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (nullable) {
|
||||
// NOT NULL 해제
|
||||
const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" DROP NOT NULL`;
|
||||
logger.info(`NOT NULL 해제: ${sql}`);
|
||||
await query(sql);
|
||||
} else {
|
||||
// NOT NULL 설정
|
||||
const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" SET NOT NULL`;
|
||||
logger.info(`NOT NULL 설정: ${sql}`);
|
||||
await query(sql);
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: nullable
|
||||
? `${columnName} 컬럼의 NOT NULL 제약이 해제되었습니다.`
|
||||
: `${columnName} 컬럼이 NOT NULL로 설정되었습니다.`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("NOT NULL 토글 오류:", error);
|
||||
|
||||
// NULL 데이터가 있는 컬럼에 NOT NULL 설정 시 안내
|
||||
const errorMsg = error.message?.includes("contains null values")
|
||||
? "해당 컬럼에 NULL 값이 있어 NOT NULL 설정이 불가합니다. NULL 데이터를 먼저 정리해주세요."
|
||||
: "NOT NULL 설정 중 오류가 발생했습니다.";
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: errorMsg,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,14 +166,20 @@ router.post(
|
||||
masterInserted: result.masterInserted,
|
||||
masterUpdated: result.masterUpdated,
|
||||
detailInserted: result.detailInserted,
|
||||
detailUpdated: result.detailUpdated,
|
||||
errors: result.errors.length,
|
||||
});
|
||||
|
||||
const detailTotal = result.detailInserted + (result.detailUpdated || 0);
|
||||
const detailMsg = result.detailUpdated
|
||||
? `디테일 신규 ${result.detailInserted}건, 수정 ${result.detailUpdated}건`
|
||||
: `디테일 ${result.detailInserted}건`;
|
||||
|
||||
return res.json({
|
||||
success: result.success,
|
||||
data: result,
|
||||
message: result.success
|
||||
? `마스터 ${result.masterInserted + result.masterUpdated}건, 디테일 ${result.detailInserted}건 처리되었습니다.`
|
||||
? `마스터 ${result.masterInserted + result.masterUpdated}건, ${detailMsg} 처리되었습니다.`
|
||||
: "업로드 중 오류가 발생했습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -28,6 +28,10 @@ import {
|
||||
multiTableSave, // 🆕 범용 다중 테이블 저장
|
||||
getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회
|
||||
getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회
|
||||
getTableConstraints, // 🆕 PK/인덱스 상태 조회
|
||||
setTablePrimaryKey, // 🆕 PK 설정
|
||||
toggleTableIndex, // 🆕 인덱스 토글
|
||||
toggleColumnNullable, // 🆕 NOT NULL 토글
|
||||
} from "../controllers/tableManagementController";
|
||||
|
||||
const router = express.Router();
|
||||
@@ -133,6 +137,30 @@ router.put("/tables/:tableName/columns/batch", updateAllColumnSettings);
|
||||
*/
|
||||
router.get("/tables/:tableName/schema", getTableSchema);
|
||||
|
||||
/**
|
||||
* PK/인덱스 제약조건 상태 조회
|
||||
* GET /api/table-management/tables/:tableName/constraints
|
||||
*/
|
||||
router.get("/tables/:tableName/constraints", getTableConstraints);
|
||||
|
||||
/**
|
||||
* PK 설정 (기존 PK DROP 후 재생성)
|
||||
* PUT /api/table-management/tables/:tableName/primary-key
|
||||
*/
|
||||
router.put("/tables/:tableName/primary-key", setTablePrimaryKey);
|
||||
|
||||
/**
|
||||
* 인덱스 토글 (생성/삭제)
|
||||
* POST /api/table-management/tables/:tableName/indexes
|
||||
*/
|
||||
router.post("/tables/:tableName/indexes", toggleTableIndex);
|
||||
|
||||
/**
|
||||
* NOT NULL 토글
|
||||
* PUT /api/table-management/tables/:tableName/columns/:columnName/nullable
|
||||
*/
|
||||
router.put("/tables/:tableName/columns/:columnName/nullable", toggleColumnNullable);
|
||||
|
||||
/**
|
||||
* 테이블 존재 여부 확인
|
||||
* GET /api/table-management/tables/:tableName/exists
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user