This commit is contained in:
dohyeons
2025-12-01 10:14:47 +09:00
59 changed files with 11443 additions and 895 deletions

View File

@@ -71,6 +71,7 @@ import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
import orderRoutes from "./routes/orderRoutes"; // 수주 관리
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@@ -236,6 +237,7 @@ app.use("/api/code-merge", codeMergeRoutes); // 코드 병합
app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
app.use("/api/orders", orderRoutes); // 수주 관리
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
// app.use("/api/collections", collectionRoutes); // 임시 주석
// app.use("/api/batch", batchRoutes); // 임시 주석
// app.use('/api/users', userRoutes);

View File

@@ -419,3 +419,66 @@ export const getTableColumns = async (
});
}
};
// 특정 필드만 업데이트 (다른 테이블 지원)
export const updateFieldValue = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode, userId } = req.user as any;
const { tableName, keyField, keyValue, updateField, updateValue } = req.body;
console.log("🔄 [updateFieldValue] 요청:", {
tableName,
keyField,
keyValue,
updateField,
updateValue,
userId,
companyCode,
});
// 필수 필드 검증
if (!tableName || !keyField || keyValue === undefined || !updateField || updateValue === undefined) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다. (tableName, keyField, keyValue, updateField, updateValue)",
});
}
// SQL 인젝션 방지를 위한 테이블명/컬럼명 검증
const validNamePattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
if (!validNamePattern.test(tableName) || !validNamePattern.test(keyField) || !validNamePattern.test(updateField)) {
return res.status(400).json({
success: false,
message: "유효하지 않은 테이블명 또는 컬럼명입니다.",
});
}
// 업데이트 쿼리 실행
const result = await dynamicFormService.updateFieldValue(
tableName,
keyField,
keyValue,
updateField,
updateValue,
companyCode,
userId
);
console.log("✅ [updateFieldValue] 성공:", result);
res.json({
success: true,
data: result,
message: "필드 값이 업데이트되었습니다.",
});
} catch (error: any) {
console.error("❌ [updateFieldValue] 실패:", error);
res.status(500).json({
success: false,
message: error.message || "필드 업데이트에 실패했습니다.",
});
}
};

View File

@@ -0,0 +1,924 @@
/**
* 화면 임베딩 및 데이터 전달 시스템 컨트롤러
*/
import { Request, Response } from "express";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
const pool = getPool();
// ============================================
// 1. 화면 임베딩 API
// ============================================
/**
* 화면 임베딩 목록 조회
* GET /api/screen-embedding?parentScreenId=1
*/
export async function getScreenEmbeddings(req: Request, res: Response) {
try {
const { parentScreenId } = req.query;
const companyCode = req.user!.companyCode;
if (!parentScreenId) {
return res.status(400).json({
success: false,
message: "부모 화면 ID가 필요합니다.",
});
}
const query = `
SELECT
se.*,
ps.screen_name as parent_screen_name,
cs.screen_name as child_screen_name
FROM screen_embedding se
LEFT JOIN screen_definitions ps ON se.parent_screen_id = ps.screen_id
LEFT JOIN screen_definitions cs ON se.child_screen_id = cs.screen_id
WHERE se.parent_screen_id = $1
AND se.company_code = $2
ORDER BY se.position, se.created_at
`;
const result = await pool.query(query, [parentScreenId, companyCode]);
logger.info("화면 임베딩 목록 조회", {
companyCode,
parentScreenId,
count: result.rowCount,
});
return res.json({
success: true,
data: result.rows,
});
} catch (error: any) {
logger.error("화면 임베딩 목록 조회 실패", error);
return res.status(500).json({
success: false,
message: "화면 임베딩 목록 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
* 화면 임베딩 상세 조회
* GET /api/screen-embedding/:id
*/
export async function getScreenEmbeddingById(req: Request, res: Response) {
try {
const { id } = req.params;
const companyCode = req.user!.companyCode;
const query = `
SELECT
se.*,
ps.screen_name as parent_screen_name,
cs.screen_name as child_screen_name
FROM screen_embedding se
LEFT JOIN screen_definitions ps ON se.parent_screen_id = ps.screen_id
LEFT JOIN screen_definitions cs ON se.child_screen_id = cs.screen_id
WHERE se.id = $1
AND se.company_code = $2
`;
const result = await pool.query(query, [id, companyCode]);
if (result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "화면 임베딩 설정을 찾을 수 없습니다.",
});
}
logger.info("화면 임베딩 상세 조회", { companyCode, id });
return res.json({
success: true,
data: result.rows[0],
});
} catch (error: any) {
logger.error("화면 임베딩 상세 조회 실패", error);
return res.status(500).json({
success: false,
message: "화면 임베딩 상세 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
* 화면 임베딩 생성
* POST /api/screen-embedding
*/
export async function createScreenEmbedding(req: Request, res: Response) {
try {
const {
parentScreenId,
childScreenId,
position,
mode,
config = {},
} = req.body;
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
// 필수 필드 검증
if (!parentScreenId || !childScreenId || !position || !mode) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다.",
});
}
const query = `
INSERT INTO screen_embedding (
parent_screen_id, child_screen_id, position, mode,
config, company_code, created_by, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
RETURNING *
`;
const result = await pool.query(query, [
parentScreenId,
childScreenId,
position,
mode,
JSON.stringify(config),
companyCode,
userId,
]);
logger.info("화면 임베딩 생성", {
companyCode,
userId,
id: result.rows[0].id,
});
return res.status(201).json({
success: true,
data: result.rows[0],
});
} catch (error: any) {
logger.error("화면 임베딩 생성 실패", error);
// 유니크 제약조건 위반
if (error.code === "23505") {
return res.status(409).json({
success: false,
message: "이미 동일한 임베딩 설정이 존재합니다.",
});
}
return res.status(500).json({
success: false,
message: "화면 임베딩 생성 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
* 화면 임베딩 수정
* PUT /api/screen-embedding/:id
*/
export async function updateScreenEmbedding(req: Request, res: Response) {
try {
const { id } = req.params;
const { position, mode, config } = req.body;
const companyCode = req.user!.companyCode;
const updates: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (position) {
updates.push(`position = $${paramIndex++}`);
values.push(position);
}
if (mode) {
updates.push(`mode = $${paramIndex++}`);
values.push(mode);
}
if (config) {
updates.push(`config = $${paramIndex++}`);
values.push(JSON.stringify(config));
}
if (updates.length === 0) {
return res.status(400).json({
success: false,
message: "수정할 내용이 없습니다.",
});
}
updates.push(`updated_at = NOW()`);
values.push(id, companyCode);
const query = `
UPDATE screen_embedding
SET ${updates.join(", ")}
WHERE id = $${paramIndex++}
AND company_code = $${paramIndex++}
RETURNING *
`;
const result = await pool.query(query, values);
if (result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "화면 임베딩 설정을 찾을 수 없습니다.",
});
}
logger.info("화면 임베딩 수정", { companyCode, id });
return res.json({
success: true,
data: result.rows[0],
});
} catch (error: any) {
logger.error("화면 임베딩 수정 실패", error);
return res.status(500).json({
success: false,
message: "화면 임베딩 수정 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
* 화면 임베딩 삭제
* DELETE /api/screen-embedding/:id
*/
export async function deleteScreenEmbedding(req: Request, res: Response) {
try {
const { id } = req.params;
const companyCode = req.user!.companyCode;
const query = `
DELETE FROM screen_embedding
WHERE id = $1 AND company_code = $2
RETURNING id
`;
const result = await pool.query(query, [id, companyCode]);
if (result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "화면 임베딩 설정을 찾을 수 없습니다.",
});
}
logger.info("화면 임베딩 삭제", { companyCode, id });
return res.json({
success: true,
message: "화면 임베딩이 삭제되었습니다.",
});
} catch (error: any) {
logger.error("화면 임베딩 삭제 실패", error);
return res.status(500).json({
success: false,
message: "화면 임베딩 삭제 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// ============================================
// 2. 데이터 전달 API
// ============================================
/**
* 데이터 전달 설정 조회
* GET /api/screen-data-transfer?sourceScreenId=1&targetScreenId=2
*/
export async function getScreenDataTransfer(req: Request, res: Response) {
try {
const { sourceScreenId, targetScreenId } = req.query;
const companyCode = req.user!.companyCode;
if (!sourceScreenId || !targetScreenId) {
return res.status(400).json({
success: false,
message: "소스 화면 ID와 타겟 화면 ID가 필요합니다.",
});
}
const query = `
SELECT
sdt.*,
ss.screen_name as source_screen_name,
ts.screen_name as target_screen_name
FROM screen_data_transfer sdt
LEFT JOIN screen_definitions ss ON sdt.source_screen_id = ss.screen_id
LEFT JOIN screen_definitions ts ON sdt.target_screen_id = ts.screen_id
WHERE sdt.source_screen_id = $1
AND sdt.target_screen_id = $2
AND sdt.company_code = $3
`;
const result = await pool.query(query, [
sourceScreenId,
targetScreenId,
companyCode,
]);
if (result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "데이터 전달 설정을 찾을 수 없습니다.",
});
}
logger.info("데이터 전달 설정 조회", {
companyCode,
sourceScreenId,
targetScreenId,
});
return res.json({
success: true,
data: result.rows[0],
});
} catch (error: any) {
logger.error("데이터 전달 설정 조회 실패", error);
return res.status(500).json({
success: false,
message: "데이터 전달 설정 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
* 데이터 전달 설정 생성
* POST /api/screen-data-transfer
*/
export async function createScreenDataTransfer(req: Request, res: Response) {
try {
const {
sourceScreenId,
targetScreenId,
sourceComponentId,
sourceComponentType,
dataReceivers,
buttonConfig,
} = req.body;
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
// 필수 필드 검증
if (!sourceScreenId || !targetScreenId || !dataReceivers) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다.",
});
}
const query = `
INSERT INTO screen_data_transfer (
source_screen_id, target_screen_id, source_component_id, source_component_type,
data_receivers, button_config, company_code, created_by, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
RETURNING *
`;
const result = await pool.query(query, [
sourceScreenId,
targetScreenId,
sourceComponentId,
sourceComponentType,
JSON.stringify(dataReceivers),
JSON.stringify(buttonConfig || {}),
companyCode,
userId,
]);
logger.info("데이터 전달 설정 생성", {
companyCode,
userId,
id: result.rows[0].id,
});
return res.status(201).json({
success: true,
data: result.rows[0],
});
} catch (error: any) {
logger.error("데이터 전달 설정 생성 실패", error);
// 유니크 제약조건 위반
if (error.code === "23505") {
return res.status(409).json({
success: false,
message: "이미 동일한 데이터 전달 설정이 존재합니다.",
});
}
return res.status(500).json({
success: false,
message: "데이터 전달 설정 생성 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
* 데이터 전달 설정 수정
* PUT /api/screen-data-transfer/:id
*/
export async function updateScreenDataTransfer(req: Request, res: Response) {
try {
const { id } = req.params;
const { dataReceivers, buttonConfig } = req.body;
const companyCode = req.user!.companyCode;
const updates: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (dataReceivers) {
updates.push(`data_receivers = $${paramIndex++}`);
values.push(JSON.stringify(dataReceivers));
}
if (buttonConfig) {
updates.push(`button_config = $${paramIndex++}`);
values.push(JSON.stringify(buttonConfig));
}
if (updates.length === 0) {
return res.status(400).json({
success: false,
message: "수정할 내용이 없습니다.",
});
}
updates.push(`updated_at = NOW()`);
values.push(id, companyCode);
const query = `
UPDATE screen_data_transfer
SET ${updates.join(", ")}
WHERE id = $${paramIndex++}
AND company_code = $${paramIndex++}
RETURNING *
`;
const result = await pool.query(query, values);
if (result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "데이터 전달 설정을 찾을 수 없습니다.",
});
}
logger.info("데이터 전달 설정 수정", { companyCode, id });
return res.json({
success: true,
data: result.rows[0],
});
} catch (error: any) {
logger.error("데이터 전달 설정 수정 실패", error);
return res.status(500).json({
success: false,
message: "데이터 전달 설정 수정 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
* 데이터 전달 설정 삭제
* DELETE /api/screen-data-transfer/:id
*/
export async function deleteScreenDataTransfer(req: Request, res: Response) {
try {
const { id } = req.params;
const companyCode = req.user!.companyCode;
const query = `
DELETE FROM screen_data_transfer
WHERE id = $1 AND company_code = $2
RETURNING id
`;
const result = await pool.query(query, [id, companyCode]);
if (result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "데이터 전달 설정을 찾을 수 없습니다.",
});
}
logger.info("데이터 전달 설정 삭제", { companyCode, id });
return res.json({
success: true,
message: "데이터 전달 설정이 삭제되었습니다.",
});
} catch (error: any) {
logger.error("데이터 전달 설정 삭제 실패", error);
return res.status(500).json({
success: false,
message: "데이터 전달 설정 삭제 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// ============================================
// 3. 분할 패널 API
// ============================================
/**
* 분할 패널 설정 조회
* GET /api/screen-split-panel/:screenId
*/
export async function getScreenSplitPanel(req: Request, res: Response) {
try {
const { screenId } = req.params;
const companyCode = req.user!.companyCode;
const query = `
SELECT
ssp.*,
le.parent_screen_id as le_parent_screen_id,
le.child_screen_id as le_child_screen_id,
le.position as le_position,
le.mode as le_mode,
le.config as le_config,
re.parent_screen_id as re_parent_screen_id,
re.child_screen_id as re_child_screen_id,
re.position as re_position,
re.mode as re_mode,
re.config as re_config,
sdt.source_screen_id,
sdt.target_screen_id,
sdt.source_component_id,
sdt.source_component_type,
sdt.data_receivers,
sdt.button_config
FROM screen_split_panel ssp
LEFT JOIN screen_embedding le ON ssp.left_embedding_id = le.id
LEFT JOIN screen_embedding re ON ssp.right_embedding_id = re.id
LEFT JOIN screen_data_transfer sdt ON ssp.data_transfer_id = sdt.id
WHERE ssp.screen_id = $1
AND ssp.company_code = $2
`;
const result = await pool.query(query, [screenId, companyCode]);
if (result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "분할 패널 설정을 찾을 수 없습니다.",
});
}
const row = result.rows[0];
// 데이터 구조화
const data = {
id: row.id,
screenId: row.screen_id,
leftEmbeddingId: row.left_embedding_id,
rightEmbeddingId: row.right_embedding_id,
dataTransferId: row.data_transfer_id,
layoutConfig: row.layout_config,
companyCode: row.company_code,
createdAt: row.created_at,
updatedAt: row.updated_at,
leftEmbedding: row.le_child_screen_id
? {
id: row.left_embedding_id,
parentScreenId: row.le_parent_screen_id,
childScreenId: row.le_child_screen_id,
position: row.le_position,
mode: row.le_mode,
config: row.le_config,
}
: null,
rightEmbedding: row.re_child_screen_id
? {
id: row.right_embedding_id,
parentScreenId: row.re_parent_screen_id,
childScreenId: row.re_child_screen_id,
position: row.re_position,
mode: row.re_mode,
config: row.re_config,
}
: null,
dataTransfer: row.source_screen_id
? {
id: row.data_transfer_id,
sourceScreenId: row.source_screen_id,
targetScreenId: row.target_screen_id,
sourceComponentId: row.source_component_id,
sourceComponentType: row.source_component_type,
dataReceivers: row.data_receivers,
buttonConfig: row.button_config,
}
: null,
};
logger.info("분할 패널 설정 조회", { companyCode, screenId });
return res.json({
success: true,
data,
});
} catch (error: any) {
logger.error("분할 패널 설정 조회 실패", error);
return res.status(500).json({
success: false,
message: "분할 패널 설정 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
* 분할 패널 설정 생성
* POST /api/screen-split-panel
*/
export async function createScreenSplitPanel(req: Request, res: Response) {
const client = await pool.connect();
try {
const {
screenId,
leftEmbedding,
rightEmbedding,
dataTransfer,
layoutConfig,
} = req.body;
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
// 필수 필드 검증
if (!screenId || !leftEmbedding || !rightEmbedding || !dataTransfer) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다.",
});
}
await client.query("BEGIN");
// 1. 좌측 임베딩 생성
const leftEmbeddingQuery = `
INSERT INTO screen_embedding (
parent_screen_id, child_screen_id, position, mode,
config, company_code, created_by, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
RETURNING id
`;
const leftResult = await client.query(leftEmbeddingQuery, [
screenId,
leftEmbedding.childScreenId,
leftEmbedding.position,
leftEmbedding.mode,
JSON.stringify(leftEmbedding.config || {}),
companyCode,
userId,
]);
const leftEmbeddingId = leftResult.rows[0].id;
// 2. 우측 임베딩 생성
const rightEmbeddingQuery = `
INSERT INTO screen_embedding (
parent_screen_id, child_screen_id, position, mode,
config, company_code, created_by, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
RETURNING id
`;
const rightResult = await client.query(rightEmbeddingQuery, [
screenId,
rightEmbedding.childScreenId,
rightEmbedding.position,
rightEmbedding.mode,
JSON.stringify(rightEmbedding.config || {}),
companyCode,
userId,
]);
const rightEmbeddingId = rightResult.rows[0].id;
// 3. 데이터 전달 설정 생성
const dataTransferQuery = `
INSERT INTO screen_data_transfer (
source_screen_id, target_screen_id, source_component_id, source_component_type,
data_receivers, button_config, company_code, created_by, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
RETURNING id
`;
const dataTransferResult = await client.query(dataTransferQuery, [
dataTransfer.sourceScreenId,
dataTransfer.targetScreenId,
dataTransfer.sourceComponentId,
dataTransfer.sourceComponentType,
JSON.stringify(dataTransfer.dataReceivers),
JSON.stringify(dataTransfer.buttonConfig || {}),
companyCode,
userId,
]);
const dataTransferId = dataTransferResult.rows[0].id;
// 4. 분할 패널 생성
const splitPanelQuery = `
INSERT INTO screen_split_panel (
screen_id, left_embedding_id, right_embedding_id, data_transfer_id,
layout_config, company_code, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
RETURNING *
`;
const splitPanelResult = await client.query(splitPanelQuery, [
screenId,
leftEmbeddingId,
rightEmbeddingId,
dataTransferId,
JSON.stringify(layoutConfig || {}),
companyCode,
]);
await client.query("COMMIT");
logger.info("분할 패널 설정 생성", {
companyCode,
userId,
screenId,
id: splitPanelResult.rows[0].id,
});
return res.status(201).json({
success: true,
data: splitPanelResult.rows[0],
});
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("분할 패널 설정 생성 실패", error);
return res.status(500).json({
success: false,
message: "분할 패널 설정 생성 중 오류가 발생했습니다.",
error: error.message,
});
} finally {
client.release();
}
}
/**
* 분할 패널 설정 수정
* PUT /api/screen-split-panel/:id
*/
export async function updateScreenSplitPanel(req: Request, res: Response) {
try {
const { id } = req.params;
const { layoutConfig } = req.body;
const companyCode = req.user!.companyCode;
if (!layoutConfig) {
return res.status(400).json({
success: false,
message: "수정할 내용이 없습니다.",
});
}
const query = `
UPDATE screen_split_panel
SET layout_config = $1, updated_at = NOW()
WHERE id = $2 AND company_code = $3
RETURNING *
`;
const result = await pool.query(query, [
JSON.stringify(layoutConfig),
id,
companyCode,
]);
if (result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "분할 패널 설정을 찾을 수 없습니다.",
});
}
logger.info("분할 패널 설정 수정", { companyCode, id });
return res.json({
success: true,
data: result.rows[0],
});
} catch (error: any) {
logger.error("분할 패널 설정 수정 실패", error);
return res.status(500).json({
success: false,
message: "분할 패널 설정 수정 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
* 분할 패널 설정 삭제
* DELETE /api/screen-split-panel/:id
*/
export async function deleteScreenSplitPanel(req: Request, res: Response) {
const client = await pool.connect();
try {
const { id } = req.params;
const companyCode = req.user!.companyCode;
await client.query("BEGIN");
// 1. 분할 패널 조회
const selectQuery = `
SELECT left_embedding_id, right_embedding_id, data_transfer_id
FROM screen_split_panel
WHERE id = $1 AND company_code = $2
`;
const selectResult = await client.query(selectQuery, [id, companyCode]);
if (selectResult.rowCount === 0) {
await client.query("ROLLBACK");
return res.status(404).json({
success: false,
message: "분할 패널 설정을 찾을 수 없습니다.",
});
}
const { left_embedding_id, right_embedding_id, data_transfer_id } =
selectResult.rows[0];
// 2. 분할 패널 삭제
await client.query(
"DELETE FROM screen_split_panel WHERE id = $1 AND company_code = $2",
[id, companyCode]
);
// 3. 관련 임베딩 및 데이터 전달 설정 삭제 (CASCADE로 자동 삭제되지만 명시적으로)
if (left_embedding_id) {
await client.query(
"DELETE FROM screen_embedding WHERE id = $1 AND company_code = $2",
[left_embedding_id, companyCode]
);
}
if (right_embedding_id) {
await client.query(
"DELETE FROM screen_embedding WHERE id = $1 AND company_code = $2",
[right_embedding_id, companyCode]
);
}
if (data_transfer_id) {
await client.query(
"DELETE FROM screen_data_transfer WHERE id = $1 AND company_code = $2",
[data_transfer_id, companyCode]
);
}
await client.query("COMMIT");
logger.info("분할 패널 설정 삭제", { companyCode, id });
return res.json({
success: true,
message: "분할 패널 설정이 삭제되었습니다.",
});
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("분할 패널 설정 삭제 실패", error);
return res.status(500).json({
success: false,
message: "분할 패널 설정 삭제 중 오류가 발생했습니다.",
error: error.message,
});
} finally {
client.release();
}
}

View File

@@ -481,6 +481,52 @@ export const deleteColumnMapping = async (req: AuthenticatedRequest, res: Respon
}
};
/**
* 테이블+컬럼 기준으로 모든 매핑 삭제
*
* DELETE /api/categories/column-mapping/:tableName/:columnName
*
* 메뉴 선택 변경 시 기존 매핑을 모두 삭제하고 새로운 매핑만 추가하기 위해 사용
*/
export const deleteColumnMappingsByColumn = async (req: AuthenticatedRequest, res: Response) => {
try {
const companyCode = req.user!.companyCode;
const { tableName, columnName } = req.params;
if (!tableName || !columnName) {
return res.status(400).json({
success: false,
message: "tableName과 columnName은 필수입니다",
});
}
logger.info("테이블+컬럼 기준 매핑 삭제", {
tableName,
columnName,
companyCode,
});
const deletedCount = await tableCategoryValueService.deleteColumnMappingsByColumn(
tableName,
columnName,
companyCode
);
return res.json({
success: true,
message: `${deletedCount}개의 컬럼 매핑이 삭제되었습니다`,
deletedCount,
});
} catch (error: any) {
logger.error(`테이블+컬럼 기준 매핑 삭제 실패: ${error.message}`);
return res.status(500).json({
success: false,
message: error.message || "컬럼 매핑 삭제 중 오류가 발생했습니다",
error: error.message,
});
}
};
/**
* 2레벨 메뉴 목록 조회
*

View File

@@ -5,6 +5,7 @@ import {
saveFormDataEnhanced,
updateFormData,
updateFormDataPartial,
updateFieldValue,
deleteFormData,
getFormData,
getFormDataList,
@@ -23,6 +24,7 @@ router.post("/save", saveFormData); // 기존 버전 (레거시 지원)
router.post("/save-enhanced", saveFormDataEnhanced); // 개선된 버전
router.put("/:id", updateFormData);
router.patch("/:id/partial", updateFormDataPartial); // 부분 업데이트
router.put("/update-field", updateFieldValue); // 특정 필드만 업데이트 (다른 테이블 지원)
router.delete("/:id", deleteFormData);
router.get("/:id", getFormData);

View File

@@ -0,0 +1,80 @@
/**
* 화면 임베딩 및 데이터 전달 시스템 라우트
*/
import express from "express";
import {
// 화면 임베딩
getScreenEmbeddings,
getScreenEmbeddingById,
createScreenEmbedding,
updateScreenEmbedding,
deleteScreenEmbedding,
// 데이터 전달
getScreenDataTransfer,
createScreenDataTransfer,
updateScreenDataTransfer,
deleteScreenDataTransfer,
// 분할 패널
getScreenSplitPanel,
createScreenSplitPanel,
updateScreenSplitPanel,
deleteScreenSplitPanel,
} from "../controllers/screenEmbeddingController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = express.Router();
// ============================================
// 화면 임베딩 라우트
// ============================================
// 화면 임베딩 목록 조회
router.get("/screen-embedding", authenticateToken, getScreenEmbeddings);
// 화면 임베딩 상세 조회
router.get("/screen-embedding/:id", authenticateToken, getScreenEmbeddingById);
// 화면 임베딩 생성
router.post("/screen-embedding", authenticateToken, createScreenEmbedding);
// 화면 임베딩 수정
router.put("/screen-embedding/:id", authenticateToken, updateScreenEmbedding);
// 화면 임베딩 삭제
router.delete("/screen-embedding/:id", authenticateToken, deleteScreenEmbedding);
// ============================================
// 데이터 전달 라우트
// ============================================
// 데이터 전달 설정 조회
router.get("/screen-data-transfer", authenticateToken, getScreenDataTransfer);
// 데이터 전달 설정 생성
router.post("/screen-data-transfer", authenticateToken, createScreenDataTransfer);
// 데이터 전달 설정 수정
router.put("/screen-data-transfer/:id", authenticateToken, updateScreenDataTransfer);
// 데이터 전달 설정 삭제
router.delete("/screen-data-transfer/:id", authenticateToken, deleteScreenDataTransfer);
// ============================================
// 분할 패널 라우트
// ============================================
// 분할 패널 설정 조회
router.get("/screen-split-panel/:screenId", authenticateToken, getScreenSplitPanel);
// 분할 패널 설정 생성
router.post("/screen-split-panel", authenticateToken, createScreenSplitPanel);
// 분할 패널 설정 수정
router.put("/screen-split-panel/:id", authenticateToken, updateScreenSplitPanel);
// 분할 패널 설정 삭제
router.delete("/screen-split-panel/:id", authenticateToken, deleteScreenSplitPanel);
export default router;

View File

@@ -11,6 +11,7 @@ import {
createColumnMapping,
getLogicalColumns,
deleteColumnMapping,
deleteColumnMappingsByColumn,
getSecondLevelMenus,
} from "../controllers/tableCategoryValueController";
import { authenticateToken } from "../middleware/authMiddleware";
@@ -57,7 +58,11 @@ router.get("/logical-columns/:tableName/:menuObjid", getLogicalColumns);
// 컬럼 매핑 생성/수정
router.post("/column-mapping", createColumnMapping);
// 컬럼 매핑 삭제
// 테이블+컬럼 기준 매핑 삭제 (메뉴 선택 변경 시 기존 매핑 모두 삭제용)
// 주의: 더 구체적인 라우트가 먼저 와야 함 (3개 세그먼트 > 1개 세그먼트)
router.delete("/column-mapping/:tableName/:columnName/all", deleteColumnMappingsByColumn);
// 컬럼 매핑 삭제 (단일)
router.delete("/column-mapping/:mappingId", deleteColumnMapping);
export default router;

View File

@@ -1,4 +1,4 @@
import { query, queryOne, transaction } from "../database/db";
import { query, queryOne, transaction, getPool } from "../database/db";
import { EventTriggerService } from "./eventTriggerService";
import { DataflowControlService } from "./dataflowControlService";
@@ -1635,6 +1635,69 @@ export class DynamicFormService {
// 에러를 다시 던지지 않음 - 메인 저장 프로세스에 영향 주지 않기 위해
}
}
/**
* 특정 테이블의 특정 필드 값만 업데이트
* (다른 테이블의 레코드 업데이트 지원)
*/
async updateFieldValue(
tableName: string,
keyField: string,
keyValue: any,
updateField: string,
updateValue: any,
companyCode: string,
userId: string
): Promise<{ affectedRows: number }> {
const pool = getPool();
const client = await pool.connect();
try {
console.log("🔄 [updateFieldValue] 업데이트 실행:", {
tableName,
keyField,
keyValue,
updateField,
updateValue,
companyCode,
});
// 멀티테넌시: company_code 조건 추가 (최고관리자는 제외)
let whereClause = `"${keyField}" = $1`;
const params: any[] = [keyValue, updateValue, userId];
let paramIndex = 4;
if (companyCode && companyCode !== "*") {
whereClause += ` AND company_code = $${paramIndex}`;
params.push(companyCode);
paramIndex++;
}
const sqlQuery = `
UPDATE "${tableName}"
SET "${updateField}" = $2,
updated_by = $3,
updated_at = NOW()
WHERE ${whereClause}
`;
console.log("🔍 [updateFieldValue] 쿼리:", sqlQuery);
console.log("🔍 [updateFieldValue] 파라미터:", params);
const result = await client.query(sqlQuery, params);
console.log("✅ [updateFieldValue] 결과:", {
affectedRows: result.rowCount,
});
return { affectedRows: result.rowCount || 0 };
} catch (error) {
console.error("❌ [updateFieldValue] 오류:", error);
throw error;
} finally {
client.release();
}
}
}
// 싱글톤 인스턴스 생성 및 export

View File

@@ -10,10 +10,6 @@ export interface MenuCopyResult {
copiedMenus: number;
copiedScreens: number;
copiedFlows: number;
copiedCategories: number;
copiedCodes: number;
copiedCategorySettings: number;
copiedNumberingRules: number;
menuIdMap: Record<number, number>;
screenIdMap: Record<number, number>;
flowIdMap: Record<number, number>;
@@ -129,35 +125,6 @@ interface FlowStepConnection {
label: string | null;
}
/**
* 코드 카테고리
*/
interface CodeCategory {
category_code: string;
category_name: string;
category_name_eng: string | null;
description: string | null;
sort_order: number | null;
is_active: string;
company_code: string;
menu_objid: number;
}
/**
* 코드 정보
*/
interface CodeInfo {
code_category: string;
code_value: string;
code_name: string;
code_name_eng: string | null;
description: string | null;
sort_order: number | null;
is_active: string;
company_code: string;
menu_objid: number;
}
/**
* 메뉴 복사 서비스
*/
@@ -249,6 +216,24 @@ export class MenuCopyService {
}
}
}
// 3) 탭 컴포넌트 (tabs 배열 내부의 screenId)
if (
props?.componentConfig?.tabs &&
Array.isArray(props.componentConfig.tabs)
) {
for (const tab of props.componentConfig.tabs) {
if (tab.screenId) {
const screenId = tab.screenId;
const numId =
typeof screenId === "number" ? screenId : parseInt(screenId);
if (!isNaN(numId)) {
referenced.push(numId);
logger.debug(` 📑 탭 컴포넌트에서 화면 참조 발견: ${numId} (탭: ${tab.label || tab.id})`);
}
}
}
}
}
return referenced;
@@ -355,127 +340,6 @@ export class MenuCopyService {
return flowIds;
}
/**
* 코드 수집
*/
private async collectCodes(
menuObjids: number[],
sourceCompanyCode: string,
client: PoolClient
): Promise<{ categories: CodeCategory[]; codes: CodeInfo[] }> {
logger.info(`📋 코드 수집 시작: ${menuObjids.length}개 메뉴`);
const categories: CodeCategory[] = [];
const codes: CodeInfo[] = [];
for (const menuObjid of menuObjids) {
// 코드 카테고리
const catsResult = await client.query<CodeCategory>(
`SELECT * FROM code_category
WHERE menu_objid = $1 AND company_code = $2`,
[menuObjid, sourceCompanyCode]
);
categories.push(...catsResult.rows);
// 각 카테고리의 코드 정보
for (const cat of catsResult.rows) {
const codesResult = await client.query<CodeInfo>(
`SELECT * FROM code_info
WHERE code_category = $1 AND menu_objid = $2 AND company_code = $3`,
[cat.category_code, menuObjid, sourceCompanyCode]
);
codes.push(...codesResult.rows);
}
}
logger.info(
`✅ 코드 수집 완료: 카테고리 ${categories.length}개, 코드 ${codes.length}`
);
return { categories, codes };
}
/**
* 카테고리 설정 수집
*/
private async collectCategorySettings(
menuObjids: number[],
sourceCompanyCode: string,
client: PoolClient
): Promise<{
columnMappings: any[];
categoryValues: any[];
}> {
logger.info(`📂 카테고리 설정 수집 시작: ${menuObjids.length}개 메뉴`);
const columnMappings: any[] = [];
const categoryValues: any[] = [];
// 카테고리 컬럼 매핑 (메뉴별 + 공통)
const mappingsResult = await client.query(
`SELECT * FROM category_column_mapping
WHERE (menu_objid = ANY($1) OR menu_objid = 0)
AND company_code = $2`,
[menuObjids, sourceCompanyCode]
);
columnMappings.push(...mappingsResult.rows);
// 테이블 컬럼 카테고리 값 (메뉴별 + 공통)
const valuesResult = await client.query(
`SELECT * FROM table_column_category_values
WHERE (menu_objid = ANY($1) OR menu_objid = 0)
AND company_code = $2`,
[menuObjids, sourceCompanyCode]
);
categoryValues.push(...valuesResult.rows);
logger.info(
`✅ 카테고리 설정 수집 완료: 컬럼 매핑 ${columnMappings.length}개 (공통 포함), 카테고리 값 ${categoryValues.length}개 (공통 포함)`
);
return { columnMappings, categoryValues };
}
/**
* 채번 규칙 수집
*/
private async collectNumberingRules(
menuObjids: number[],
sourceCompanyCode: string,
client: PoolClient
): Promise<{
rules: any[];
parts: any[];
}> {
logger.info(`📋 채번 규칙 수집 시작: ${menuObjids.length}개 메뉴`);
const rules: any[] = [];
const parts: any[] = [];
for (const menuObjid of menuObjids) {
// 채번 규칙
const rulesResult = await client.query(
`SELECT * FROM numbering_rules
WHERE menu_objid = $1 AND company_code = $2`,
[menuObjid, sourceCompanyCode]
);
rules.push(...rulesResult.rows);
// 각 규칙의 파트
for (const rule of rulesResult.rows) {
const partsResult = await client.query(
`SELECT * FROM numbering_rule_parts
WHERE rule_id = $1 AND company_code = $2`,
[rule.rule_id, sourceCompanyCode]
);
parts.push(...partsResult.rows);
}
}
logger.info(
`✅ 채번 규칙 수집 완료: 규칙 ${rules.length}개, 파트 ${parts.length}`
);
return { rules, parts };
}
/**
* 다음 메뉴 objid 생성
*/
@@ -709,42 +573,8 @@ export class MenuCopyService {
]);
logger.info(` ✅ 메뉴 권한 삭제 완료`);
// 5-5. 채번 규칙 파트 삭제
await client.query(
`DELETE FROM numbering_rule_parts
WHERE rule_id IN (
SELECT rule_id FROM numbering_rules
WHERE menu_objid = ANY($1) AND company_code = $2
)`,
[existingMenuIds, targetCompanyCode]
);
logger.info(` ✅ 채번 규칙 파트 삭제 완료`);
// 5-6. 채번 규칙 삭제
await client.query(
`DELETE FROM numbering_rules
WHERE menu_objid = ANY($1) AND company_code = $2`,
[existingMenuIds, targetCompanyCode]
);
logger.info(` ✅ 채번 규칙 삭제 완료`);
// 5-7. 테이블 컬럼 카테고리 값 삭제
await client.query(
`DELETE FROM table_column_category_values
WHERE menu_objid = ANY($1) AND company_code = $2`,
[existingMenuIds, targetCompanyCode]
);
logger.info(` ✅ 카테고리 값 삭제 완료`);
// 5-8. 카테고리 컬럼 매핑 삭제
await client.query(
`DELETE FROM category_column_mapping
WHERE menu_objid = ANY($1) AND company_code = $2`,
[existingMenuIds, targetCompanyCode]
);
logger.info(` ✅ 카테고리 매핑 삭제 완료`);
// 5-9. 메뉴 삭제 (역순: 하위 메뉴부터)
// 5-5. 메뉴 삭제 (역순: 하위 메뉴부터)
// 주의: 채번 규칙과 카테고리 설정은 회사마다 고유하므로 삭제하지 않음
for (let i = existingMenus.length - 1; i >= 0; i--) {
await client.query(`DELETE FROM menu_info WHERE objid = $1`, [
existingMenus[i].objid,
@@ -801,33 +631,11 @@ export class MenuCopyService {
const flowIds = await this.collectFlows(screenIds, client);
const codes = await this.collectCodes(
menus.map((m) => m.objid),
sourceCompanyCode,
client
);
const categorySettings = await this.collectCategorySettings(
menus.map((m) => m.objid),
sourceCompanyCode,
client
);
const numberingRules = await this.collectNumberingRules(
menus.map((m) => m.objid),
sourceCompanyCode,
client
);
logger.info(`
📊 수집 완료:
- 메뉴: ${menus.length}
- 화면: ${screenIds.size}
- 플로우: ${flowIds.size}
- 코드 카테고리: ${codes.categories.length}
- 코드: ${codes.codes.length}
- 카테고리 설정: 컬럼 매핑 ${categorySettings.columnMappings.length}개, 카테고리 값 ${categorySettings.categoryValues.length}
- 채번 규칙: 규칙 ${numberingRules.rules.length}개, 파트 ${numberingRules.parts.length}
`);
// === 2단계: 플로우 복사 ===
@@ -871,30 +679,6 @@ export class MenuCopyService {
client
);
// === 6단계: 코드 복사 ===
logger.info("\n📋 [6단계] 코드 복사");
await this.copyCodes(codes, menuIdMap, targetCompanyCode, userId, client);
// === 7단계: 카테고리 설정 복사 ===
logger.info("\n📂 [7단계] 카테고리 설정 복사");
await this.copyCategorySettings(
categorySettings,
menuIdMap,
targetCompanyCode,
userId,
client
);
// === 8단계: 채번 규칙 복사 ===
logger.info("\n📋 [8단계] 채번 규칙 복사");
await this.copyNumberingRules(
numberingRules,
menuIdMap,
targetCompanyCode,
userId,
client
);
// 커밋
await client.query("COMMIT");
logger.info("✅ 트랜잭션 커밋 완료");
@@ -904,13 +688,6 @@ export class MenuCopyService {
copiedMenus: menuIdMap.size,
copiedScreens: screenIdMap.size,
copiedFlows: flowIdMap.size,
copiedCategories: codes.categories.length,
copiedCodes: codes.codes.length,
copiedCategorySettings:
categorySettings.columnMappings.length +
categorySettings.categoryValues.length,
copiedNumberingRules:
numberingRules.rules.length + numberingRules.parts.length,
menuIdMap: Object.fromEntries(menuIdMap),
screenIdMap: Object.fromEntries(screenIdMap),
flowIdMap: Object.fromEntries(flowIdMap),
@@ -923,10 +700,8 @@ export class MenuCopyService {
- 메뉴: ${result.copiedMenus}
- 화면: ${result.copiedScreens}
- 플로우: ${result.copiedFlows}
- 코드 카테고리: ${result.copiedCategories}
- 코드: ${result.copiedCodes}
- 카테고리 설정: ${result.copiedCategorySettings}
- 채번 규칙: ${result.copiedNumberingRules}
⚠️ 주의: 코드, 카테고리 설정, 채번 규칙은 복사되지 않습니다.
============================================
`);
@@ -1125,13 +900,31 @@ export class MenuCopyService {
const screenDef = screenDefResult.rows[0];
// 2) screen_code 생성
// 2) 중복 체크: 같은 screen_code가 대상 회사에 이미 있는지 확인
const existingScreenResult = await client.query<{ screen_id: number }>(
`SELECT screen_id FROM screen_definitions
WHERE screen_code = $1 AND company_code = $2 AND deleted_date IS NULL
LIMIT 1`,
[screenDef.screen_code, targetCompanyCode]
);
if (existingScreenResult.rows.length > 0) {
// 이미 존재하는 화면 - 복사하지 않고 기존 ID 매핑
const existingScreenId = existingScreenResult.rows[0].screen_id;
screenIdMap.set(originalScreenId, existingScreenId);
logger.info(
` ⏭️ 화면 이미 존재 (스킵): ${originalScreenId}${existingScreenId} (${screenDef.screen_code})`
);
continue; // 레이아웃 복사도 스킵
}
// 3) 새 screen_code 생성
const newScreenCode = await this.generateUniqueScreenCode(
targetCompanyCode,
client
);
// 2-1) 화면명 변환 적용
// 4) 화면명 변환 적용
let transformedScreenName = screenDef.screen_name;
if (screenNameConfig) {
// 1. 제거할 텍스트 제거
@@ -1150,7 +943,7 @@ export class MenuCopyService {
}
}
// 3) screen_definitions 복사 (deleted 필드는 NULL로 설정, 삭제된 화면도 활성화)
// 5) screen_definitions 복사 (deleted 필드는 NULL로 설정, 삭제된 화면도 활성화)
const newScreenResult = await client.query<{ screen_id: number }>(
`INSERT INTO screen_definitions (
screen_name, screen_code, table_name, company_code,
@@ -1479,383 +1272,4 @@ export class MenuCopyService {
logger.info(`✅ 화면-메뉴 할당 완료: ${assignmentCount}`);
}
/**
* 코드 카테고리 중복 체크
*/
private async checkCodeCategoryExists(
categoryCode: string,
companyCode: string,
menuObjid: number,
client: PoolClient
): Promise<boolean> {
const result = await client.query<{ exists: boolean }>(
`SELECT EXISTS(
SELECT 1 FROM code_category
WHERE category_code = $1 AND company_code = $2 AND menu_objid = $3
) as exists`,
[categoryCode, companyCode, menuObjid]
);
return result.rows[0].exists;
}
/**
* 코드 정보 중복 체크
*/
private async checkCodeInfoExists(
categoryCode: string,
codeValue: string,
companyCode: string,
menuObjid: number,
client: PoolClient
): Promise<boolean> {
const result = await client.query<{ exists: boolean }>(
`SELECT EXISTS(
SELECT 1 FROM code_info
WHERE code_category = $1 AND code_value = $2
AND company_code = $3 AND menu_objid = $4
) as exists`,
[categoryCode, codeValue, companyCode, menuObjid]
);
return result.rows[0].exists;
}
/**
* 코드 복사
*/
private async copyCodes(
codes: { categories: CodeCategory[]; codes: CodeInfo[] },
menuIdMap: Map<number, number>,
targetCompanyCode: string,
userId: string,
client: PoolClient
): Promise<void> {
logger.info(`📋 코드 복사 중...`);
let categoryCount = 0;
let codeCount = 0;
let skippedCategories = 0;
let skippedCodes = 0;
// 1) 코드 카테고리 복사 (중복 체크)
for (const category of codes.categories) {
const newMenuObjid = menuIdMap.get(category.menu_objid);
if (!newMenuObjid) continue;
// 중복 체크
const exists = await this.checkCodeCategoryExists(
category.category_code,
targetCompanyCode,
newMenuObjid,
client
);
if (exists) {
skippedCategories++;
logger.debug(
` ⏭️ 카테고리 이미 존재: ${category.category_code} (menu_objid=${newMenuObjid})`
);
continue;
}
// 카테고리 복사
await client.query(
`INSERT INTO code_category (
category_code, category_name, category_name_eng, description,
sort_order, is_active, company_code, menu_objid, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
category.category_code,
category.category_name,
category.category_name_eng,
category.description,
category.sort_order,
category.is_active,
targetCompanyCode, // 새 회사 코드
newMenuObjid, // 재매핑
userId,
]
);
categoryCount++;
}
// 2) 코드 정보 복사 (중복 체크)
for (const code of codes.codes) {
const newMenuObjid = menuIdMap.get(code.menu_objid);
if (!newMenuObjid) continue;
// 중복 체크
const exists = await this.checkCodeInfoExists(
code.code_category,
code.code_value,
targetCompanyCode,
newMenuObjid,
client
);
if (exists) {
skippedCodes++;
logger.debug(
` ⏭️ 코드 이미 존재: ${code.code_category}.${code.code_value} (menu_objid=${newMenuObjid})`
);
continue;
}
// 코드 복사
await client.query(
`INSERT INTO code_info (
code_category, code_value, code_name, code_name_eng, description,
sort_order, is_active, company_code, menu_objid, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[
code.code_category,
code.code_value,
code.code_name,
code.code_name_eng,
code.description,
code.sort_order,
code.is_active,
targetCompanyCode, // 새 회사 코드
newMenuObjid, // 재매핑
userId,
]
);
codeCount++;
}
logger.info(
`✅ 코드 복사 완료: 카테고리 ${categoryCount}개 (${skippedCategories}개 스킵), 코드 ${codeCount}개 (${skippedCodes}개 스킵)`
);
}
/**
* 카테고리 설정 복사
*/
private async copyCategorySettings(
settings: { columnMappings: any[]; categoryValues: any[] },
menuIdMap: Map<number, number>,
targetCompanyCode: string,
userId: string,
client: PoolClient
): Promise<void> {
logger.info(`📂 카테고리 설정 복사 중...`);
const valueIdMap = new Map<number, number>(); // 원본 value_id → 새 value_id
let mappingCount = 0;
let valueCount = 0;
// 1) 카테고리 컬럼 매핑 복사 (덮어쓰기 모드)
for (const mapping of settings.columnMappings) {
// menu_objid = 0인 공통 설정은 그대로 0으로 유지
let newMenuObjid: number | undefined;
if (
mapping.menu_objid === 0 ||
mapping.menu_objid === "0" ||
mapping.menu_objid == 0
) {
newMenuObjid = 0; // 공통 설정
} else {
newMenuObjid = menuIdMap.get(mapping.menu_objid);
if (newMenuObjid === undefined) {
logger.debug(
` ⏭️ 매핑할 메뉴가 없음: menu_objid=${mapping.menu_objid}`
);
continue;
}
}
// 기존 매핑 삭제 (덮어쓰기)
await client.query(
`DELETE FROM category_column_mapping
WHERE table_name = $1 AND physical_column_name = $2 AND company_code = $3`,
[mapping.table_name, mapping.physical_column_name, targetCompanyCode]
);
// 새 매핑 추가
await client.query(
`INSERT INTO category_column_mapping (
table_name, logical_column_name, physical_column_name,
menu_objid, company_code, description, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
mapping.table_name,
mapping.logical_column_name,
mapping.physical_column_name,
newMenuObjid,
targetCompanyCode,
mapping.description,
userId,
]
);
mappingCount++;
}
// 2) 테이블 컬럼 카테고리 값 복사 (덮어쓰기 모드, 부모-자식 관계 유지)
const sortedValues = settings.categoryValues.sort(
(a, b) => a.depth - b.depth
);
// 먼저 기존 값들을 모두 삭제 (테이블+컬럼 단위)
const uniqueTableColumns = new Set<string>();
for (const value of sortedValues) {
uniqueTableColumns.add(`${value.table_name}:${value.column_name}`);
}
for (const tableColumn of uniqueTableColumns) {
const [tableName, columnName] = tableColumn.split(":");
await client.query(
`DELETE FROM table_column_category_values
WHERE table_name = $1 AND column_name = $2 AND company_code = $3`,
[tableName, columnName, targetCompanyCode]
);
logger.debug(` 🗑️ 기존 카테고리 값 삭제: ${tableName}.${columnName}`);
}
// 새 값 추가
for (const value of sortedValues) {
// menu_objid = 0인 공통 설정은 그대로 0으로 유지
let newMenuObjid: number | undefined;
if (
value.menu_objid === 0 ||
value.menu_objid === "0" ||
value.menu_objid == 0
) {
newMenuObjid = 0; // 공통 설정
} else {
newMenuObjid = menuIdMap.get(value.menu_objid);
if (newMenuObjid === undefined) {
logger.debug(
` ⏭️ 매핑할 메뉴가 없음: menu_objid=${value.menu_objid}`
);
continue;
}
}
// 부모 ID 재매핑
let newParentValueId = null;
if (value.parent_value_id) {
newParentValueId = valueIdMap.get(value.parent_value_id) || null;
}
const result = await client.query(
`INSERT INTO table_column_category_values (
table_name, column_name, value_code, value_label,
value_order, parent_value_id, depth, description,
color, icon, is_active, is_default,
company_code, menu_objid, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
RETURNING value_id`,
[
value.table_name,
value.column_name,
value.value_code,
value.value_label,
value.value_order,
newParentValueId,
value.depth,
value.description,
value.color,
value.icon,
value.is_active,
value.is_default,
targetCompanyCode,
newMenuObjid,
userId,
]
);
// ID 매핑 저장
const newValueId = result.rows[0].value_id;
valueIdMap.set(value.value_id, newValueId);
valueCount++;
}
logger.info(
`✅ 카테고리 설정 복사 완료: 컬럼 매핑 ${mappingCount}개, 카테고리 값 ${valueCount}개 (덮어쓰기)`
);
}
/**
* 채번 규칙 복사
*/
private async copyNumberingRules(
rules: { rules: any[]; parts: any[] },
menuIdMap: Map<number, number>,
targetCompanyCode: string,
userId: string,
client: PoolClient
): Promise<void> {
logger.info(`📋 채번 규칙 복사 중...`);
const ruleIdMap = new Map<string, string>(); // 원본 rule_id → 새 rule_id
let ruleCount = 0;
let partCount = 0;
// 1) 채번 규칙 복사
for (const rule of rules.rules) {
const newMenuObjid = menuIdMap.get(rule.menu_objid);
if (!newMenuObjid) continue;
// 새 rule_id 생성 (타임스탬프 기반)
const newRuleId = `rule-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
ruleIdMap.set(rule.rule_id, newRuleId);
await client.query(
`INSERT INTO numbering_rules (
rule_id, rule_name, description, separator,
reset_period, current_sequence, table_name, column_name,
company_code, menu_objid, created_by, scope_type
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
[
newRuleId,
rule.rule_name,
rule.description,
rule.separator,
rule.reset_period,
1, // 시퀀스 초기화
rule.table_name,
rule.column_name,
targetCompanyCode,
newMenuObjid,
userId,
rule.scope_type,
]
);
ruleCount++;
}
// 2) 채번 규칙 파트 복사
for (const part of rules.parts) {
const newRuleId = ruleIdMap.get(part.rule_id);
if (!newRuleId) continue;
await client.query(
`INSERT INTO numbering_rule_parts (
rule_id, part_order, part_type, generation_method,
auto_config, manual_config, company_code
) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
newRuleId,
part.part_order,
part.part_type,
part.generation_method,
part.auto_config,
part.manual_config,
targetCompanyCode,
]
);
partCount++;
}
logger.info(
`✅ 채번 규칙 복사 완료: 규칙 ${ruleCount}개, 파트 ${partCount}`
);
}
}

View File

@@ -102,6 +102,72 @@ export async function getSiblingMenuObjids(menuObjid: number): Promise<number[]>
}
}
/**
* 선택한 메뉴와 그 하위 메뉴들의 OBJID 조회
*
* 형제 메뉴는 포함하지 않고, 선택한 메뉴와 그 자식 메뉴들만 반환합니다.
* 채번 규칙 필터링 등 특정 메뉴 계층만 필요할 때 사용합니다.
*
* @param menuObjid 메뉴 OBJID
* @returns 선택한 메뉴 + 모든 하위 메뉴 OBJID 배열 (재귀적)
*
* @example
* // 메뉴 구조:
* // └── 구매관리 (100)
* // ├── 공급업체관리 (101)
* // ├── 발주관리 (102)
* // └── 입고관리 (103)
* // └── 입고상세 (104)
*
* await getMenuAndChildObjids(100);
* // 결과: [100, 101, 102, 103, 104]
*/
export async function getMenuAndChildObjids(menuObjid: number): Promise<number[]> {
const pool = getPool();
try {
logger.debug("메뉴 및 하위 메뉴 조회 시작", { menuObjid });
// 재귀 CTE를 사용하여 선택한 메뉴와 모든 하위 메뉴 조회
const query = `
WITH RECURSIVE menu_tree AS (
-- 시작점: 선택한 메뉴
SELECT objid, parent_obj_id, 1 AS depth
FROM menu_info
WHERE objid = $1
UNION ALL
-- 재귀: 하위 메뉴들
SELECT m.objid, m.parent_obj_id, mt.depth + 1
FROM menu_info m
INNER JOIN menu_tree mt ON m.parent_obj_id = mt.objid
WHERE mt.depth < 10 -- 무한 루프 방지
)
SELECT objid FROM menu_tree ORDER BY depth, objid
`;
const result = await pool.query(query, [menuObjid]);
const objids = result.rows.map((row) => Number(row.objid));
logger.debug("메뉴 및 하위 메뉴 조회 완료", {
menuObjid,
totalCount: objids.length,
objids
});
return objids;
} catch (error: any) {
logger.error("메뉴 및 하위 메뉴 조회 실패", {
menuObjid,
error: error.message,
stack: error.stack
});
// 에러 발생 시 안전하게 자기 자신만 반환
return [menuObjid];
}
}
/**
* 여러 메뉴의 형제 메뉴 OBJID 합집합 조회
*

View File

@@ -4,7 +4,7 @@
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
import { getSiblingMenuObjids } from "./menuService";
import { getMenuAndChildObjids } from "./menuService";
interface NumberingRulePart {
id?: number;
@@ -161,7 +161,7 @@ class NumberingRuleService {
companyCode: string,
menuObjid?: number
): Promise<NumberingRuleConfig[]> {
let siblingObjids: number[] = []; // catch 블록에서 접근 가능하도록 함수 최상단에 선언
let menuAndChildObjids: number[] = []; // catch 블록에서 접근 가능하도록 함수 최상단에 선언
try {
logger.info("메뉴별 사용 가능한 채번 규칙 조회 시작 (메뉴 스코프)", {
@@ -171,14 +171,14 @@ class NumberingRuleService {
const pool = getPool();
// 1. 형제 메뉴 OBJID 조회
// 1. 선택한 메뉴와 하위 메뉴 OBJID 조회 (형제 메뉴 제외)
if (menuObjid) {
siblingObjids = await getSiblingMenuObjids(menuObjid);
logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids });
menuAndChildObjids = await getMenuAndChildObjids(menuObjid);
logger.info("선택한 메뉴 및 하위 메뉴 OBJID 목록", { menuObjid, menuAndChildObjids });
}
// menuObjid가 없으면 global 규칙만 반환
if (!menuObjid || siblingObjids.length === 0) {
if (!menuObjid || menuAndChildObjids.length === 0) {
let query: string;
let params: any[];
@@ -280,7 +280,7 @@ class NumberingRuleService {
let params: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 규칙 조회 (형제 메뉴 포함)
// 최고 관리자: 모든 규칙 조회 (선택한 메뉴 + 하위 메뉴)
query = `
SELECT
rule_id AS "ruleId",
@@ -301,8 +301,7 @@ class NumberingRuleService {
WHERE
scope_type = 'global'
OR (scope_type = 'menu' AND menu_objid = ANY($1))
OR (scope_type = 'table' AND menu_objid = ANY($1)) -- ✅ 메뉴별로 필터링
OR (scope_type = 'table' AND menu_objid IS NULL) -- ✅ 기존 규칙(menu_objid NULL) 포함 (하위 호환성)
OR (scope_type = 'table' AND menu_objid = ANY($1))
ORDER BY
CASE
WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($1)) THEN 1
@@ -311,10 +310,10 @@ class NumberingRuleService {
END,
created_at DESC
`;
params = [siblingObjids];
logger.info("최고 관리자: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { siblingObjids });
params = [menuAndChildObjids];
logger.info("최고 관리자: 메뉴 및 하위 메뉴 기반 채번 규칙 조회", { menuAndChildObjids });
} else {
// 일반 회사: 자신의 규칙만 조회 (형제 메뉴 포함, 메뉴별 필터링)
// 일반 회사: 자신의 규칙만 조회 (선택한 메뉴 + 하위 메뉴)
query = `
SELECT
rule_id AS "ruleId",
@@ -336,8 +335,7 @@ class NumberingRuleService {
AND (
scope_type = 'global'
OR (scope_type = 'menu' AND menu_objid = ANY($2))
OR (scope_type = 'table' AND menu_objid = ANY($2)) -- ✅ 메뉴별로 필터링
OR (scope_type = 'table' AND menu_objid IS NULL) -- ✅ 기존 규칙(menu_objid NULL) 포함 (하위 호환성)
OR (scope_type = 'table' AND menu_objid = ANY($2))
)
ORDER BY
CASE
@@ -347,8 +345,8 @@ class NumberingRuleService {
END,
created_at DESC
`;
params = [companyCode, siblingObjids];
logger.info("회사별: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { companyCode, siblingObjids });
params = [companyCode, menuAndChildObjids];
logger.info("회사별: 메뉴 및 하위 메뉴 기반 채번 규칙 조회", { companyCode, menuAndChildObjids });
}
logger.info("🔍 채번 규칙 쿼리 실행", {
@@ -420,7 +418,7 @@ class NumberingRuleService {
logger.info("메뉴별 사용 가능한 채번 규칙 조회 완료", {
companyCode,
menuObjid,
siblingCount: siblingObjids.length,
menuAndChildCount: menuAndChildObjids.length,
count: result.rowCount,
});
@@ -432,7 +430,7 @@ class NumberingRuleService {
errorStack: error.stack,
companyCode,
menuObjid,
siblingObjids: siblingObjids || [],
menuAndChildObjids: menuAndChildObjids || [],
});
throw error;
}

View File

@@ -1066,6 +1066,66 @@ class TableCategoryValueService {
}
}
/**
* 테이블+컬럼 기준으로 모든 매핑 삭제
*
* 메뉴 선택 변경 시 기존 매핑을 모두 삭제하고 새로운 매핑만 추가하기 위해 사용
*
* @param tableName - 테이블명
* @param columnName - 컬럼명
* @param companyCode - 회사 코드
* @returns 삭제된 매핑 수
*/
async deleteColumnMappingsByColumn(
tableName: string,
columnName: string,
companyCode: string
): Promise<number> {
const pool = getPool();
try {
logger.info("테이블+컬럼 기준 매핑 삭제", { tableName, columnName, companyCode });
// 멀티테넌시 적용
let deleteQuery: string;
let deleteParams: any[];
if (companyCode === "*") {
// 최고 관리자: 해당 테이블+컬럼의 모든 매핑 삭제
deleteQuery = `
DELETE FROM category_column_mapping
WHERE table_name = $1
AND logical_column_name = $2
`;
deleteParams = [tableName, columnName];
} else {
// 일반 회사: 자신의 매핑만 삭제
deleteQuery = `
DELETE FROM category_column_mapping
WHERE table_name = $1
AND logical_column_name = $2
AND company_code = $3
`;
deleteParams = [tableName, columnName, companyCode];
}
const result = await pool.query(deleteQuery, deleteParams);
const deletedCount = result.rowCount || 0;
logger.info("테이블+컬럼 기준 매핑 삭제 완료", {
tableName,
columnName,
companyCode,
deletedCount
});
return deletedCount;
} catch (error: any) {
logger.error(`테이블+컬럼 기준 매핑 삭제 실패: ${error.message}`);
throw error;
}
}
/**
* 논리적 컬럼명을 물리적 컬럼명으로 변환
*