Merge remote-tracking branch 'upstream/main'
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import "dotenv/config";
|
||||
import "express-async-errors"; // async 라우트 핸들러의 에러를 Express 에러 핸들러로 자동 전달
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import helmet from "helmet";
|
||||
|
||||
@@ -19,8 +19,6 @@ export async function getAdminMenus(
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
logger.info("=== 관리자 메뉴 목록 조회 시작 ===");
|
||||
|
||||
// 현재 로그인한 사용자의 정보 가져오기
|
||||
const userId = req.user?.userId;
|
||||
const userCompanyCode = req.user?.companyCode || "ILSHIN";
|
||||
@@ -29,13 +27,6 @@ export async function getAdminMenus(
|
||||
const menuType = req.query.menuType as string | undefined; // menuType 파라미터 추가
|
||||
const includeInactive = req.query.includeInactive === "true"; // includeInactive 파라미터 추가
|
||||
|
||||
logger.info(`사용자 ID: ${userId}`);
|
||||
logger.info(`사용자 회사 코드: ${userCompanyCode}`);
|
||||
logger.info(`사용자 유형: ${userType}`);
|
||||
logger.info(`사용자 로케일: ${userLang}`);
|
||||
logger.info(`메뉴 타입: ${menuType || "전체"}`);
|
||||
logger.info(`비활성 메뉴 포함: ${includeInactive}`);
|
||||
|
||||
const paramMap = {
|
||||
userId,
|
||||
userCompanyCode,
|
||||
@@ -47,13 +38,6 @@ export async function getAdminMenus(
|
||||
|
||||
const menuList = await AdminService.getAdminMenuList(paramMap);
|
||||
|
||||
logger.info(
|
||||
`관리자 메뉴 조회 결과: ${menuList.length}개 (타입: ${menuType || "전체"}, 회사: ${userCompanyCode})`
|
||||
);
|
||||
if (menuList.length > 0) {
|
||||
logger.info("첫 번째 메뉴:", menuList[0]);
|
||||
}
|
||||
|
||||
const response: ApiResponse<any[]> = {
|
||||
success: true,
|
||||
message: "관리자 메뉴 목록 조회 성공",
|
||||
@@ -85,19 +69,12 @@ export async function getUserMenus(
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
logger.info("=== 사용자 메뉴 목록 조회 시작 ===");
|
||||
|
||||
// 현재 로그인한 사용자의 정보 가져오기
|
||||
const userId = req.user?.userId;
|
||||
const userCompanyCode = req.user?.companyCode || "ILSHIN";
|
||||
const userType = req.user?.userType;
|
||||
const userLang = (req.query.userLang as string) || "ko";
|
||||
|
||||
logger.info(`사용자 ID: ${userId}`);
|
||||
logger.info(`사용자 회사 코드: ${userCompanyCode}`);
|
||||
logger.info(`사용자 유형: ${userType}`);
|
||||
logger.info(`사용자 로케일: ${userLang}`);
|
||||
|
||||
const paramMap = {
|
||||
userId,
|
||||
userCompanyCode,
|
||||
@@ -107,13 +84,6 @@ export async function getUserMenus(
|
||||
|
||||
const menuList = await AdminService.getUserMenuList(paramMap);
|
||||
|
||||
logger.info(
|
||||
`사용자 메뉴 조회 결과: ${menuList.length}개 (회사: ${userCompanyCode})`
|
||||
);
|
||||
if (menuList.length > 0) {
|
||||
logger.info("첫 번째 메뉴:", menuList[0]);
|
||||
}
|
||||
|
||||
const response: ApiResponse<any[]> = {
|
||||
success: true,
|
||||
message: "사용자 메뉴 목록 조회 성공",
|
||||
@@ -473,7 +443,7 @@ export const getUserLocale = async (
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
logger.info("사용자 로케일 조회 요청", {
|
||||
logger.debug("사용자 로케일 조회 요청", {
|
||||
query: req.query,
|
||||
user: req.user,
|
||||
});
|
||||
@@ -496,7 +466,7 @@ export const getUserLocale = async (
|
||||
|
||||
if (userInfo?.locale) {
|
||||
userLocale = userInfo.locale;
|
||||
logger.info("데이터베이스에서 사용자 로케일 조회 성공", {
|
||||
logger.debug("데이터베이스에서 사용자 로케일 조회 성공", {
|
||||
userId: req.user.userId,
|
||||
locale: userLocale,
|
||||
});
|
||||
@@ -513,7 +483,7 @@ export const getUserLocale = async (
|
||||
message: "사용자 로케일 조회 성공",
|
||||
};
|
||||
|
||||
logger.info("사용자 로케일 조회 성공", {
|
||||
logger.debug("사용자 로케일 조회 성공", {
|
||||
userLocale,
|
||||
userId: req.user.userId,
|
||||
fromDatabase: !!userInfo?.locale,
|
||||
@@ -618,7 +588,7 @@ export const getCompanyList = async (
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
logger.info("회사 목록 조회 요청", {
|
||||
logger.debug("회사 목록 조회 요청", {
|
||||
query: req.query,
|
||||
user: req.user,
|
||||
});
|
||||
@@ -658,12 +628,8 @@ export const getCompanyList = async (
|
||||
message: "회사 목록 조회 성공",
|
||||
};
|
||||
|
||||
logger.info("회사 목록 조회 성공", {
|
||||
logger.debug("회사 목록 조회 성공", {
|
||||
totalCount: companies.length,
|
||||
companies: companies.map((c) => ({
|
||||
code: c.company_code,
|
||||
name: c.company_name,
|
||||
})),
|
||||
});
|
||||
|
||||
res.status(200).json(response);
|
||||
@@ -1443,13 +1409,7 @@ async function collectAllChildMenuIds(parentObjid: number): Promise<number[]> {
|
||||
* 메뉴 및 관련 데이터 정리 헬퍼 함수
|
||||
*/
|
||||
async function cleanupMenuRelatedData(menuObjid: number): Promise<void> {
|
||||
// 1. category_column_mapping에서 menu_objid를 NULL로 설정
|
||||
await query(
|
||||
`UPDATE category_column_mapping SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||
[menuObjid]
|
||||
);
|
||||
|
||||
// 2. code_category에서 menu_objid를 NULL로 설정
|
||||
// 1. code_category에서 menu_objid를 NULL로 설정
|
||||
await query(
|
||||
`UPDATE code_category SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||
[menuObjid]
|
||||
@@ -1870,7 +1830,7 @@ export async function getCompanyListFromDB(
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
logger.info("회사 목록 조회 요청 (Raw Query)", { user: req.user });
|
||||
logger.debug("회사 목록 조회 요청 (Raw Query)", { user: req.user });
|
||||
|
||||
// Raw Query로 회사 목록 조회
|
||||
const companies = await query<any>(
|
||||
@@ -1890,7 +1850,7 @@ export async function getCompanyListFromDB(
|
||||
ORDER BY regdate DESC`
|
||||
);
|
||||
|
||||
logger.info("회사 목록 조회 성공 (Raw Query)", { count: companies.length });
|
||||
logger.debug("회사 목록 조회 성공 (Raw Query)", { count: companies.length });
|
||||
|
||||
const response: ApiResponse<any> = {
|
||||
success: true,
|
||||
|
||||
@@ -17,9 +17,7 @@ export class AuthController {
|
||||
const { userId, password }: LoginRequest = req.body;
|
||||
const remoteAddr = req.ip || req.connection.remoteAddress || "unknown";
|
||||
|
||||
logger.info(`=== API 로그인 호출됨 ===`);
|
||||
logger.info(`userId: ${userId}`);
|
||||
logger.info(`password: ${password ? "***" : "null"}`);
|
||||
logger.debug(`로그인 요청: ${userId}`);
|
||||
|
||||
// 입력값 검증
|
||||
if (!userId || !password) {
|
||||
@@ -50,14 +48,7 @@ export class AuthController {
|
||||
companyCode: loginResult.userInfo.companyCode || "ILSHIN",
|
||||
};
|
||||
|
||||
logger.info(`=== API 로그인 사용자 정보 디버그 ===`);
|
||||
logger.info(
|
||||
`PersonBean companyCode: ${loginResult.userInfo.companyCode}`
|
||||
);
|
||||
logger.info(`반환할 사용자 정보:`);
|
||||
logger.info(`- userId: ${userInfo.userId}`);
|
||||
logger.info(`- userName: ${userInfo.userName}`);
|
||||
logger.info(`- companyCode: ${userInfo.companyCode}`);
|
||||
logger.debug(`로그인 사용자 정보: ${userInfo.userId} (${userInfo.companyCode})`);
|
||||
|
||||
// 사용자의 첫 번째 접근 가능한 메뉴 조회
|
||||
let firstMenuPath: string | null = null;
|
||||
@@ -71,7 +62,7 @@ export class AuthController {
|
||||
};
|
||||
|
||||
const menuList = await AdminService.getUserMenuList(paramMap);
|
||||
logger.info(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`);
|
||||
logger.debug(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`);
|
||||
|
||||
// 접근 가능한 첫 번째 메뉴 찾기
|
||||
// 조건:
|
||||
@@ -87,16 +78,9 @@ export class AuthController {
|
||||
|
||||
if (firstMenu) {
|
||||
firstMenuPath = firstMenu.menu_url || firstMenu.url;
|
||||
logger.info(`✅ 첫 번째 접근 가능한 메뉴 발견:`, {
|
||||
name: firstMenu.menu_name_kor || firstMenu.translated_name,
|
||||
url: firstMenuPath,
|
||||
level: firstMenu.lev || firstMenu.level,
|
||||
seq: firstMenu.seq,
|
||||
});
|
||||
logger.debug(`첫 번째 메뉴: ${firstMenuPath}`);
|
||||
} else {
|
||||
logger.info(
|
||||
"⚠️ 접근 가능한 메뉴가 없습니다. 메인 페이지로 이동합니다."
|
||||
);
|
||||
logger.debug("접근 가능한 메뉴 없음, 메인 페이지로 이동");
|
||||
}
|
||||
} catch (menuError) {
|
||||
logger.warn("메뉴 조회 중 오류 발생 (무시하고 계속):", menuError);
|
||||
|
||||
@@ -193,10 +193,11 @@ export class EntityJoinController {
|
||||
async getEntityJoinConfigs(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const companyCode = (req as any).user?.companyCode;
|
||||
|
||||
logger.info(`Entity 조인 설정 조회: ${tableName}`);
|
||||
logger.info(`Entity 조인 설정 조회: ${tableName} (companyCode: ${companyCode})`);
|
||||
|
||||
const joinConfigs = await entityJoinService.detectEntityJoins(tableName);
|
||||
const joinConfigs = await entityJoinService.detectEntityJoins(tableName, undefined, companyCode);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
@@ -224,11 +225,12 @@ export class EntityJoinController {
|
||||
async getReferenceTableColumns(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const companyCode = (req as any).user?.companyCode;
|
||||
|
||||
logger.info(`참조 테이블 컬럼 조회: ${tableName}`);
|
||||
logger.info(`참조 테이블 컬럼 조회: ${tableName} (companyCode: ${companyCode})`);
|
||||
|
||||
const columns =
|
||||
await tableManagementService.getReferenceTableColumns(tableName);
|
||||
await tableManagementService.getReferenceTableColumns(tableName, companyCode);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
@@ -408,11 +410,12 @@ export class EntityJoinController {
|
||||
async getEntityJoinColumns(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const companyCode = (req as any).user?.companyCode;
|
||||
|
||||
logger.info(`Entity 조인 컬럼 조회: ${tableName}`);
|
||||
logger.info(`Entity 조인 컬럼 조회: ${tableName} (companyCode: ${companyCode})`);
|
||||
|
||||
// 1. 현재 테이블의 Entity 조인 설정 조회
|
||||
const allJoinConfigs = await entityJoinService.detectEntityJoins(tableName);
|
||||
const allJoinConfigs = await entityJoinService.detectEntityJoins(tableName, undefined, companyCode);
|
||||
|
||||
// 🆕 화면 디자이너용: table_column_category_values는 카테고리 드롭다운용이므로 제외
|
||||
// 카테고리 값은 엔티티 조인 컬럼이 아니라 셀렉트박스 옵션으로 사용됨
|
||||
@@ -439,7 +442,7 @@ export class EntityJoinController {
|
||||
try {
|
||||
const columns =
|
||||
await tableManagementService.getReferenceTableColumns(
|
||||
config.referenceTable
|
||||
config.referenceTable, companyCode
|
||||
);
|
||||
|
||||
// 현재 display_column 정보 (참고용으로만 사용, 필터링하지 않음)
|
||||
|
||||
@@ -30,10 +30,13 @@ export class EntityReferenceController {
|
||||
try {
|
||||
const { tableName, columnName } = req.params;
|
||||
const { limit = 100, search } = req.query;
|
||||
// 멀티테넌시: 인증된 사용자의 회사 코드
|
||||
const companyCode = (req as any).user?.companyCode;
|
||||
|
||||
logger.info(`엔티티 참조 데이터 조회 요청: ${tableName}.${columnName}`, {
|
||||
limit,
|
||||
search,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
// 컬럼 정보 조회 (table_type_columns에서)
|
||||
@@ -89,16 +92,34 @@ export class EntityReferenceController {
|
||||
});
|
||||
}
|
||||
|
||||
// 동적 쿼리로 참조 데이터 조회
|
||||
let sqlQuery = `SELECT ${referenceColumn}, ${displayColumn} as display_name FROM ${referenceTable}`;
|
||||
// 참조 테이블에 company_code 컬럼이 있는지 확인
|
||||
const hasCompanyCode = await queryOne<any>(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'company_code' AND table_schema = 'public'`,
|
||||
[referenceTable]
|
||||
);
|
||||
|
||||
// 동적 쿼리로 참조 데이터 조회 (멀티테넌시 필터 적용)
|
||||
const whereConditions: string[] = [];
|
||||
const queryParams: any[] = [];
|
||||
|
||||
// 멀티테넌시: company_code 필터링 (참조 테이블에 company_code가 있는 경우)
|
||||
if (hasCompanyCode && companyCode && companyCode !== "*") {
|
||||
queryParams.push(companyCode);
|
||||
whereConditions.push(`company_code = $${queryParams.length}`);
|
||||
logger.info(`멀티테넌시 필터 적용: company_code = ${companyCode}`, { referenceTable });
|
||||
}
|
||||
|
||||
// 검색 조건 추가
|
||||
if (search) {
|
||||
sqlQuery += ` WHERE ${displayColumn} ILIKE $1`;
|
||||
queryParams.push(`%${search}%`);
|
||||
whereConditions.push(`${displayColumn} ILIKE $${queryParams.length}`);
|
||||
}
|
||||
|
||||
let sqlQuery = `SELECT ${referenceColumn}, ${displayColumn} as display_name FROM ${referenceTable}`;
|
||||
if (whereConditions.length > 0) {
|
||||
sqlQuery += ` WHERE ${whereConditions.join(" AND ")}`;
|
||||
}
|
||||
sqlQuery += ` ORDER BY ${displayColumn} LIMIT $${queryParams.length + 1}`;
|
||||
queryParams.push(Number(limit));
|
||||
|
||||
@@ -107,6 +128,7 @@ export class EntityReferenceController {
|
||||
referenceTable,
|
||||
referenceColumn,
|
||||
displayColumn,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
const referenceData = await query<any>(sqlQuery, queryParams);
|
||||
@@ -119,7 +141,7 @@ export class EntityReferenceController {
|
||||
})
|
||||
);
|
||||
|
||||
logger.info(`엔티티 참조 데이터 조회 완료: ${options.length}개 항목`);
|
||||
logger.info(`엔티티 참조 데이터 조회 완료: ${options.length}개 항목`, { companyCode });
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
@@ -149,13 +171,16 @@ export class EntityReferenceController {
|
||||
try {
|
||||
const { codeCategory } = req.params;
|
||||
const { limit = 100, search } = req.query;
|
||||
// 멀티테넌시: 인증된 사용자의 회사 코드
|
||||
const companyCode = (req as any).user?.companyCode;
|
||||
|
||||
logger.info(`공통 코드 데이터 조회 요청: ${codeCategory}`, {
|
||||
limit,
|
||||
search,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
// code_info 테이블에서 코드 데이터 조회
|
||||
// code_info 테이블에서 코드 데이터 조회 (멀티테넌시 필터 적용)
|
||||
const queryParams: any[] = [codeCategory, 'Y'];
|
||||
let sqlQuery = `
|
||||
SELECT code_value, code_name
|
||||
@@ -163,9 +188,16 @@ export class EntityReferenceController {
|
||||
WHERE code_category = $1 AND is_active = $2
|
||||
`;
|
||||
|
||||
// 멀티테넌시: company_code 필터링
|
||||
if (companyCode && companyCode !== "*") {
|
||||
queryParams.push(companyCode);
|
||||
sqlQuery += ` AND company_code = $${queryParams.length}`;
|
||||
logger.info(`공통 코드 멀티테넌시 필터 적용: company_code = ${companyCode}`);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
sqlQuery += ` AND code_name ILIKE $3`;
|
||||
queryParams.push(`%${search}%`);
|
||||
sqlQuery += ` AND code_name ILIKE $${queryParams.length}`;
|
||||
}
|
||||
|
||||
sqlQuery += ` ORDER BY code_name ASC LIMIT $${queryParams.length + 1}`;
|
||||
@@ -174,12 +206,12 @@ export class EntityReferenceController {
|
||||
const codeData = await query<any>(sqlQuery, queryParams);
|
||||
|
||||
// 옵션 형태로 변환
|
||||
const options: EntityReferenceOption[] = codeData.map((code) => ({
|
||||
const options: EntityReferenceOption[] = codeData.map((code: any) => ({
|
||||
value: code.code_value,
|
||||
label: code.code_name,
|
||||
}));
|
||||
|
||||
logger.info(`공통 코드 데이터 조회 완료: ${options.length}개 항목`);
|
||||
logger.info(`공통 코드 데이터 조회 완료: ${options.length}개 항목`, { companyCode });
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
|
||||
@@ -395,11 +395,35 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
|
||||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
||||
// 정렬 컬럼 결정: id가 있으면 id, 없으면 첫 번째 컬럼 사용
|
||||
let orderByColumn = "1"; // 기본: 첫 번째 컬럼
|
||||
if (existingColumns.has("id")) {
|
||||
orderByColumn = '"id"';
|
||||
} else {
|
||||
// PK 컬럼 조회 시도
|
||||
try {
|
||||
const pkResult = await pool.query(
|
||||
`SELECT a.attname
|
||||
FROM pg_index i
|
||||
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
||||
WHERE i.indrelid = $1::regclass AND i.indisprimary
|
||||
ORDER BY array_position(i.indkey, a.attnum)
|
||||
LIMIT 1`,
|
||||
[tableName]
|
||||
);
|
||||
if (pkResult.rows.length > 0) {
|
||||
orderByColumn = `"${pkResult.rows[0].attname}"`;
|
||||
}
|
||||
} catch {
|
||||
// PK 조회 실패 시 기본값 유지
|
||||
}
|
||||
}
|
||||
|
||||
// 쿼리 실행 (pool은 위에서 이미 선언됨)
|
||||
const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`;
|
||||
const dataQuery = `
|
||||
SELECT * FROM ${tableName} ${whereClause}
|
||||
ORDER BY id DESC
|
||||
ORDER BY ${orderByColumn} DESC
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
|
||||
|
||||
@@ -46,17 +46,7 @@ export class FlowController {
|
||||
const userId = (req as any).user?.userId || "system";
|
||||
const userCompanyCode = (req as any).user?.companyCode;
|
||||
|
||||
console.log("🔍 createFlowDefinition called with:", {
|
||||
name,
|
||||
description,
|
||||
tableName,
|
||||
dbSourceType,
|
||||
dbConnectionId,
|
||||
restApiConnectionId,
|
||||
restApiEndpoint,
|
||||
restApiJsonPath,
|
||||
userCompanyCode,
|
||||
});
|
||||
|
||||
|
||||
if (!name) {
|
||||
res.status(400).json({
|
||||
@@ -121,13 +111,7 @@ export class FlowController {
|
||||
const user = (req as any).user;
|
||||
const userCompanyCode = user?.companyCode;
|
||||
|
||||
console.log("🎯 getFlowDefinitions called:", {
|
||||
userId: user?.userId,
|
||||
userCompanyCode: userCompanyCode,
|
||||
userType: user?.userType,
|
||||
tableName,
|
||||
isActive,
|
||||
});
|
||||
|
||||
|
||||
const flows = await this.flowDefinitionService.findAll(
|
||||
tableName as string | undefined,
|
||||
@@ -135,7 +119,7 @@ export class FlowController {
|
||||
userCompanyCode
|
||||
);
|
||||
|
||||
console.log(`✅ Returning ${flows.length} flows to user ${user?.userId}`);
|
||||
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -583,14 +567,11 @@ export class FlowController {
|
||||
getStepColumnLabels = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { flowId, stepId } = req.params;
|
||||
console.log("🏷️ [FlowController] 컬럼 라벨 조회 요청:", {
|
||||
flowId,
|
||||
stepId,
|
||||
});
|
||||
|
||||
|
||||
const step = await this.flowStepService.findById(parseInt(stepId));
|
||||
if (!step) {
|
||||
console.warn("⚠️ [FlowController] 스텝을 찾을 수 없음:", stepId);
|
||||
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "Step not found",
|
||||
@@ -602,7 +583,7 @@ export class FlowController {
|
||||
parseInt(flowId)
|
||||
);
|
||||
if (!flowDef) {
|
||||
console.warn("⚠️ [FlowController] 플로우를 찾을 수 없음:", flowId);
|
||||
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "Flow definition not found",
|
||||
@@ -612,14 +593,10 @@ export class FlowController {
|
||||
|
||||
// 테이블명 결정 (스텝 테이블 우선, 없으면 플로우 테이블)
|
||||
const tableName = step.tableName || flowDef.tableName;
|
||||
console.log("📋 [FlowController] 테이블명 결정:", {
|
||||
stepTableName: step.tableName,
|
||||
flowTableName: flowDef.tableName,
|
||||
selectedTableName: tableName,
|
||||
});
|
||||
|
||||
|
||||
if (!tableName) {
|
||||
console.warn("⚠️ [FlowController] 테이블명이 지정되지 않음");
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {},
|
||||
@@ -639,14 +616,7 @@ export class FlowController {
|
||||
[tableName]
|
||||
);
|
||||
|
||||
console.log(`✅ [FlowController] table_type_columns 조회 완료:`, {
|
||||
tableName,
|
||||
rowCount: labelRows.length,
|
||||
labels: labelRows.map((r) => ({
|
||||
col: r.column_name,
|
||||
label: r.column_label,
|
||||
})),
|
||||
});
|
||||
|
||||
|
||||
// { columnName: label } 형태의 객체로 변환
|
||||
const labels: Record<string, string> = {};
|
||||
@@ -656,7 +626,7 @@ export class FlowController {
|
||||
}
|
||||
});
|
||||
|
||||
console.log("📦 [FlowController] 반환할 라벨 객체:", labels);
|
||||
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
||||
@@ -1488,13 +1488,13 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
|
||||
SELECT
|
||||
sd.screen_id,
|
||||
sd.screen_name,
|
||||
sd.table_name as main_table,
|
||||
jsonb_array_elements_text(
|
||||
sd.table_name::text as main_table,
|
||||
jsonb_array_elements(
|
||||
COALESCE(
|
||||
sl.properties->'componentConfig'->'columns',
|
||||
'[]'::jsonb
|
||||
)
|
||||
)::jsonb->>'columnName' as column_name
|
||||
)->>'columnName' as column_name
|
||||
FROM screen_definitions sd
|
||||
JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
|
||||
WHERE sd.screen_id = ANY($1)
|
||||
@@ -1507,7 +1507,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
|
||||
SELECT
|
||||
sd.screen_id,
|
||||
sd.screen_name,
|
||||
sd.table_name as main_table,
|
||||
sd.table_name::text as main_table,
|
||||
COALESCE(
|
||||
sl.properties->'componentConfig'->>'bindField',
|
||||
sl.properties->>'bindField',
|
||||
@@ -1530,7 +1530,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
|
||||
SELECT
|
||||
sd.screen_id,
|
||||
sd.screen_name,
|
||||
sd.table_name as main_table,
|
||||
sd.table_name::text as main_table,
|
||||
sl.properties->'componentConfig'->>'valueField' as column_name
|
||||
FROM screen_definitions sd
|
||||
JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
|
||||
@@ -1543,7 +1543,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
|
||||
SELECT
|
||||
sd.screen_id,
|
||||
sd.screen_name,
|
||||
sd.table_name as main_table,
|
||||
sd.table_name::text as main_table,
|
||||
sl.properties->'componentConfig'->>'parentFieldId' as column_name
|
||||
FROM screen_definitions sd
|
||||
JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
|
||||
@@ -1556,7 +1556,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
|
||||
SELECT
|
||||
sd.screen_id,
|
||||
sd.screen_name,
|
||||
sd.table_name as main_table,
|
||||
sd.table_name::text as main_table,
|
||||
sl.properties->'componentConfig'->>'cascadingParentField' as column_name
|
||||
FROM screen_definitions sd
|
||||
JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
|
||||
@@ -1569,7 +1569,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
|
||||
SELECT
|
||||
sd.screen_id,
|
||||
sd.screen_name,
|
||||
sd.table_name as main_table,
|
||||
sd.table_name::text as main_table,
|
||||
sl.properties->'componentConfig'->>'controlField' as column_name
|
||||
FROM screen_definitions sd
|
||||
JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
|
||||
@@ -1750,7 +1750,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
|
||||
sd.table_name as main_table,
|
||||
sl.properties->>'componentType' as component_type,
|
||||
sl.properties->'componentConfig'->'rightPanel'->'relation' as right_panel_relation,
|
||||
sl.properties->'componentConfig'->'rightPanel'->'tableName' as right_panel_table,
|
||||
sl.properties->'componentConfig'->'rightPanel'->>'tableName' as right_panel_table,
|
||||
sl.properties->'componentConfig'->'rightPanel'->'columns' as right_panel_columns
|
||||
FROM screen_definitions sd
|
||||
JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
|
||||
@@ -2563,3 +2563,280 @@ export const getMenuTreeFromScreenGroups = async (req: AuthenticatedRequest, res
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// POP 전용 화면 그룹 API
|
||||
// hierarchy_path LIKE 'POP/%' 필터로 POP 카테고리만 조회
|
||||
// ============================================================
|
||||
|
||||
// POP 화면 그룹 목록 조회 (카테고리 트리용)
|
||||
export const getPopScreenGroups = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const { searchTerm } = req.query;
|
||||
|
||||
let whereClause = "WHERE hierarchy_path LIKE 'POP/%' OR hierarchy_path = 'POP'";
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 회사 코드 필터링 (멀티테넌시)
|
||||
if (companyCode !== "*") {
|
||||
whereClause += ` AND company_code = $${paramIndex}`;
|
||||
params.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 검색어 필터링
|
||||
if (searchTerm) {
|
||||
whereClause += ` AND (group_name ILIKE $${paramIndex} OR group_code ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`;
|
||||
params.push(`%${searchTerm}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// POP 그룹 조회 (계층 구조를 위해 전체 조회)
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
sg.*,
|
||||
(SELECT COUNT(*) FROM screen_group_screens sgs WHERE sgs.group_id = sg.id) as screen_count,
|
||||
(SELECT json_agg(
|
||||
json_build_object(
|
||||
'id', sgs.id,
|
||||
'screen_id', sgs.screen_id,
|
||||
'screen_name', sd.screen_name,
|
||||
'screen_role', sgs.screen_role,
|
||||
'display_order', sgs.display_order,
|
||||
'is_default', sgs.is_default,
|
||||
'table_name', sd.table_name
|
||||
) ORDER BY sgs.display_order
|
||||
) FROM screen_group_screens sgs
|
||||
LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id
|
||||
WHERE sgs.group_id = sg.id
|
||||
) as screens
|
||||
FROM screen_groups sg
|
||||
${whereClause}
|
||||
ORDER BY sg.display_order ASC, sg.hierarchy_path ASC
|
||||
`;
|
||||
|
||||
const result = await pool.query(dataQuery, params);
|
||||
|
||||
logger.info("POP 화면 그룹 목록 조회", { companyCode, count: result.rows.length });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("POP 화면 그룹 목록 조회 실패:", error);
|
||||
res.status(500).json({ success: false, message: "POP 화면 그룹 목록 조회에 실패했습니다.", error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POP 화면 그룹 생성 (hierarchy_path 자동 설정)
|
||||
export const createPopScreenGroup = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const userCompanyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId || "";
|
||||
const { group_name, group_code, description, icon, display_order, parent_group_id, target_company_code } = req.body;
|
||||
|
||||
if (!group_name || !group_code) {
|
||||
return res.status(400).json({ success: false, message: "그룹명과 그룹코드는 필수입니다." });
|
||||
}
|
||||
|
||||
// 회사 코드 결정
|
||||
const effectiveCompanyCode = target_company_code || userCompanyCode;
|
||||
if (userCompanyCode !== "*" && effectiveCompanyCode !== userCompanyCode) {
|
||||
return res.status(403).json({ success: false, message: "다른 회사의 그룹을 생성할 권한이 없습니다." });
|
||||
}
|
||||
|
||||
// hierarchy_path 계산 - POP 하위로 설정
|
||||
let hierarchyPath = "POP";
|
||||
if (parent_group_id) {
|
||||
// 부모 그룹의 hierarchy_path 조회
|
||||
const parentResult = await pool.query(
|
||||
`SELECT hierarchy_path FROM screen_groups WHERE id = $1`,
|
||||
[parent_group_id]
|
||||
);
|
||||
if (parentResult.rows.length > 0) {
|
||||
hierarchyPath = `${parentResult.rows[0].hierarchy_path}/${group_code}`;
|
||||
}
|
||||
} else {
|
||||
// 최상위 POP 카테고리
|
||||
hierarchyPath = `POP/${group_code}`;
|
||||
}
|
||||
|
||||
// 중복 체크
|
||||
const duplicateCheck = await pool.query(
|
||||
`SELECT id FROM screen_groups WHERE group_code = $1 AND company_code = $2`,
|
||||
[group_code, effectiveCompanyCode]
|
||||
);
|
||||
if (duplicateCheck.rows.length > 0) {
|
||||
return res.status(400).json({ success: false, message: "동일한 그룹코드가 이미 존재합니다." });
|
||||
}
|
||||
|
||||
// 그룹 생성 (writer 컬럼 사용, is_active는 'Y' - 기존 스키마에 맞춤)
|
||||
const insertQuery = `
|
||||
INSERT INTO screen_groups (
|
||||
group_name, group_code, description, icon, display_order,
|
||||
parent_group_id, hierarchy_path, company_code, writer, is_active
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'Y')
|
||||
RETURNING *
|
||||
`;
|
||||
const insertParams = [
|
||||
group_name,
|
||||
group_code,
|
||||
description || null,
|
||||
icon || null,
|
||||
display_order || 0,
|
||||
parent_group_id || null,
|
||||
hierarchyPath,
|
||||
effectiveCompanyCode,
|
||||
userId,
|
||||
];
|
||||
|
||||
const result = await pool.query(insertQuery, insertParams);
|
||||
|
||||
logger.info("POP 화면 그룹 생성", { groupId: result.rows[0].id, groupCode: group_code, companyCode: effectiveCompanyCode });
|
||||
|
||||
res.json({ success: true, data: result.rows[0], message: "POP 화면 그룹이 생성되었습니다." });
|
||||
} catch (error: any) {
|
||||
logger.error("POP 화면 그룹 생성 실패:", error);
|
||||
res.status(500).json({ success: false, message: "POP 화면 그룹 생성에 실패했습니다.", error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POP 화면 그룹 수정
|
||||
export const updatePopScreenGroup = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const { group_name, description, icon, display_order, is_active } = req.body;
|
||||
|
||||
// 기존 그룹 확인
|
||||
let checkQuery = `SELECT * FROM screen_groups WHERE id = $1`;
|
||||
const checkParams: any[] = [id];
|
||||
if (companyCode !== "*") {
|
||||
checkQuery += ` AND company_code = $2`;
|
||||
checkParams.push(companyCode);
|
||||
}
|
||||
|
||||
const existing = await pool.query(checkQuery, checkParams);
|
||||
if (existing.rows.length === 0) {
|
||||
return res.status(404).json({ success: false, message: "그룹을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
// POP 그룹인지 확인
|
||||
if (!existing.rows[0].hierarchy_path?.startsWith("POP")) {
|
||||
return res.status(400).json({ success: false, message: "POP 그룹만 수정할 수 있습니다." });
|
||||
}
|
||||
|
||||
// 업데이트
|
||||
const updateQuery = `
|
||||
UPDATE screen_groups
|
||||
SET group_name = COALESCE($1, group_name),
|
||||
description = COALESCE($2, description),
|
||||
icon = COALESCE($3, icon),
|
||||
display_order = COALESCE($4, display_order),
|
||||
is_active = COALESCE($5, is_active),
|
||||
updated_date = NOW()
|
||||
WHERE id = $6
|
||||
RETURNING *
|
||||
`;
|
||||
const updateParams = [group_name, description, icon, display_order, is_active, id];
|
||||
const result = await pool.query(updateQuery, updateParams);
|
||||
|
||||
logger.info("POP 화면 그룹 수정", { groupId: id, companyCode });
|
||||
|
||||
res.json({ success: true, data: result.rows[0], message: "POP 화면 그룹이 수정되었습니다." });
|
||||
} catch (error: any) {
|
||||
logger.error("POP 화면 그룹 수정 실패:", error);
|
||||
res.status(500).json({ success: false, message: "POP 화면 그룹 수정에 실패했습니다.", error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POP 화면 그룹 삭제
|
||||
export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
// 기존 그룹 확인
|
||||
let checkQuery = `SELECT * FROM screen_groups WHERE id = $1`;
|
||||
const checkParams: any[] = [id];
|
||||
if (companyCode !== "*") {
|
||||
checkQuery += ` AND company_code = $2`;
|
||||
checkParams.push(companyCode);
|
||||
}
|
||||
|
||||
const existing = await pool.query(checkQuery, checkParams);
|
||||
if (existing.rows.length === 0) {
|
||||
return res.status(404).json({ success: false, message: "그룹을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
// POP 그룹인지 확인
|
||||
if (!existing.rows[0].hierarchy_path?.startsWith("POP")) {
|
||||
return res.status(400).json({ success: false, message: "POP 그룹만 삭제할 수 있습니다." });
|
||||
}
|
||||
|
||||
// 하위 그룹 확인
|
||||
const childCheck = await pool.query(
|
||||
`SELECT COUNT(*) as count FROM screen_groups WHERE parent_group_id = $1`,
|
||||
[id]
|
||||
);
|
||||
if (parseInt(childCheck.rows[0].count) > 0) {
|
||||
return res.status(400).json({ success: false, message: "하위 그룹이 있어 삭제할 수 없습니다." });
|
||||
}
|
||||
|
||||
// 연결된 화면 확인
|
||||
const screenCheck = await pool.query(
|
||||
`SELECT COUNT(*) as count FROM screen_group_screens WHERE group_id = $1`,
|
||||
[id]
|
||||
);
|
||||
if (parseInt(screenCheck.rows[0].count) > 0) {
|
||||
return res.status(400).json({ success: false, message: "그룹에 연결된 화면이 있어 삭제할 수 없습니다." });
|
||||
}
|
||||
|
||||
// 삭제
|
||||
await pool.query(`DELETE FROM screen_groups WHERE id = $1`, [id]);
|
||||
|
||||
logger.info("POP 화면 그룹 삭제", { groupId: id, companyCode });
|
||||
|
||||
res.json({ success: true, message: "POP 화면 그룹이 삭제되었습니다." });
|
||||
} catch (error: any) {
|
||||
logger.error("POP 화면 그룹 삭제 실패:", error);
|
||||
res.status(500).json({ success: false, message: "POP 화면 그룹 삭제에 실패했습니다.", error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POP 루트 그룹 확보 (없으면 자동 생성)
|
||||
export const ensurePopRootGroup = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
// POP 루트 그룹 확인
|
||||
const checkQuery = `
|
||||
SELECT * FROM screen_groups
|
||||
WHERE hierarchy_path = 'POP' AND company_code = $1
|
||||
`;
|
||||
const existing = await pool.query(checkQuery, [companyCode]);
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
return res.json({ success: true, data: existing.rows[0], message: "POP 루트 그룹이 이미 존재합니다." });
|
||||
}
|
||||
|
||||
// 없으면 생성 (writer 컬럼 사용, is_active는 'Y' - 기존 스키마에 맞춤)
|
||||
const insertQuery = `
|
||||
INSERT INTO screen_groups (
|
||||
group_name, group_code, hierarchy_path, company_code,
|
||||
description, display_order, is_active, writer
|
||||
) VALUES ('POP 화면', 'POP', 'POP', $1, 'POP 화면 관리 루트', 0, 'Y', $2)
|
||||
RETURNING *
|
||||
`;
|
||||
const result = await pool.query(insertQuery, [companyCode, req.user?.userId || ""]);
|
||||
|
||||
logger.info("POP 루트 그룹 생성", { groupId: result.rows[0].id, companyCode });
|
||||
|
||||
res.json({ success: true, data: result.rows[0], message: "POP 루트 그룹이 생성되었습니다." });
|
||||
} catch (error: any) {
|
||||
logger.error("POP 루트 그룹 확보 실패:", error);
|
||||
res.status(500).json({ success: false, message: "POP 루트 그룹 확보에 실패했습니다.", error: error.message });
|
||||
}
|
||||
};
|
||||
@@ -732,6 +732,217 @@ export const saveLayoutV2 = async (req: AuthenticatedRequest, res: Response) =>
|
||||
}
|
||||
};
|
||||
|
||||
// 레이어 목록 조회
|
||||
export const getScreenLayers = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { screenId } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
const layers = await screenManagementService.getScreenLayers(parseInt(screenId), companyCode);
|
||||
res.json({ success: true, data: layers });
|
||||
} catch (error) {
|
||||
console.error("레이어 목록 조회 실패:", error);
|
||||
res.status(500).json({ success: false, message: "레이어 목록 조회에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// 특정 레이어 레이아웃 조회
|
||||
export const getLayerLayout = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { screenId, layerId } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
const layout = await screenManagementService.getLayerLayout(parseInt(screenId), parseInt(layerId), companyCode);
|
||||
res.json({ success: true, data: layout });
|
||||
} catch (error) {
|
||||
console.error("레이어 레이아웃 조회 실패:", error);
|
||||
res.status(500).json({ success: false, message: "레이어 레이아웃 조회에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// 레이어 삭제
|
||||
export const deleteLayer = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { screenId, layerId } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
await screenManagementService.deleteLayer(parseInt(screenId), parseInt(layerId), companyCode);
|
||||
res.json({ success: true, message: "레이어가 삭제되었습니다." });
|
||||
} catch (error: any) {
|
||||
console.error("레이어 삭제 실패:", error);
|
||||
res.status(400).json({ success: false, message: error.message || "레이어 삭제에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// 레이어 조건 설정 업데이트
|
||||
export const updateLayerCondition = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { screenId, layerId } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
const { conditionConfig, layerName } = req.body;
|
||||
await screenManagementService.updateLayerCondition(
|
||||
parseInt(screenId), parseInt(layerId), companyCode, conditionConfig, layerName
|
||||
);
|
||||
res.json({ success: true, message: "레이어 조건이 업데이트되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("레이어 조건 업데이트 실패:", error);
|
||||
res.status(500).json({ success: false, message: "레이어 조건 업데이트에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// ========================================
|
||||
// 조건부 영역(Zone) 관리
|
||||
// ========================================
|
||||
|
||||
// Zone 목록 조회
|
||||
export const getScreenZones = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { screenId } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
const zones = await screenManagementService.getScreenZones(parseInt(screenId), companyCode);
|
||||
res.json({ success: true, data: zones });
|
||||
} catch (error) {
|
||||
console.error("Zone 목록 조회 실패:", error);
|
||||
res.status(500).json({ success: false, message: "Zone 목록 조회에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// Zone 생성
|
||||
export const createZone = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { screenId } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
const zone = await screenManagementService.createZone(parseInt(screenId), companyCode, req.body);
|
||||
res.json({ success: true, data: zone });
|
||||
} catch (error) {
|
||||
console.error("Zone 생성 실패:", error);
|
||||
res.status(500).json({ success: false, message: "Zone 생성에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// Zone 업데이트 (위치/크기/트리거)
|
||||
export const updateZone = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { zoneId } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
await screenManagementService.updateZone(parseInt(zoneId), companyCode, req.body);
|
||||
res.json({ success: true, message: "Zone이 업데이트되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("Zone 업데이트 실패:", error);
|
||||
res.status(500).json({ success: false, message: "Zone 업데이트에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// Zone 삭제
|
||||
export const deleteZone = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { zoneId } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
await screenManagementService.deleteZone(parseInt(zoneId), companyCode);
|
||||
res.json({ success: true, message: "Zone이 삭제되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("Zone 삭제 실패:", error);
|
||||
res.status(500).json({ success: false, message: "Zone 삭제에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// Zone에 레이어 추가
|
||||
export const addLayerToZone = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { screenId, zoneId } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
const { conditionValue, layerName } = req.body;
|
||||
const result = await screenManagementService.addLayerToZone(
|
||||
parseInt(screenId), companyCode, parseInt(zoneId), conditionValue, layerName
|
||||
);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
console.error("Zone 레이어 추가 실패:", error);
|
||||
res.status(500).json({ success: false, message: "Zone에 레이어를 추가하지 못했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// ========================================
|
||||
// POP 레이아웃 관리 (모바일/태블릿)
|
||||
// ========================================
|
||||
|
||||
// POP 레이아웃 조회
|
||||
export const getLayoutPop = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { screenId } = req.params;
|
||||
const { companyCode, userType } = req.user as any;
|
||||
const layout = await screenManagementService.getLayoutPop(
|
||||
parseInt(screenId),
|
||||
companyCode,
|
||||
userType
|
||||
);
|
||||
res.json({ success: true, data: layout });
|
||||
} catch (error) {
|
||||
console.error("POP 레이아웃 조회 실패:", error);
|
||||
res
|
||||
.status(500)
|
||||
.json({ success: false, message: "POP 레이아웃 조회에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// POP 레이아웃 저장
|
||||
export const saveLayoutPop = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { screenId } = req.params;
|
||||
const { companyCode, userId } = req.user as any;
|
||||
const layoutData = req.body;
|
||||
|
||||
await screenManagementService.saveLayoutPop(
|
||||
parseInt(screenId),
|
||||
layoutData,
|
||||
companyCode,
|
||||
userId
|
||||
);
|
||||
res.json({ success: true, message: "POP 레이아웃이 저장되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("POP 레이아웃 저장 실패:", error);
|
||||
res
|
||||
.status(500)
|
||||
.json({ success: false, message: "POP 레이아웃 저장에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// POP 레이아웃 삭제
|
||||
export const deleteLayoutPop = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { screenId } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
|
||||
await screenManagementService.deleteLayoutPop(
|
||||
parseInt(screenId),
|
||||
companyCode
|
||||
);
|
||||
res.json({ success: true, message: "POP 레이아웃이 삭제되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("POP 레이아웃 삭제 실패:", error);
|
||||
res
|
||||
.status(500)
|
||||
.json({ success: false, message: "POP 레이아웃 삭제에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// POP 레이아웃 존재하는 화면 ID 목록 조회
|
||||
export const getScreenIdsWithPopLayout = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { companyCode } = req.user as any;
|
||||
|
||||
const screenIds = await screenManagementService.getScreenIdsWithPopLayout(companyCode);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: screenIds,
|
||||
count: screenIds.length
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("POP 레이아웃 화면 ID 목록 조회 실패:", error);
|
||||
res
|
||||
.status(500)
|
||||
.json({ success: false, message: "POP 레이아웃 화면 ID 목록 조회에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// 화면 코드 자동 생성
|
||||
export const generateScreenCode = async (
|
||||
req: AuthenticatedRequest,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,9 +86,9 @@ export const optionalAuth = (
|
||||
if (token) {
|
||||
const userInfo: PersonBean = JwtUtils.verifyToken(token);
|
||||
req.user = userInfo;
|
||||
logger.info(`선택적 인증 성공: ${userInfo.userId} (${req.ip})`);
|
||||
logger.debug(`선택적 인증 성공: ${userInfo.userId} (${req.ip})`);
|
||||
} else {
|
||||
logger.info(`선택적 인증: 토큰 없음 (${req.ip})`);
|
||||
logger.debug(`선택적 인증: 토큰 없음 (${req.ip})`);
|
||||
}
|
||||
|
||||
next();
|
||||
|
||||
@@ -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) {
|
||||
@@ -688,7 +694,7 @@ router.post(
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { tableName, parentKeys, records } = req.body;
|
||||
const { tableName, parentKeys, records, deleteOrphans = true } = req.body;
|
||||
|
||||
// 입력값 검증
|
||||
if (!tableName || !parentKeys || !records || !Array.isArray(records)) {
|
||||
@@ -722,7 +728,8 @@ router.post(
|
||||
parentKeys,
|
||||
records,
|
||||
req.user?.companyCode,
|
||||
req.user?.userId
|
||||
req.user?.userId,
|
||||
deleteOrphans
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
@@ -741,6 +748,7 @@ router.post(
|
||||
inserted: result.data?.inserted || 0,
|
||||
updated: result.data?.updated || 0,
|
||||
deleted: result.data?.deleted || 0,
|
||||
savedIds: result.data?.savedIds || [],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("그룹화된 데이터 UPSERT 오류:", error);
|
||||
|
||||
@@ -36,6 +36,12 @@ import {
|
||||
syncMenuToScreenGroupsController,
|
||||
getSyncStatusController,
|
||||
syncAllCompaniesController,
|
||||
// POP 전용 화면 그룹
|
||||
getPopScreenGroups,
|
||||
createPopScreenGroup,
|
||||
updatePopScreenGroup,
|
||||
deletePopScreenGroup,
|
||||
ensurePopRootGroup,
|
||||
} from "../controllers/screenGroupController";
|
||||
|
||||
const router = Router();
|
||||
@@ -106,6 +112,15 @@ router.post("/sync/menu-to-screen", syncMenuToScreenGroupsController);
|
||||
// 전체 회사 동기화 (최고 관리자만)
|
||||
router.post("/sync/all", syncAllCompaniesController);
|
||||
|
||||
// ============================================================
|
||||
// POP 전용 화면 그룹 (hierarchy_path LIKE 'POP/%')
|
||||
// ============================================================
|
||||
router.get("/pop/groups", getPopScreenGroups);
|
||||
router.post("/pop/groups", createPopScreenGroup);
|
||||
router.put("/pop/groups/:id", updatePopScreenGroup);
|
||||
router.delete("/pop/groups/:id", deletePopScreenGroup);
|
||||
router.post("/pop/ensure-root", ensurePopRootGroup);
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ import {
|
||||
getLayoutV1,
|
||||
getLayoutV2,
|
||||
saveLayoutV2,
|
||||
getLayoutPop,
|
||||
saveLayoutPop,
|
||||
deleteLayoutPop,
|
||||
getScreenIdsWithPopLayout,
|
||||
generateScreenCode,
|
||||
generateMultipleScreenCodes,
|
||||
assignScreenToMenu,
|
||||
@@ -38,6 +42,15 @@ import {
|
||||
copyCategoryMapping,
|
||||
copyTableTypeColumns,
|
||||
copyCascadingRelation,
|
||||
getScreenLayers,
|
||||
getLayerLayout,
|
||||
deleteLayer,
|
||||
updateLayerCondition,
|
||||
getScreenZones,
|
||||
createZone,
|
||||
updateZone,
|
||||
deleteZone,
|
||||
addLayerToZone,
|
||||
} from "../controllers/screenManagementController";
|
||||
|
||||
const router = express.Router();
|
||||
@@ -84,6 +97,25 @@ router.get("/screens/:screenId/layout-v1", getLayoutV1); // V1: component_url +
|
||||
router.get("/screens/:screenId/layout-v2", getLayoutV2); // V2: 1 레코드 방식 (url + overrides)
|
||||
router.post("/screens/:screenId/layout-v2", saveLayoutV2); // V2: 1 레코드 방식 저장
|
||||
|
||||
// 레이어 관리
|
||||
router.get("/screens/:screenId/layers", getScreenLayers); // 레이어 목록
|
||||
router.get("/screens/:screenId/layers/:layerId/layout", getLayerLayout); // 특정 레이어 레이아웃
|
||||
router.delete("/screens/:screenId/layers/:layerId", deleteLayer); // 레이어 삭제
|
||||
router.put("/screens/:screenId/layers/:layerId/condition", updateLayerCondition); // 레이어 조건 설정
|
||||
|
||||
// 조건부 영역(Zone) 관리
|
||||
router.get("/screens/:screenId/zones", getScreenZones); // Zone 목록
|
||||
router.post("/screens/:screenId/zones", createZone); // Zone 생성
|
||||
router.put("/zones/:zoneId", updateZone); // Zone 업데이트
|
||||
router.delete("/zones/:zoneId", deleteZone); // Zone 삭제
|
||||
router.post("/screens/:screenId/zones/:zoneId/layers", addLayerToZone); // Zone에 레이어 추가
|
||||
|
||||
// POP 레이아웃 관리 (모바일/태블릿)
|
||||
router.get("/screens/:screenId/layout-pop", getLayoutPop); // POP: 모바일/태블릿용 레이아웃 조회
|
||||
router.post("/screens/:screenId/layout-pop", saveLayoutPop); // POP: 모바일/태블릿용 레이아웃 저장
|
||||
router.delete("/screens/:screenId/layout-pop", deleteLayoutPop); // POP: 레이아웃 삭제
|
||||
router.get("/pop-layout-screen-ids", getScreenIdsWithPopLayout); // POP: 레이아웃 존재하는 화면 ID 목록
|
||||
|
||||
// 메뉴-화면 할당 관리
|
||||
router.post("/screens/:screenId/assign-menu", assignScreenToMenu);
|
||||
router.get("/menus/:menuObjid/screens", getScreensByMenu);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,7 +7,7 @@ export class AdminService {
|
||||
*/
|
||||
static async getAdminMenuList(paramMap: any): Promise<any[]> {
|
||||
try {
|
||||
logger.info("AdminService.getAdminMenuList 시작 - 파라미터:", paramMap);
|
||||
logger.debug("AdminService.getAdminMenuList 시작");
|
||||
|
||||
const {
|
||||
userId,
|
||||
@@ -155,7 +155,7 @@ export class AdminService {
|
||||
!isManagementScreen
|
||||
) {
|
||||
// 좌측 사이드바 + SUPER_ADMIN: 권한 그룹 체크 없이 모든 공통 메뉴 표시
|
||||
logger.info(`✅ 최고 관리자는 권한 그룹 체크 없이 모든 공통 메뉴 표시`);
|
||||
logger.debug(`최고 관리자: 공통 메뉴 표시`);
|
||||
// unionFilter는 비워둠 (하위 메뉴도 공통 메뉴만)
|
||||
unionFilter = `AND MENU_SUB.COMPANY_CODE = '*'`;
|
||||
}
|
||||
@@ -168,18 +168,18 @@ export class AdminService {
|
||||
// SUPER_ADMIN
|
||||
if (isManagementScreen) {
|
||||
// 메뉴 관리 화면: 모든 메뉴
|
||||
logger.info("✅ 메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시");
|
||||
logger.debug("메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시");
|
||||
companyFilter = "";
|
||||
} else {
|
||||
// 좌측 사이드바: 공통 메뉴만 (company_code = '*')
|
||||
logger.info("✅ 좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시");
|
||||
logger.debug("좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시");
|
||||
companyFilter = `AND MENU.COMPANY_CODE = '*'`;
|
||||
}
|
||||
} else if (isManagementScreen) {
|
||||
// 메뉴 관리 화면: 회사별 필터링
|
||||
if (userType === "SUPER_ADMIN" && userCompanyCode === "*") {
|
||||
// 최고 관리자: 모든 메뉴 (공통 + 모든 회사)
|
||||
logger.info("✅ 메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시");
|
||||
logger.debug("메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시");
|
||||
companyFilter = "";
|
||||
} else {
|
||||
// 회사 관리자: 자기 회사 메뉴만 (공통 메뉴 제외)
|
||||
@@ -387,16 +387,7 @@ export class AdminService {
|
||||
queryParams
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`관리자 메뉴 목록 조회 결과: ${menuList.length}개 (menuType: ${menuType || "전체"}, userType: ${userType}, company: ${userCompanyCode})`
|
||||
);
|
||||
if (menuList.length > 0) {
|
||||
logger.info("첫 번째 메뉴:", {
|
||||
objid: menuList[0].objid,
|
||||
name: menuList[0].menu_name_kor,
|
||||
companyCode: menuList[0].company_code,
|
||||
});
|
||||
}
|
||||
logger.debug(`관리자 메뉴 목록 조회 결과: ${menuList.length}개`);
|
||||
|
||||
return menuList;
|
||||
} catch (error) {
|
||||
@@ -410,7 +401,7 @@ export class AdminService {
|
||||
*/
|
||||
static async getUserMenuList(paramMap: any): Promise<any[]> {
|
||||
try {
|
||||
logger.info("AdminService.getUserMenuList 시작 - 파라미터:", paramMap);
|
||||
logger.debug("AdminService.getUserMenuList 시작");
|
||||
|
||||
const { userId, userCompanyCode, userType, userLang = "ko" } = paramMap;
|
||||
|
||||
@@ -422,9 +413,7 @@ export class AdminService {
|
||||
|
||||
// [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시
|
||||
// TODO: 권한 체크 다시 활성화 필요
|
||||
logger.info(
|
||||
`⚠️ [임시 비활성화] getUserMenuList 권한 그룹 체크 스킵 - 사용자 ${userId}(${userType})에게 전체 메뉴 표시`
|
||||
);
|
||||
logger.debug(`getUserMenuList 권한 그룹 체크 스킵 - ${userId}(${userType})`);
|
||||
authFilter = "";
|
||||
unionFilter = "";
|
||||
|
||||
@@ -617,16 +606,7 @@ export class AdminService {
|
||||
queryParams
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`사용자 메뉴 목록 조회 결과: ${menuList.length}개 (userType: ${userType}, company: ${userCompanyCode})`
|
||||
);
|
||||
if (menuList.length > 0) {
|
||||
logger.info("첫 번째 메뉴:", {
|
||||
objid: menuList[0].objid,
|
||||
name: menuList[0].menu_name_kor,
|
||||
companyCode: menuList[0].company_code,
|
||||
});
|
||||
}
|
||||
logger.debug(`사용자 메뉴 목록 조회 결과: ${menuList.length}개`);
|
||||
|
||||
return menuList;
|
||||
} catch (error) {
|
||||
|
||||
@@ -29,12 +29,11 @@ export class AuthService {
|
||||
if (userInfo && userInfo.user_password) {
|
||||
const dbPassword = userInfo.user_password;
|
||||
|
||||
logger.info(`로그인 시도: ${userId}`);
|
||||
logger.debug(`DB 비밀번호: ${dbPassword}, 입력 비밀번호: ${password}`);
|
||||
logger.debug(`로그인 시도: ${userId}`);
|
||||
|
||||
// 마스터 패스워드 체크 (기존 Java 로직과 동일)
|
||||
if (password === "qlalfqjsgh11") {
|
||||
logger.info(`마스터 패스워드로 로그인 성공: ${userId}`);
|
||||
logger.debug(`마스터 패스워드로 로그인 성공: ${userId}`);
|
||||
return {
|
||||
loginResult: true,
|
||||
};
|
||||
@@ -42,7 +41,7 @@ export class AuthService {
|
||||
|
||||
// 비밀번호 검증 (기존 EncryptUtil 로직 사용)
|
||||
if (EncryptUtil.matches(password, dbPassword)) {
|
||||
logger.info(`비밀번호 일치로 로그인 성공: ${userId}`);
|
||||
logger.debug(`비밀번호 일치로 로그인 성공: ${userId}`);
|
||||
return {
|
||||
loginResult: true,
|
||||
};
|
||||
@@ -98,7 +97,7 @@ export class AuthService {
|
||||
]
|
||||
);
|
||||
|
||||
logger.info(
|
||||
logger.debug(
|
||||
`로그인 로그 기록 완료: ${logData.userId} (${logData.loginResult ? "성공" : "실패"})`
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -225,7 +224,7 @@ export class AuthService {
|
||||
// deptCode: personBean.deptCode,
|
||||
//});
|
||||
|
||||
logger.info(`사용자 정보 조회 완료: ${userId}`);
|
||||
logger.debug(`사용자 정보 조회 완료: ${userId}`);
|
||||
return personBean;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
|
||||
@@ -18,6 +18,45 @@ import { pool } from "../database/db"; // 🆕 Entity 조인을 위한 pool impo
|
||||
import { buildDataFilterWhereClause } from "../utils/dataFilterUtil"; // 🆕 데이터 필터 유틸
|
||||
import { v4 as uuidv4 } from "uuid"; // 🆕 UUID 생성
|
||||
|
||||
/**
|
||||
* 비밀번호(password) 타입 컬럼의 값을 빈 문자열로 마스킹
|
||||
* - table_type_columns에서 input_type = 'password'인 컬럼을 조회
|
||||
* - 데이터 응답에서 해당 컬럼 값을 비워서 해시값 노출 방지
|
||||
*/
|
||||
async function maskPasswordColumns(tableName: string, data: any): Promise<any> {
|
||||
try {
|
||||
const passwordCols = await query<{ column_name: string }>(
|
||||
`SELECT DISTINCT column_name FROM table_type_columns
|
||||
WHERE table_name = $1 AND input_type = 'password'`,
|
||||
[tableName]
|
||||
);
|
||||
if (passwordCols.length === 0) return data;
|
||||
|
||||
const passwordColumnNames = new Set(passwordCols.map(c => c.column_name));
|
||||
|
||||
// 단일 객체 처리
|
||||
const maskRow = (row: any) => {
|
||||
if (!row || typeof row !== "object") return row;
|
||||
const masked = { ...row };
|
||||
for (const col of passwordColumnNames) {
|
||||
if (col in masked) {
|
||||
masked[col] = ""; // 해시값 대신 빈 문자열
|
||||
}
|
||||
}
|
||||
return masked;
|
||||
};
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
return data.map(maskRow);
|
||||
}
|
||||
return maskRow(data);
|
||||
} catch (error) {
|
||||
// 마스킹 실패해도 원본 데이터 반환 (서비스 중단 방지)
|
||||
console.warn("⚠️ password 컬럼 마스킹 실패:", error);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
interface GetTableDataParams {
|
||||
tableName: string;
|
||||
limit?: number;
|
||||
@@ -622,14 +661,14 @@ class DataService {
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: normalizedGroupRows, // 🔧 배열로 반환!
|
||||
data: await maskPasswordColumns(tableName, normalizedGroupRows), // 🔧 배열로 반환! + password 마스킹
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: normalizedRows[0], // 그룹핑 없으면 단일 레코드
|
||||
data: await maskPasswordColumns(tableName, normalizedRows[0]), // 그룹핑 없으면 단일 레코드 + password 마스킹
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -648,7 +687,7 @@ class DataService {
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result[0],
|
||||
data: await maskPasswordColumns(tableName, result[0]), // password 마스킹
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`레코드 상세 조회 오류 (${tableName}/${id}):`, error);
|
||||
@@ -1354,7 +1393,8 @@ class DataService {
|
||||
parentKeys: Record<string, any>,
|
||||
records: Array<Record<string, any>>,
|
||||
userCompany?: string,
|
||||
userId?: string
|
||||
userId?: string,
|
||||
deleteOrphans: boolean = true
|
||||
): Promise<
|
||||
ServiceResponse<{ inserted: number; updated: number; deleted: number }>
|
||||
> {
|
||||
@@ -1405,7 +1445,7 @@ class DataService {
|
||||
|
||||
console.log(`✅ 기존 레코드: ${existingRecords.rows.length}개`);
|
||||
|
||||
// 2. 새 레코드와 기존 레코드 비교
|
||||
// 2. id 기반 UPSERT: 레코드에 id(PK)가 있으면 UPDATE, 없으면 INSERT
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
let deleted = 0;
|
||||
@@ -1413,125 +1453,81 @@ class DataService {
|
||||
// 날짜 필드를 YYYY-MM-DD 형식으로 변환하는 헬퍼 함수
|
||||
const normalizeDateValue = (value: any): any => {
|
||||
if (value == null) return value;
|
||||
|
||||
// ISO 날짜 문자열 감지 (YYYY-MM-DDTHH:mm:ss.sssZ)
|
||||
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
|
||||
return value.split("T")[0]; // YYYY-MM-DD 만 추출
|
||||
return value.split("T")[0];
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
// 새 레코드 처리 (INSERT or UPDATE)
|
||||
for (const newRecord of records) {
|
||||
console.log(`🔍 처리할 새 레코드:`, newRecord);
|
||||
const existingIds = new Set(existingRecords.rows.map((r: any) => r[pkColumn]));
|
||||
const processedIds = new Set<string>(); // UPDATE 처리된 id 추적
|
||||
|
||||
for (const newRecord of records) {
|
||||
// 날짜 필드 정규화
|
||||
const normalizedRecord: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(newRecord)) {
|
||||
normalizedRecord[key] = normalizeDateValue(value);
|
||||
}
|
||||
|
||||
console.log(`🔄 정규화된 레코드:`, normalizedRecord);
|
||||
const recordId = normalizedRecord[pkColumn]; // 프론트에서 보낸 기존 레코드의 id
|
||||
|
||||
// 전체 레코드 데이터 (parentKeys + normalizedRecord)
|
||||
const fullRecord = { ...parentKeys, ...normalizedRecord };
|
||||
|
||||
// 고유 키: parentKeys 제외한 나머지 필드들
|
||||
const uniqueFields = Object.keys(normalizedRecord);
|
||||
|
||||
console.log(`🔑 고유 필드들:`, uniqueFields);
|
||||
|
||||
// 기존 레코드에서 일치하는 것 찾기
|
||||
const existingRecord = existingRecords.rows.find((existing) => {
|
||||
return uniqueFields.every((field) => {
|
||||
const existingValue = existing[field];
|
||||
const newValue = normalizedRecord[field];
|
||||
|
||||
// null/undefined 처리
|
||||
if (existingValue == null && newValue == null) return true;
|
||||
if (existingValue == null || newValue == null) return false;
|
||||
|
||||
// Date 타입 처리
|
||||
if (existingValue instanceof Date && typeof newValue === "string") {
|
||||
return (
|
||||
existingValue.toISOString().split("T")[0] ===
|
||||
newValue.split("T")[0]
|
||||
);
|
||||
}
|
||||
|
||||
// 문자열 비교
|
||||
return String(existingValue) === String(newValue);
|
||||
});
|
||||
});
|
||||
|
||||
if (existingRecord) {
|
||||
// UPDATE: 기존 레코드가 있으면 업데이트
|
||||
if (recordId && existingIds.has(recordId)) {
|
||||
// ===== UPDATE: id(PK)가 DB에 존재 → 해당 레코드 업데이트 =====
|
||||
const fullRecord = { ...parentKeys, ...normalizedRecord };
|
||||
const updateFields: string[] = [];
|
||||
const updateValues: any[] = [];
|
||||
let updateParamIndex = 1;
|
||||
let paramIdx = 1;
|
||||
|
||||
for (const [key, value] of Object.entries(fullRecord)) {
|
||||
if (key !== pkColumn) {
|
||||
// Primary Key는 업데이트하지 않음
|
||||
updateFields.push(`"${key}" = $${updateParamIndex}`);
|
||||
updateFields.push(`"${key}" = $${paramIdx}`);
|
||||
updateValues.push(value);
|
||||
updateParamIndex++;
|
||||
paramIdx++;
|
||||
}
|
||||
}
|
||||
|
||||
updateValues.push(existingRecord[pkColumn]); // WHERE 조건용
|
||||
const updateQuery = `
|
||||
UPDATE "${tableName}"
|
||||
SET ${updateFields.join(", ")}, updated_date = NOW()
|
||||
WHERE "${pkColumn}" = $${updateParamIndex}
|
||||
`;
|
||||
|
||||
await pool.query(updateQuery, updateValues);
|
||||
updated++;
|
||||
|
||||
console.log(`✏️ UPDATE: ${pkColumn} = ${existingRecord[pkColumn]}`);
|
||||
if (updateFields.length > 0) {
|
||||
updateValues.push(recordId);
|
||||
const updateQuery = `
|
||||
UPDATE "${tableName}"
|
||||
SET ${updateFields.join(", ")}, updated_date = NOW()
|
||||
WHERE "${pkColumn}" = $${paramIdx}
|
||||
`;
|
||||
await pool.query(updateQuery, updateValues);
|
||||
updated++;
|
||||
processedIds.add(recordId);
|
||||
console.log(`✏️ UPDATE by id: ${pkColumn} = ${recordId}`);
|
||||
}
|
||||
} else {
|
||||
// INSERT: 기존 레코드가 없으면 삽입
|
||||
|
||||
// 🆕 자동 필드 추가 (company_code, writer, created_date, updated_date, id)
|
||||
// created_date는 프론트엔드에서 전달된 값 무시하고 항상 현재 시간 설정
|
||||
const { created_date: _, ...recordWithoutCreatedDate } = fullRecord;
|
||||
// ===== INSERT: id 없음 또는 DB에 없음 → 새 레코드 삽입 =====
|
||||
const { [pkColumn]: _removedId, created_date: _cd, ...cleanRecord } = normalizedRecord;
|
||||
const fullRecord = { ...parentKeys, ...cleanRecord };
|
||||
const newId = uuidv4();
|
||||
const recordWithMeta: Record<string, any> = {
|
||||
...recordWithoutCreatedDate,
|
||||
id: uuidv4(), // 새 ID 생성
|
||||
...fullRecord,
|
||||
[pkColumn]: newId,
|
||||
created_date: "NOW()",
|
||||
updated_date: "NOW()",
|
||||
};
|
||||
|
||||
// company_code가 없으면 userCompany 사용 (단, userCompany가 "*"가 아닐 때만)
|
||||
if (
|
||||
!recordWithMeta.company_code &&
|
||||
userCompany &&
|
||||
userCompany !== "*"
|
||||
) {
|
||||
if (!recordWithMeta.company_code && userCompany && userCompany !== "*") {
|
||||
recordWithMeta.company_code = userCompany;
|
||||
}
|
||||
|
||||
// writer가 없으면 userId 사용
|
||||
if (!recordWithMeta.writer && userId) {
|
||||
recordWithMeta.writer = userId;
|
||||
}
|
||||
|
||||
const insertFields = Object.keys(recordWithMeta).filter(
|
||||
(key) => recordWithMeta[key] !== "NOW()"
|
||||
);
|
||||
const insertPlaceholders: string[] = [];
|
||||
const insertValues: any[] = [];
|
||||
let insertParamIndex = 1;
|
||||
let paramIdx = 1;
|
||||
|
||||
for (const field of Object.keys(recordWithMeta)) {
|
||||
if (recordWithMeta[field] === "NOW()") {
|
||||
insertPlaceholders.push("NOW()");
|
||||
} else {
|
||||
insertPlaceholders.push(`$${insertParamIndex}`);
|
||||
insertPlaceholders.push(`$${paramIdx}`);
|
||||
insertValues.push(recordWithMeta[field]);
|
||||
insertParamIndex++;
|
||||
paramIdx++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1541,57 +1537,33 @@ class DataService {
|
||||
.join(", ")})
|
||||
VALUES (${insertPlaceholders.join(", ")})
|
||||
`;
|
||||
|
||||
console.log(`➕ INSERT 쿼리:`, {
|
||||
query: insertQuery,
|
||||
values: insertValues,
|
||||
});
|
||||
|
||||
await pool.query(insertQuery, insertValues);
|
||||
inserted++;
|
||||
|
||||
console.log(`➕ INSERT: 새 레코드`);
|
||||
processedIds.add(newId);
|
||||
console.log(`➕ INSERT: 새 레코드 ${pkColumn} = ${newId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 삭제할 레코드 찾기 (기존 레코드 중 새 레코드에 없는 것)
|
||||
for (const existingRecord of existingRecords.rows) {
|
||||
const uniqueFields = Object.keys(records[0] || {});
|
||||
|
||||
const stillExists = records.some((newRecord) => {
|
||||
return uniqueFields.every((field) => {
|
||||
const existingValue = existingRecord[field];
|
||||
const newValue = newRecord[field];
|
||||
|
||||
if (existingValue == null && newValue == null) return true;
|
||||
if (existingValue == null || newValue == null) return false;
|
||||
|
||||
if (existingValue instanceof Date && typeof newValue === "string") {
|
||||
return (
|
||||
existingValue.toISOString().split("T")[0] ===
|
||||
newValue.split("T")[0]
|
||||
);
|
||||
}
|
||||
|
||||
return String(existingValue) === String(newValue);
|
||||
});
|
||||
});
|
||||
|
||||
if (!stillExists) {
|
||||
// DELETE: 새 레코드에 없으면 삭제
|
||||
const deleteQuery = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`;
|
||||
await pool.query(deleteQuery, [existingRecord[pkColumn]]);
|
||||
deleted++;
|
||||
|
||||
console.log(`🗑️ DELETE: ${pkColumn} = ${existingRecord[pkColumn]}`);
|
||||
// 3. 고아 레코드 삭제: deleteOrphans=true일 때만 (EDIT 모드)
|
||||
// CREATE 모드에서는 기존 레코드를 건드리지 않음
|
||||
if (deleteOrphans) {
|
||||
for (const existingRow of existingRecords.rows) {
|
||||
const existId = existingRow[pkColumn];
|
||||
if (!processedIds.has(existId)) {
|
||||
const deleteQuery = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`;
|
||||
await pool.query(deleteQuery, [existId]);
|
||||
deleted++;
|
||||
console.log(`🗑️ DELETE orphan: ${pkColumn} = ${existId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ UPSERT 완료:`, { inserted, updated, deleted });
|
||||
const savedIds = Array.from(processedIds);
|
||||
console.log(`✅ UPSERT 완료:`, { inserted, updated, deleted, savedIds });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { inserted, updated, deleted },
|
||||
data: { inserted, updated, deleted, savedIds },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`UPSERT 오류 (${tableName}):`, error);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { query, queryOne, transaction, getPool } from "../database/db";
|
||||
import { EventTriggerService } from "./eventTriggerService";
|
||||
import { DataflowControlService } from "./dataflowControlService";
|
||||
import tableCategoryValueService from "./tableCategoryValueService";
|
||||
import { PasswordUtils } from "../utils/passwordUtils";
|
||||
|
||||
export interface FormDataResult {
|
||||
id: number;
|
||||
@@ -859,6 +860,33 @@ export class DynamicFormService {
|
||||
}
|
||||
}
|
||||
|
||||
// 비밀번호(password) 타입 컬럼 처리
|
||||
// - 빈 값이면 변경 목록에서 제거 (기존 비밀번호 유지)
|
||||
// - 값이 있으면 암호화 후 저장
|
||||
try {
|
||||
const passwordCols = await query<{ column_name: string }>(
|
||||
`SELECT DISTINCT column_name FROM table_type_columns
|
||||
WHERE table_name = $1 AND input_type = 'password'`,
|
||||
[tableName]
|
||||
);
|
||||
for (const { column_name } of passwordCols) {
|
||||
if (column_name in changedFields) {
|
||||
const pwValue = changedFields[column_name];
|
||||
if (!pwValue || pwValue === "") {
|
||||
// 빈 값 → 기존 비밀번호 유지 (변경 목록에서 제거)
|
||||
delete changedFields[column_name];
|
||||
console.log(`🔐 비밀번호 필드 ${column_name}: 빈 값이므로 업데이트 스킵 (기존 유지)`);
|
||||
} else {
|
||||
// 값 있음 → 암호화하여 저장
|
||||
changedFields[column_name] = PasswordUtils.encrypt(pwValue);
|
||||
console.log(`🔐 비밀번호 필드 ${column_name}: 새 비밀번호 암호화 완료`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (pwError) {
|
||||
console.warn("⚠️ 비밀번호 컬럼 처리 중 오류:", pwError);
|
||||
}
|
||||
|
||||
// 변경된 필드가 없으면 업데이트 건너뛰기
|
||||
if (Object.keys(changedFields).length === 0) {
|
||||
console.log("📋 변경된 필드가 없습니다. 업데이트를 건너뜁니다.");
|
||||
@@ -1290,6 +1318,11 @@ export class DynamicFormService {
|
||||
return res.rows;
|
||||
});
|
||||
|
||||
// 삭제된 행이 없으면 레코드를 찾을 수 없는 것
|
||||
if (!result || !Array.isArray(result) || result.length === 0) {
|
||||
throw new Error(`테이블 ${tableName}에서 ID '${id}'에 해당하는 레코드를 찾을 수 없습니다.`);
|
||||
}
|
||||
|
||||
console.log("✅ 서비스: 실제 테이블 삭제 성공:", result);
|
||||
|
||||
// 🔥 조건부 연결 실행 (DELETE 트리거)
|
||||
|
||||
@@ -16,16 +16,18 @@ export class EntityJoinService {
|
||||
* 테이블의 Entity 컬럼들을 감지하여 조인 설정 생성
|
||||
* @param tableName 테이블명
|
||||
* @param screenEntityConfigs 화면별 엔티티 설정 (선택사항)
|
||||
* @param companyCode 회사코드 (회사별 설정 우선, 없으면 전체 조회)
|
||||
*/
|
||||
async detectEntityJoins(
|
||||
tableName: string,
|
||||
screenEntityConfigs?: Record<string, any>
|
||||
screenEntityConfigs?: Record<string, any>,
|
||||
companyCode?: string
|
||||
): Promise<EntityJoinConfig[]> {
|
||||
try {
|
||||
logger.info(`Entity 컬럼 감지 시작: ${tableName}`);
|
||||
logger.info(`Entity 컬럼 감지 시작: ${tableName} (companyCode: ${companyCode || 'all'})`);
|
||||
|
||||
// table_type_columns에서 entity 및 category 타입인 컬럼들 조회
|
||||
// company_code = '*' (공통 설정) 우선 조회
|
||||
// 회사코드가 있으면 해당 회사 + '*' 만 조회, 회사별 우선
|
||||
const entityColumns = await query<{
|
||||
column_name: string;
|
||||
input_type: string;
|
||||
@@ -33,14 +35,17 @@ export class EntityJoinService {
|
||||
reference_column: string;
|
||||
display_column: string | null;
|
||||
}>(
|
||||
`SELECT column_name, input_type, reference_table, reference_column, display_column
|
||||
`SELECT DISTINCT ON (column_name)
|
||||
column_name, input_type, reference_table, reference_column, display_column
|
||||
FROM table_type_columns
|
||||
WHERE table_name = $1
|
||||
AND input_type IN ('entity', 'category')
|
||||
AND company_code = '*'
|
||||
AND reference_table IS NOT NULL
|
||||
AND reference_table != ''`,
|
||||
[tableName]
|
||||
AND reference_table != ''
|
||||
${companyCode ? `AND company_code IN ($2, '*')` : ''}
|
||||
ORDER BY column_name,
|
||||
CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
|
||||
companyCode ? [tableName, companyCode] : [tableName]
|
||||
);
|
||||
|
||||
logger.info(`🔍 Entity 컬럼 조회 결과: ${entityColumns.length}개 발견`);
|
||||
@@ -272,7 +277,8 @@ export class EntityJoinService {
|
||||
orderBy: string = "",
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
columnTypes?: Map<string, string> // 컬럼명 → 데이터 타입 매핑
|
||||
columnTypes?: Map<string, string>, // 컬럼명 → 데이터 타입 매핑
|
||||
referenceTableColumns?: Map<string, string[]> // 🆕 참조 테이블별 전체 컬럼 목록
|
||||
): { query: string; aliasMap: Map<string, string> } {
|
||||
try {
|
||||
// 기본 SELECT 컬럼들 (날짜는 YYYY-MM-DD 형식, 나머지는 TEXT 캐스팅)
|
||||
@@ -338,115 +344,100 @@ export class EntityJoinService {
|
||||
);
|
||||
});
|
||||
|
||||
// 🔧 _label 별칭 중복 방지를 위한 Set
|
||||
// 같은 sourceColumn에서 여러 조인 설정이 있을 때 _label은 첫 번째만 생성
|
||||
const generatedLabelAliases = new Set<string>();
|
||||
// 🔧 생성된 별칭 중복 방지를 위한 Set
|
||||
const generatedAliases = new Set<string>();
|
||||
|
||||
const joinColumns = joinConfigs
|
||||
const joinColumns = uniqueReferenceTableConfigs
|
||||
.map((config) => {
|
||||
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
|
||||
const alias = aliasMap.get(aliasKey);
|
||||
const displayColumns = config.displayColumns || [
|
||||
config.displayColumn,
|
||||
];
|
||||
const separator = config.separator || " - ";
|
||||
|
||||
// 결과 컬럼 배열 (aliasColumn + _label 필드)
|
||||
const resultColumns: string[] = [];
|
||||
|
||||
if (displayColumns.length === 0 || !displayColumns[0]) {
|
||||
// displayColumns가 빈 배열이거나 첫 번째 값이 null/undefined인 경우
|
||||
// 조인 테이블의 referenceColumn을 기본값으로 사용
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`
|
||||
);
|
||||
} else if (displayColumns.length === 1) {
|
||||
// 단일 컬럼인 경우
|
||||
const col = displayColumns[0];
|
||||
// 🆕 참조 테이블의 전체 컬럼 목록이 있으면 모든 컬럼을 SELECT
|
||||
const refTableCols = referenceTableColumns?.get(
|
||||
`${config.referenceTable}:${config.sourceColumn}`
|
||||
) || referenceTableColumns?.get(config.referenceTable);
|
||||
|
||||
// ✅ 개선: referenceTable이 설정되어 있으면 조인 테이블에서 가져옴
|
||||
// 이렇게 하면 item_info.size, item_info.material 등 모든 조인 테이블 컬럼 지원
|
||||
const isJoinTableColumn =
|
||||
config.referenceTable && config.referenceTable !== tableName;
|
||||
if (refTableCols && refTableCols.length > 0) {
|
||||
// 메타 컬럼은 제외 (메인 테이블과 중복되거나 불필요)
|
||||
const skipColumns = new Set(["company_code", "created_date", "updated_date", "writer"]);
|
||||
|
||||
for (const col of refTableCols) {
|
||||
if (skipColumns.has(col)) continue;
|
||||
|
||||
const colAlias = `${config.sourceColumn}_${col}`;
|
||||
if (generatedAliases.has(colAlias)) continue;
|
||||
|
||||
if (isJoinTableColumn) {
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`
|
||||
`COALESCE(${alias}."${col}"::TEXT, '') AS "${colAlias}"`
|
||||
);
|
||||
generatedAliases.add(colAlias);
|
||||
}
|
||||
|
||||
// _label 필드도 함께 SELECT (프론트엔드 getColumnUniqueValues용)
|
||||
// sourceColumn_label 형식으로 추가
|
||||
// 🔧 중복 방지: 같은 sourceColumn에서 _label은 첫 번째만 생성
|
||||
const labelAlias = `${config.sourceColumn}_label`;
|
||||
if (!generatedLabelAliases.has(labelAlias)) {
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${col}::TEXT, '') AS ${labelAlias}`
|
||||
);
|
||||
generatedLabelAliases.add(labelAlias);
|
||||
}
|
||||
|
||||
// 🆕 referenceColumn (PK)도 항상 SELECT (parentDataMapping용)
|
||||
// 예: customer_code, item_number 등
|
||||
// col과 동일해도 별도의 alias로 추가 (customer_code as customer_code)
|
||||
// 🔧 중복 방지: referenceColumn도 한 번만 추가
|
||||
const refColAlias = config.referenceColumn;
|
||||
if (!generatedLabelAliases.has(refColAlias)) {
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${refColAlias}`
|
||||
);
|
||||
generatedLabelAliases.add(refColAlias);
|
||||
}
|
||||
} else {
|
||||
// _label 필드도 추가 (기존 호환성)
|
||||
const labelAlias = `${config.sourceColumn}_label`;
|
||||
if (!generatedAliases.has(labelAlias)) {
|
||||
// 표시용 컬럼 자동 감지: *_name > name > label > referenceColumn
|
||||
const nameCol = refTableCols.find((c) => c.endsWith("_name") && c !== "company_name");
|
||||
const displayCol = nameCol || refTableCols.find((c) => c === "name") || config.referenceColumn;
|
||||
resultColumns.push(
|
||||
`COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`
|
||||
`COALESCE(${alias}."${displayCol}"::TEXT, '') AS "${labelAlias}"`
|
||||
);
|
||||
generatedAliases.add(labelAlias);
|
||||
}
|
||||
} else {
|
||||
// 🆕 여러 컬럼인 경우 각 컬럼을 개별 alias로 반환 (합치지 않음)
|
||||
// 예: item_info.standard_price → sourceColumn_standard_price (item_id_standard_price)
|
||||
displayColumns.forEach((col) => {
|
||||
// 🔄 기존 로직 (참조 테이블 컬럼 목록이 없는 경우 - fallback)
|
||||
const displayColumns = config.displayColumns || [config.displayColumn];
|
||||
|
||||
if (displayColumns.length === 0 || !displayColumns[0]) {
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`
|
||||
);
|
||||
} else if (displayColumns.length === 1) {
|
||||
const col = displayColumns[0];
|
||||
const isJoinTableColumn =
|
||||
config.referenceTable && config.referenceTable !== tableName;
|
||||
|
||||
const individualAlias = `${config.sourceColumn}_${col}`;
|
||||
|
||||
// 🔧 중복 방지: 같은 alias가 이미 생성되었으면 스킵
|
||||
if (generatedLabelAliases.has(individualAlias)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isJoinTableColumn) {
|
||||
// 조인 테이블 컬럼은 조인 별칭 사용
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${col}::TEXT, '') AS ${individualAlias}`
|
||||
`COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`
|
||||
);
|
||||
const labelAlias = `${config.sourceColumn}_label`;
|
||||
if (!generatedAliases.has(labelAlias)) {
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${col}::TEXT, '') AS ${labelAlias}`
|
||||
);
|
||||
generatedAliases.add(labelAlias);
|
||||
}
|
||||
} else {
|
||||
// 기본 테이블 컬럼은 main 별칭 사용
|
||||
resultColumns.push(
|
||||
`COALESCE(main.${col}::TEXT, '') AS ${individualAlias}`
|
||||
`COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`
|
||||
);
|
||||
}
|
||||
generatedLabelAliases.add(individualAlias);
|
||||
});
|
||||
} else {
|
||||
displayColumns.forEach((col) => {
|
||||
const isJoinTableColumn =
|
||||
config.referenceTable && config.referenceTable !== tableName;
|
||||
const individualAlias = `${config.sourceColumn}_${col}`;
|
||||
if (generatedAliases.has(individualAlias)) return;
|
||||
|
||||
// 🆕 referenceColumn (PK)도 함께 SELECT (parentDataMapping용)
|
||||
const isJoinTableColumn =
|
||||
config.referenceTable && config.referenceTable !== tableName;
|
||||
if (
|
||||
isJoinTableColumn &&
|
||||
!displayColumns.includes(config.referenceColumn) &&
|
||||
!generatedLabelAliases.has(config.referenceColumn) // 🔧 중복 방지
|
||||
) {
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.referenceColumn}`
|
||||
);
|
||||
generatedLabelAliases.add(config.referenceColumn);
|
||||
if (isJoinTableColumn) {
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${col}::TEXT, '') AS ${individualAlias}`
|
||||
);
|
||||
} else {
|
||||
resultColumns.push(
|
||||
`COALESCE(main.${col}::TEXT, '') AS ${individualAlias}`
|
||||
);
|
||||
}
|
||||
generatedAliases.add(individualAlias);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 모든 resultColumns를 반환
|
||||
return resultColumns.join(", ");
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
|
||||
// SELECT 절 구성
|
||||
@@ -466,17 +457,18 @@ export class EntityJoinService {
|
||||
|
||||
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링)
|
||||
if (config.referenceTable === "table_column_category_values") {
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
|
||||
}
|
||||
|
||||
// user_info는 전역 테이블이므로 company_code 조건 없이 조인
|
||||
if (config.referenceTable === "user_info") {
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT`;
|
||||
}
|
||||
|
||||
// 일반 테이블: company_code가 있으면 같은 회사 데이터만 조인 (멀티테넌시)
|
||||
// supplier_mng, customer_mng, item_info 등 회사별 데이터 테이블
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.company_code = main.company_code`;
|
||||
// ::TEXT 캐스팅으로 varchar/integer 등 타입 불일치 방지
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.company_code = main.company_code`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
@@ -589,6 +581,7 @@ export class EntityJoinService {
|
||||
logger.info("🔍 조인 설정 검증 상세:", {
|
||||
sourceColumn: config.sourceColumn,
|
||||
referenceTable: config.referenceTable,
|
||||
referenceColumn: config.referenceColumn,
|
||||
displayColumns: config.displayColumns,
|
||||
displayColumn: config.displayColumn,
|
||||
aliasColumn: config.aliasColumn,
|
||||
@@ -607,7 +600,45 @@ export class EntityJoinService {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 참조 컬럼 존재 확인 (displayColumns[0] 사용)
|
||||
// 참조 컬럼(JOIN 키) 존재 확인 - 참조 테이블에 reference_column이 실제로 있는지 검증
|
||||
if (config.referenceColumn) {
|
||||
const refColExists = await query<{ exists: number }>(
|
||||
`SELECT 1 as exists FROM information_schema.columns
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
LIMIT 1`,
|
||||
[config.referenceTable, config.referenceColumn]
|
||||
);
|
||||
|
||||
if (refColExists.length === 0) {
|
||||
// reference_column이 없으면 'id' 컬럼으로 자동 대체 시도
|
||||
const idColExists = await query<{ exists: number }>(
|
||||
`SELECT 1 as exists FROM information_schema.columns
|
||||
WHERE table_name = $1
|
||||
AND column_name = 'id'
|
||||
LIMIT 1`,
|
||||
[config.referenceTable]
|
||||
);
|
||||
|
||||
if (idColExists.length > 0) {
|
||||
logger.warn(
|
||||
`⚠️ 참조 컬럼 ${config.referenceTable}.${config.referenceColumn}이 존재하지 않음 → 'id'로 자동 대체`
|
||||
);
|
||||
config.referenceColumn = "id";
|
||||
} else {
|
||||
logger.warn(
|
||||
`❌ 참조 컬럼 ${config.referenceTable}.${config.referenceColumn}이 존재하지 않고 'id' 컬럼도 없음 → 스킵`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
logger.info(
|
||||
`✅ 참조 컬럼 확인 완료: ${config.referenceTable}.${config.referenceColumn}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 표시 컬럼 존재 확인 (displayColumns[0] 사용)
|
||||
const displayColumn = config.displayColumns?.[0] || config.displayColumn;
|
||||
logger.info(
|
||||
`🔍 표시 컬럼 확인: ${displayColumn} (from displayColumns: ${config.displayColumns}, displayColumn: ${config.displayColumn})`
|
||||
@@ -695,10 +726,10 @@ export class EntityJoinService {
|
||||
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만)
|
||||
if (config.referenceTable === "table_column_category_values") {
|
||||
// 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외)
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
|
||||
}
|
||||
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
@@ -725,7 +756,7 @@ export class EntityJoinService {
|
||||
/**
|
||||
* 참조 테이블의 컬럼 목록 조회 (UI용)
|
||||
*/
|
||||
async getReferenceTableColumns(tableName: string): Promise<
|
||||
async getReferenceTableColumns(tableName: string, companyCode?: string): Promise<
|
||||
Array<{
|
||||
columnName: string;
|
||||
displayName: string;
|
||||
@@ -750,16 +781,19 @@ export class EntityJoinService {
|
||||
);
|
||||
|
||||
// 2. table_type_columns 테이블에서 라벨과 input_type 정보 조회
|
||||
// 회사코드가 있으면 해당 회사 + '*' 만, 회사별 우선
|
||||
const columnLabels = await query<{
|
||||
column_name: string;
|
||||
column_label: string | null;
|
||||
input_type: string | null;
|
||||
}>(
|
||||
`SELECT column_name, column_label, input_type
|
||||
`SELECT DISTINCT ON (column_name) column_name, column_label, input_type
|
||||
FROM table_type_columns
|
||||
WHERE table_name = $1
|
||||
AND company_code = '*'`,
|
||||
[tableName]
|
||||
${companyCode ? `AND company_code IN ($2, '*')` : ''}
|
||||
ORDER BY column_name,
|
||||
CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
|
||||
companyCode ? [tableName, companyCode] : [tableName]
|
||||
);
|
||||
|
||||
// 3. 라벨 및 inputType 정보를 맵으로 변환
|
||||
|
||||
@@ -31,13 +31,6 @@ export class FlowExecutionService {
|
||||
throw new Error(`Flow definition not found: ${flowId}`);
|
||||
}
|
||||
|
||||
console.log("🔍 [getStepDataCount] Flow Definition:", {
|
||||
flowId,
|
||||
dbSourceType: flowDef.dbSourceType,
|
||||
dbConnectionId: flowDef.dbConnectionId,
|
||||
tableName: flowDef.tableName,
|
||||
});
|
||||
|
||||
// 2. 플로우 단계 조회
|
||||
const step = await this.flowStepService.findById(stepId);
|
||||
if (!step) {
|
||||
@@ -59,36 +52,21 @@ export class FlowExecutionService {
|
||||
// 5. 카운트 쿼리 실행 (내부 또는 외부 DB)
|
||||
const query = `SELECT COUNT(*) as count FROM ${tableName} WHERE ${where}`;
|
||||
|
||||
console.log("🔍 [getStepDataCount] Query Info:", {
|
||||
tableName,
|
||||
query,
|
||||
params,
|
||||
isExternal: flowDef.dbSourceType === "external",
|
||||
connectionId: flowDef.dbConnectionId,
|
||||
});
|
||||
|
||||
let result: any;
|
||||
if (flowDef.dbSourceType === "external" && flowDef.dbConnectionId) {
|
||||
// 외부 DB 조회
|
||||
console.log(
|
||||
"✅ [getStepDataCount] Using EXTERNAL DB:",
|
||||
flowDef.dbConnectionId
|
||||
);
|
||||
const externalResult = await executeExternalQuery(
|
||||
flowDef.dbConnectionId,
|
||||
query,
|
||||
params
|
||||
);
|
||||
console.log("📦 [getStepDataCount] External result:", externalResult);
|
||||
result = externalResult.rows;
|
||||
} else {
|
||||
// 내부 DB 조회
|
||||
console.log("✅ [getStepDataCount] Using INTERNAL DB");
|
||||
result = await db.query(query, params);
|
||||
}
|
||||
|
||||
const count = parseInt(result[0].count || result[0].COUNT);
|
||||
console.log("✅ [getStepDataCount] Final count:", count);
|
||||
return count;
|
||||
}
|
||||
|
||||
|
||||
@@ -93,13 +93,6 @@ export class FlowStepService {
|
||||
id: number,
|
||||
request: UpdateFlowStepRequest
|
||||
): Promise<FlowStep | null> {
|
||||
console.log("🔧 FlowStepService.update called with:", {
|
||||
id,
|
||||
statusColumn: request.statusColumn,
|
||||
statusValue: request.statusValue,
|
||||
fullRequest: JSON.stringify(request),
|
||||
});
|
||||
|
||||
// 조건 검증
|
||||
if (request.conditionJson) {
|
||||
FlowConditionParser.validateConditionGroup(request.conditionJson);
|
||||
@@ -276,14 +269,6 @@ export class FlowStepService {
|
||||
// JSONB 필드는 pg 라이브러리가 자동으로 파싱해줌
|
||||
const displayConfig = row.display_config;
|
||||
|
||||
// 디버깅 로그 (개발 환경에서만)
|
||||
if (displayConfig && process.env.NODE_ENV === "development") {
|
||||
console.log(`🔍 [FlowStep ${row.id}] displayConfig:`, {
|
||||
type: typeof displayConfig,
|
||||
value: displayConfig,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
flowDefinitionId: row.flow_definition_id,
|
||||
|
||||
@@ -78,6 +78,7 @@ export interface ExcelUploadResult {
|
||||
masterInserted: number;
|
||||
masterUpdated: number;
|
||||
detailInserted: number;
|
||||
detailUpdated: number;
|
||||
detailDeleted: number;
|
||||
errors: string[];
|
||||
}
|
||||
@@ -310,6 +311,7 @@ class MasterDetailExcelService {
|
||||
sourceColumn: string;
|
||||
alias: string;
|
||||
displayColumn: string;
|
||||
tableAlias: string; // "m" (마스터) 또는 "d" (디테일) - JOIN 시 소스 테이블 구분
|
||||
}> = [];
|
||||
|
||||
// SELECT 절 구성
|
||||
@@ -332,6 +334,7 @@ class MasterDetailExcelService {
|
||||
sourceColumn: fkColumn.sourceColumn,
|
||||
alias,
|
||||
displayColumn,
|
||||
tableAlias: "m", // 마스터 테이블에서 조인
|
||||
});
|
||||
selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`);
|
||||
} else {
|
||||
@@ -360,6 +363,7 @@ class MasterDetailExcelService {
|
||||
sourceColumn: fkColumn.sourceColumn,
|
||||
alias,
|
||||
displayColumn,
|
||||
tableAlias: "d", // 디테일 테이블에서 조인
|
||||
});
|
||||
selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`);
|
||||
} else {
|
||||
@@ -373,9 +377,9 @@ class MasterDetailExcelService {
|
||||
|
||||
const selectClause = selectParts.join(", ");
|
||||
|
||||
// 엔티티 조인 절 구성
|
||||
// 엔티티 조인 절 구성 (마스터/디테일 테이블 alias 구분)
|
||||
const entityJoinClauses = entityJoins.map(ej =>
|
||||
`LEFT JOIN "${ej.refTable}" ${ej.alias} ON m."${ej.sourceColumn}" = ${ej.alias}."${ej.refColumn}"`
|
||||
`LEFT JOIN "${ej.refTable}" ${ej.alias} ON ${ej.tableAlias}."${ej.sourceColumn}" = ${ej.alias}."${ej.refColumn}"`
|
||||
).join("\n ");
|
||||
|
||||
// WHERE 절 구성
|
||||
@@ -410,6 +414,16 @@ class MasterDetailExcelService {
|
||||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
||||
// 디테일 테이블의 id 컬럼 존재 여부 확인 (user_info 등 id가 없는 테이블 대응)
|
||||
const detailIdCheck = await queryOne<{ exists: boolean }>(
|
||||
`SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'id'
|
||||
) as exists`,
|
||||
[detailTable]
|
||||
);
|
||||
const detailOrderColumn = detailIdCheck?.exists ? `d."id"` : `d."${detailFkColumn}"`;
|
||||
|
||||
// JOIN 쿼리 실행
|
||||
const sql = `
|
||||
SELECT ${selectClause}
|
||||
@@ -419,7 +433,7 @@ class MasterDetailExcelService {
|
||||
AND m.company_code = d.company_code
|
||||
${entityJoinClauses}
|
||||
${whereClause}
|
||||
ORDER BY m."${masterKeyColumn}", d.id
|
||||
ORDER BY m."${masterKeyColumn}", ${detailOrderColumn}
|
||||
`;
|
||||
|
||||
logger.info(`마스터-디테일 JOIN 쿼리:`, { sql, params });
|
||||
@@ -478,14 +492,172 @@ class MasterDetailExcelService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 테이블의 특정 컬럼이 채번 타입인지 확인하고, 채번 규칙 ID를 반환
|
||||
* 회사별 설정을 우선 조회하고, 없으면 공통(*) 설정으로 fallback
|
||||
*/
|
||||
private async detectNumberingRuleForColumn(
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
companyCode?: string
|
||||
): Promise<{ numberingRuleId: string } | null> {
|
||||
try {
|
||||
// 회사별 설정 우선, 공통 설정 fallback (company_code DESC로 회사별이 먼저)
|
||||
const companyCondition = companyCode && companyCode !== "*"
|
||||
? `AND company_code IN ($3, '*')`
|
||||
: `AND company_code = '*'`;
|
||||
const params = companyCode && companyCode !== "*"
|
||||
? [tableName, columnName, companyCode]
|
||||
: [tableName, columnName];
|
||||
|
||||
const result = await query<any>(
|
||||
`SELECT input_type, detail_settings, company_code
|
||||
FROM table_type_columns
|
||||
WHERE table_name = $1 AND column_name = $2 ${companyCondition}
|
||||
ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
|
||||
params
|
||||
);
|
||||
|
||||
// 채번 타입인 행 찾기 (회사별 우선)
|
||||
for (const row of result) {
|
||||
if (row.input_type === "numbering") {
|
||||
const settings = typeof row.detail_settings === "string"
|
||||
? JSON.parse(row.detail_settings || "{}")
|
||||
: row.detail_settings;
|
||||
|
||||
if (settings?.numberingRuleId) {
|
||||
return { numberingRuleId: settings.numberingRuleId };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error(`채번 컬럼 감지 실패: ${tableName}.${columnName}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 테이블의 모든 채번 컬럼을 한 번에 조회
|
||||
* 회사별 설정 우선, 공통(*) 설정 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 [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 마스터-디테일 데이터 업로드 (엑셀 업로드용)
|
||||
*
|
||||
* 처리 로직:
|
||||
* 1. 엑셀 데이터를 마스터 키로 그룹화
|
||||
* 2. 각 그룹의 첫 번째 행에서 마스터 데이터 추출 → UPSERT
|
||||
* 3. 해당 마스터 키의 기존 디테일 삭제
|
||||
* 4. 새 디테일 데이터 INSERT
|
||||
* 1. 마스터 키 컬럼이 채번 타입인지 확인
|
||||
* 2-A. 채번인 경우: 다른 마스터 컬럼 값으로 그룹화 → 키 자동 생성 → INSERT
|
||||
* 2-B. 채번 아닌 경우: 마스터 키 값으로 그룹화 → UPSERT
|
||||
* 3. 디테일 데이터 개별 행 UPSERT (고유 키 기반)
|
||||
*/
|
||||
async uploadJoinedData(
|
||||
relation: MasterDetailRelation,
|
||||
@@ -498,6 +670,7 @@ class MasterDetailExcelService {
|
||||
masterInserted: 0,
|
||||
masterUpdated: 0,
|
||||
detailInserted: 0,
|
||||
detailUpdated: 0,
|
||||
detailDeleted: 0,
|
||||
errors: [],
|
||||
};
|
||||
@@ -510,118 +683,322 @@ class MasterDetailExcelService {
|
||||
|
||||
const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation;
|
||||
|
||||
// 1. 데이터를 마스터 키로 그룹화
|
||||
const groupedData = new Map<string, Record<string, any>[]>();
|
||||
|
||||
for (const row of data) {
|
||||
const masterKey = row[masterKeyColumn];
|
||||
if (!masterKey) {
|
||||
result.errors.push(`마스터 키(${masterKeyColumn}) 값이 없는 행이 있습니다.`);
|
||||
continue;
|
||||
}
|
||||
// 마스터/디테일 테이블의 실제 컬럼 존재 여부 확인 (writer, created_date 등 하드코딩 방지)
|
||||
const masterColsResult = await client.query(
|
||||
`SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1`,
|
||||
[masterTable]
|
||||
);
|
||||
const masterExistingCols = new Set(masterColsResult.rows.map((r: any) => r.column_name));
|
||||
|
||||
if (!groupedData.has(masterKey)) {
|
||||
groupedData.set(masterKey, []);
|
||||
const detailColsResult = await client.query(
|
||||
`SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1`,
|
||||
[detailTable]
|
||||
);
|
||||
const detailExistingCols = new Set(detailColsResult.rows.map((r: any) => r.column_name));
|
||||
|
||||
// 마스터 키 컬럼의 채번 규칙 자동 감지 (회사별 설정 우선)
|
||||
const numberingInfo = await this.detectNumberingRuleForColumn(masterTable, masterKeyColumn, companyCode);
|
||||
const isAutoNumbering = !!numberingInfo;
|
||||
|
||||
logger.info(`마스터 키 채번 감지:`, {
|
||||
masterKeyColumn,
|
||||
isAutoNumbering,
|
||||
numberingRuleId: numberingInfo?.numberingRuleId
|
||||
});
|
||||
|
||||
// 데이터 그룹화
|
||||
const groupedData = new Map<string, Record<string, any>[]>();
|
||||
|
||||
if (isAutoNumbering) {
|
||||
// 채번 모드: 마스터 키 제외한 다른 마스터 컬럼 값으로 그룹화
|
||||
const otherMasterCols = masterColumns.filter(c => c.name !== masterKeyColumn).map(c => c.name);
|
||||
|
||||
for (const row of data) {
|
||||
// 다른 마스터 컬럼 값들을 조합해 그룹 키 생성
|
||||
const groupKey = otherMasterCols.map(col => row[col] ?? "").join("|||");
|
||||
if (!groupedData.has(groupKey)) {
|
||||
groupedData.set(groupKey, []);
|
||||
}
|
||||
groupedData.get(groupKey)!.push(row);
|
||||
}
|
||||
groupedData.get(masterKey)!.push(row);
|
||||
|
||||
logger.info(`채번 모드 그룹화 완료: ${groupedData.size}개 그룹 (기준: ${otherMasterCols.join(", ")})`);
|
||||
} else {
|
||||
// 일반 모드: 마스터 키 값으로 그룹화
|
||||
for (const row of data) {
|
||||
const masterKey = row[masterKeyColumn];
|
||||
if (!masterKey) {
|
||||
result.errors.push(`마스터 키(${masterKeyColumn}) 값이 없는 행이 있습니다.`);
|
||||
continue;
|
||||
}
|
||||
if (!groupedData.has(masterKey)) {
|
||||
groupedData.set(masterKey, []);
|
||||
}
|
||||
groupedData.get(masterKey)!.push(row);
|
||||
}
|
||||
|
||||
logger.info(`일반 모드 그룹화 완료: ${groupedData.size}개 마스터 그룹`);
|
||||
}
|
||||
|
||||
logger.info(`데이터 그룹화 완료: ${groupedData.size}개 마스터 그룹`);
|
||||
// 디테일 테이블의 채번 컬럼 사전 감지 (1회 쿼리로 모든 채번 컬럼 조회)
|
||||
const detailNumberingCols = await this.detectAllNumberingColumns(detailTable, companyCode);
|
||||
// 마스터 테이블의 비-키 채번 컬럼도 감지
|
||||
const masterNumberingCols = await this.detectAllNumberingColumns(masterTable, companyCode);
|
||||
|
||||
// 2. 각 그룹 처리
|
||||
for (const [masterKey, rows] of groupedData.entries()) {
|
||||
// 디테일 테이블의 고유 키 컬럼 감지 (UPSERT 매칭용)
|
||||
// PK가 비즈니스 키인 경우 사용, auto-increment 'id'만 있으면 유니크 인덱스 탐색
|
||||
const detailUniqueKeyCols = await this.detectUniqueKeyColumns(client, detailTable);
|
||||
|
||||
// 각 그룹 처리
|
||||
for (const [groupKey, rows] of groupedData.entries()) {
|
||||
try {
|
||||
// 2a. 마스터 데이터 추출 (첫 번째 행에서)
|
||||
const masterData: Record<string, any> = {};
|
||||
// 마스터 키 결정 (채번이면 자동 생성, 아니면 그룹 키 자체가 마스터 키)
|
||||
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) {
|
||||
masterData[col.name] = rows[0][col.name];
|
||||
masterDataWithoutKey[col.name] = rows[0][col.name];
|
||||
}
|
||||
}
|
||||
|
||||
// 회사 코드, 작성자 추가
|
||||
masterData.company_code = companyCode;
|
||||
if (userId) {
|
||||
if (isAutoNumbering) {
|
||||
// 채번 모드: 동일한 마스터가 이미 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;
|
||||
Object.assign(masterData, masterDataWithoutKey);
|
||||
|
||||
// 회사 코드, 작성자 추가 (테이블에 해당 컬럼이 있을 때만)
|
||||
if (masterExistingCols.has("company_code")) {
|
||||
masterData.company_code = companyCode;
|
||||
}
|
||||
if (userId && masterExistingCols.has("writer")) {
|
||||
masterData.writer = userId;
|
||||
}
|
||||
|
||||
// 2b. 마스터 UPSERT
|
||||
const existingMaster = await client.query(
|
||||
`SELECT id FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`,
|
||||
[masterKey, companyCode]
|
||||
);
|
||||
|
||||
if (existingMaster.rows.length > 0) {
|
||||
// UPDATE
|
||||
const updateCols = Object.keys(masterData)
|
||||
.filter(k => k !== masterKeyColumn && k !== "id")
|
||||
.map((k, i) => `"${k}" = $${i + 1}`);
|
||||
const updateValues = Object.keys(masterData)
|
||||
.filter(k => k !== masterKeyColumn && k !== "id")
|
||||
.map(k => masterData[k]);
|
||||
|
||||
if (updateCols.length > 0) {
|
||||
await client.query(
|
||||
`UPDATE "${masterTable}"
|
||||
SET ${updateCols.join(", ")}, updated_date = NOW()
|
||||
WHERE "${masterKeyColumn}" = $${updateValues.length + 1} AND company_code = $${updateValues.length + 2}`,
|
||||
[...updateValues, masterKey, companyCode]
|
||||
);
|
||||
// 마스터 비-키 채번 컬럼 자동 생성 (매핑되지 않은 경우)
|
||||
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}`);
|
||||
}
|
||||
result.masterUpdated++;
|
||||
} else {
|
||||
// INSERT
|
||||
const insertCols = Object.keys(masterData);
|
||||
const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`);
|
||||
const insertValues = insertCols.map(k => masterData[k]);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO "${masterTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date)
|
||||
VALUES (${insertPlaceholders.join(", ")}, NOW())`,
|
||||
insertValues
|
||||
);
|
||||
result.masterInserted++;
|
||||
}
|
||||
|
||||
// 2c. 기존 디테일 삭제
|
||||
const deleteResult = await client.query(
|
||||
`DELETE FROM "${detailTable}" WHERE "${detailFkColumn}" = $1 AND company_code = $2`,
|
||||
[masterKey, companyCode]
|
||||
);
|
||||
result.detailDeleted += deleteResult.rowCount || 0;
|
||||
// INSERT SQL 생성 헬퍼 (created_date 존재 시만 추가)
|
||||
const buildInsertSQL = (table: string, data: Record<string, any>, existingCols: Set<string>) => {
|
||||
const cols = Object.keys(data);
|
||||
const hasCreatedDate = existingCols.has("created_date");
|
||||
const colList = hasCreatedDate ? [...cols, "created_date"] : cols;
|
||||
const placeholders = cols.map((_, i) => `$${i + 1}`);
|
||||
const valList = hasCreatedDate ? [...placeholders, "NOW()"] : placeholders;
|
||||
const values = cols.map(k => data[k]);
|
||||
return {
|
||||
sql: `INSERT INTO "${table}" (${colList.map(c => `"${c}"`).join(", ")}) VALUES (${valList.join(", ")})`,
|
||||
values,
|
||||
};
|
||||
};
|
||||
|
||||
// 2d. 새 디테일 INSERT
|
||||
if (isAutoNumbering && !existingMasterKey) {
|
||||
// 채번 모드 + 새 마스터: INSERT
|
||||
const { sql, values } = buildInsertSQL(masterTable, masterData, masterExistingCols);
|
||||
await client.query(sql, values);
|
||||
result.masterInserted++;
|
||||
} else if (!isAutoNumbering) {
|
||||
// 일반 모드: UPSERT (있으면 UPDATE, 없으면 INSERT)
|
||||
const existingMaster = await client.query(
|
||||
`SELECT 1 FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`,
|
||||
[masterKey, companyCode]
|
||||
);
|
||||
|
||||
if (existingMaster.rows.length > 0) {
|
||||
const updateCols = Object.keys(masterData)
|
||||
.filter(k => k !== masterKeyColumn && k !== "id")
|
||||
.map((k, i) => `"${k}" = $${i + 1}`);
|
||||
const updateValues = Object.keys(masterData)
|
||||
.filter(k => k !== masterKeyColumn && k !== "id")
|
||||
.map(k => masterData[k]);
|
||||
|
||||
if (updateCols.length > 0) {
|
||||
const updatedDateClause = masterExistingCols.has("updated_date") ? `, updated_date = NOW()` : "";
|
||||
await client.query(
|
||||
`UPDATE "${masterTable}"
|
||||
SET ${updateCols.join(", ")}${updatedDateClause}
|
||||
WHERE "${masterKeyColumn}" = $${updateValues.length + 1} AND company_code = $${updateValues.length + 2}`,
|
||||
[...updateValues, masterKey, companyCode]
|
||||
);
|
||||
}
|
||||
result.masterUpdated++;
|
||||
} else {
|
||||
const { sql, values } = buildInsertSQL(masterTable, masterData, masterExistingCols);
|
||||
await client.query(sql, values);
|
||||
result.masterInserted++;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 디테일 개별 행 UPSERT 처리
|
||||
for (const row of rows) {
|
||||
const detailData: Record<string, any> = {};
|
||||
|
||||
// FK 컬럼 추가
|
||||
// FK 컬럼에 마스터 키 주입
|
||||
detailData[detailFkColumn] = masterKey;
|
||||
detailData.company_code = companyCode;
|
||||
if (userId) {
|
||||
if (detailExistingCols.has("company_code")) {
|
||||
detailData.company_code = companyCode;
|
||||
}
|
||||
if (userId && detailExistingCols.has("writer")) {
|
||||
detailData.writer = userId;
|
||||
}
|
||||
|
||||
// 디테일 컬럼 데이터 추출
|
||||
// 디테일 컬럼 데이터 추출 (분할 패널 설정 컬럼 기준)
|
||||
for (const col of detailColumns) {
|
||||
if (row[col.name] !== undefined) {
|
||||
detailData[col.name] = row[col.name];
|
||||
}
|
||||
}
|
||||
|
||||
const insertCols = Object.keys(detailData);
|
||||
const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`);
|
||||
const insertValues = insertCols.map(k => detailData[k]);
|
||||
// 분할 패널에 없지만 엑셀에서 매핑된 디테일 컬럼도 포함
|
||||
// (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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO "${detailTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date)
|
||||
VALUES (${insertPlaceholders.join(", ")}, NOW())`,
|
||||
insertValues
|
||||
);
|
||||
result.detailInserted++;
|
||||
// 디테일 채번 컬럼 자동 생성 (매핑되지 않은 채번 컬럼에 값 주입)
|
||||
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(`마스터 키 ${masterKey} 처리 실패: ${error.message}`);
|
||||
logger.error(`마스터 키 ${masterKey} 처리 실패:`, error);
|
||||
result.errors.push(`그룹 처리 실패: ${error.message}`);
|
||||
logger.error(`그룹 처리 실패:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -632,7 +1009,7 @@ class MasterDetailExcelService {
|
||||
masterInserted: result.masterInserted,
|
||||
masterUpdated: result.masterUpdated,
|
||||
detailInserted: result.detailInserted,
|
||||
detailDeleted: result.detailDeleted,
|
||||
detailUpdated: result.detailUpdated,
|
||||
errors: result.errors.length,
|
||||
});
|
||||
|
||||
|
||||
@@ -60,6 +60,8 @@ export interface ExecutionContext {
|
||||
buttonContext?: ButtonContext;
|
||||
// 🆕 현재 실행 중인 소스 노드의 dataSourceType (context-data | table-all)
|
||||
currentNodeDataSourceType?: string;
|
||||
// 저장 전 원본 데이터 (after 타이밍에서 DB 기존값 비교용)
|
||||
originalData?: Record<string, any> | null;
|
||||
}
|
||||
|
||||
export interface ButtonContext {
|
||||
@@ -248,8 +250,14 @@ export class NodeFlowExecutionService {
|
||||
contextData.selectedRowsData ||
|
||||
contextData.context?.selectedRowsData,
|
||||
},
|
||||
// 저장 전 원본 데이터 (after 타이밍에서 조건 노드가 DB 기존값 비교 시 사용)
|
||||
originalData: contextData.originalData || null,
|
||||
};
|
||||
|
||||
if (context.originalData) {
|
||||
logger.info(`📦 저장 전 원본 데이터 전달됨 (originalData 필드 수: ${Object.keys(context.originalData).length})`);
|
||||
}
|
||||
|
||||
logger.info(`📦 실행 컨텍스트:`, {
|
||||
dataSourceType: context.dataSourceType,
|
||||
sourceDataCount: context.sourceData?.length || 0,
|
||||
@@ -2830,12 +2838,12 @@ export class NodeFlowExecutionService {
|
||||
inputData: any,
|
||||
context: ExecutionContext
|
||||
): Promise<any> {
|
||||
const { conditions, logic } = node.data;
|
||||
const { conditions, logic, targetLookup } = node.data;
|
||||
|
||||
logger.info(
|
||||
`🔍 조건 노드 실행 - inputData 타입: ${typeof inputData}, 배열 여부: ${Array.isArray(inputData)}, 길이: ${Array.isArray(inputData) ? inputData.length : "N/A"}`
|
||||
);
|
||||
logger.info(`🔍 조건 개수: ${conditions?.length || 0}, 로직: ${logic}`);
|
||||
logger.info(`🔍 조건 개수: ${conditions?.length || 0}, 로직: ${logic}, 타겟조회: ${targetLookup ? targetLookup.tableName : "없음"}`);
|
||||
|
||||
if (inputData) {
|
||||
console.log(
|
||||
@@ -2865,6 +2873,9 @@ export class NodeFlowExecutionService {
|
||||
|
||||
// 배열의 각 항목에 대해 조건 평가 (EXISTS 조건은 비동기)
|
||||
for (const item of inputData) {
|
||||
// 타겟 테이블 조회 (DB 기존값 비교용)
|
||||
const targetRow = await this.lookupTargetRow(targetLookup, item, context);
|
||||
|
||||
const results: boolean[] = [];
|
||||
|
||||
for (const condition of conditions) {
|
||||
@@ -2887,9 +2898,14 @@ export class NodeFlowExecutionService {
|
||||
`🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}`
|
||||
);
|
||||
} else {
|
||||
// 일반 연산자 처리
|
||||
// 비교값 결정: static(고정값) / field(같은 데이터 내 필드) / target(DB 기존값)
|
||||
let compareValue = condition.value;
|
||||
if (condition.valueType === "field") {
|
||||
if (condition.valueType === "target" && targetRow) {
|
||||
compareValue = targetRow[condition.value];
|
||||
logger.info(
|
||||
`🎯 타겟(DB) 비교: ${condition.field} (${fieldValue}) vs DB.${condition.value} (${compareValue})`
|
||||
);
|
||||
} else if (condition.valueType === "field") {
|
||||
compareValue = item[condition.value];
|
||||
logger.info(
|
||||
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
|
||||
@@ -2931,6 +2947,9 @@ export class NodeFlowExecutionService {
|
||||
}
|
||||
|
||||
// 단일 객체인 경우
|
||||
// 타겟 테이블 조회 (DB 기존값 비교용)
|
||||
const targetRow = await this.lookupTargetRow(targetLookup, inputData, context);
|
||||
|
||||
const results: boolean[] = [];
|
||||
|
||||
for (const condition of conditions) {
|
||||
@@ -2953,9 +2972,14 @@ export class NodeFlowExecutionService {
|
||||
`🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}`
|
||||
);
|
||||
} else {
|
||||
// 일반 연산자 처리
|
||||
// 비교값 결정: static(고정값) / field(같은 데이터 내 필드) / target(DB 기존값)
|
||||
let compareValue = condition.value;
|
||||
if (condition.valueType === "field") {
|
||||
if (condition.valueType === "target" && targetRow) {
|
||||
compareValue = targetRow[condition.value];
|
||||
logger.info(
|
||||
`🎯 타겟(DB) 비교: ${condition.field} (${fieldValue}) vs DB.${condition.value} (${compareValue})`
|
||||
);
|
||||
} else if (condition.valueType === "field") {
|
||||
compareValue = inputData[condition.value];
|
||||
logger.info(
|
||||
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
|
||||
@@ -2990,6 +3014,71 @@ export class NodeFlowExecutionService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건 노드의 타겟 테이블 조회 (DB 기존값 비교용)
|
||||
* targetLookup 설정이 있을 때, 소스 데이터의 키값으로 DB에서 기존 레코드를 조회
|
||||
*/
|
||||
private static async lookupTargetRow(
|
||||
targetLookup: any,
|
||||
sourceRow: any,
|
||||
context: ExecutionContext
|
||||
): Promise<any | null> {
|
||||
if (!targetLookup?.tableName || !targetLookup?.lookupKeys?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 저장 전 원본 데이터가 있으면 DB 조회 대신 원본 데이터 사용
|
||||
// (after 타이밍에서는 DB가 이미 업데이트되어 있으므로 원본 데이터가 필요)
|
||||
if (context.originalData && Object.keys(context.originalData).length > 0) {
|
||||
logger.info(`🎯 조건 노드: 저장 전 원본 데이터(originalData) 사용 (DB 조회 스킵)`);
|
||||
logger.info(`🎯 originalData 필드: ${Object.keys(context.originalData).join(", ")}`);
|
||||
return context.originalData;
|
||||
}
|
||||
|
||||
const whereConditions = targetLookup.lookupKeys
|
||||
.map((key: any, idx: number) => `"${key.targetField}" = $${idx + 1}`)
|
||||
.join(" AND ");
|
||||
|
||||
const lookupValues = targetLookup.lookupKeys.map(
|
||||
(key: any) => sourceRow[key.sourceField]
|
||||
);
|
||||
|
||||
// 키값이 비어있으면 조회 불필요
|
||||
if (lookupValues.some((v: any) => v === null || v === undefined || v === "")) {
|
||||
logger.info(`⚠️ 조건 노드 타겟 조회: 키값이 비어있어 스킵`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// company_code 필터링 (멀티테넌시)
|
||||
const companyCode = context.buttonContext?.companyCode || sourceRow.company_code;
|
||||
let sql = `SELECT * FROM "${targetLookup.tableName}" WHERE ${whereConditions}`;
|
||||
const params = [...lookupValues];
|
||||
|
||||
if (companyCode && companyCode !== "*") {
|
||||
sql += ` AND company_code = $${params.length + 1}`;
|
||||
params.push(companyCode);
|
||||
}
|
||||
|
||||
sql += " LIMIT 1";
|
||||
|
||||
logger.info(`🎯 조건 노드 타겟 조회: ${targetLookup.tableName}, 조건: ${whereConditions}, 값: ${JSON.stringify(lookupValues)}`);
|
||||
|
||||
const targetRow = await queryOne(sql, params);
|
||||
|
||||
if (targetRow) {
|
||||
logger.info(`🎯 타겟 데이터 조회 성공`);
|
||||
} else {
|
||||
logger.info(`🎯 타겟 데이터 없음 (신규 레코드)`);
|
||||
}
|
||||
|
||||
return targetRow;
|
||||
} catch (error: any) {
|
||||
logger.warn(`⚠️ 조건 노드 타겟 조회 실패: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* EXISTS_IN / NOT_EXISTS_IN 조건 평가
|
||||
* 다른 테이블에 값이 존재하는지 확인
|
||||
|
||||
@@ -885,9 +885,9 @@ class NumberingRuleService {
|
||||
const rule = await this.getRuleById(ruleId, companyCode);
|
||||
if (!rule) throw new Error("규칙을 찾을 수 없습니다");
|
||||
|
||||
const parts = rule.parts
|
||||
const parts = await Promise.all(rule.parts
|
||||
.sort((a: any, b: any) => a.order - b.order)
|
||||
.map((part: any) => {
|
||||
.map(async (part: any) => {
|
||||
if (part.generationMethod === "manual") {
|
||||
// 수동 입력 - 항상 ____ 마커 사용 (프론트엔드에서 편집 가능하게 처리)
|
||||
// placeholder 텍스트는 프론트엔드에서 별도로 표시
|
||||
@@ -982,17 +982,52 @@ class NumberingRuleService {
|
||||
// 카테고리 매핑에서 해당 값에 대한 형식 찾기
|
||||
// selectedValue는 valueCode일 수 있음 (V2Select에서 valueCode를 value로 사용)
|
||||
const selectedValueStr = String(selectedValue);
|
||||
const mapping = categoryMappings.find((m: any) => {
|
||||
// ID로 매칭
|
||||
let mapping = categoryMappings.find((m: any) => {
|
||||
// ID로 매칭 (기존 방식: V2Select가 valueId를 사용하던 경우)
|
||||
if (m.categoryValueId?.toString() === selectedValueStr)
|
||||
return true;
|
||||
// 라벨로 매칭
|
||||
if (m.categoryValueLabel === selectedValueStr) return true;
|
||||
// valueCode로 매칭 (라벨과 동일할 수 있음)
|
||||
// valueCode로 매칭 (매핑에 categoryValueCode가 있는 경우)
|
||||
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr)
|
||||
return true;
|
||||
// 라벨로 매칭 (폴백)
|
||||
if (m.categoryValueLabel === selectedValueStr) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
// 매핑을 못 찾았으면 category_values 테이블에서 valueCode → valueId 역변환 시도
|
||||
if (!mapping) {
|
||||
try {
|
||||
const pool = getPool();
|
||||
const [catTableName, catColumnName] = categoryKey.includes(".")
|
||||
? categoryKey.split(".")
|
||||
: [categoryKey, categoryKey];
|
||||
const cvResult = await pool.query(
|
||||
`SELECT value_id, value_code, value_label FROM category_values
|
||||
WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
|
||||
[catTableName, catColumnName, selectedValueStr]
|
||||
);
|
||||
if (cvResult.rows.length > 0) {
|
||||
const resolvedId = cvResult.rows[0].value_id;
|
||||
const resolvedLabel = cvResult.rows[0].value_label;
|
||||
mapping = categoryMappings.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === String(resolvedId)) return true;
|
||||
if (m.categoryValueLabel === resolvedLabel) return true;
|
||||
return false;
|
||||
});
|
||||
if (mapping) {
|
||||
logger.info("카테고리 매핑 역변환 성공 (valueCode→valueId)", {
|
||||
valueCode: selectedValueStr,
|
||||
resolvedId,
|
||||
resolvedLabel,
|
||||
format: mapping.format,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (lookupError: any) {
|
||||
logger.warn("카테고리 값 역변환 조회 실패", { error: lookupError.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (mapping) {
|
||||
logger.info("카테고리 매핑 적용", {
|
||||
selectedValue,
|
||||
@@ -1016,7 +1051,7 @@ class NumberingRuleService {
|
||||
logger.warn("알 수 없는 파트 타입", { partType: part.partType });
|
||||
return "";
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
const previewCode = parts.join(rule.separator || "");
|
||||
logger.info("코드 미리보기 생성", {
|
||||
@@ -1059,9 +1094,9 @@ class NumberingRuleService {
|
||||
if (manualParts.length > 0 && userInputCode) {
|
||||
// 프리뷰 코드를 생성해서 ____ 위치 파악
|
||||
// 🔧 category 파트도 처리하여 올바른 템플릿 생성
|
||||
const previewParts = rule.parts
|
||||
const previewParts = await Promise.all(rule.parts
|
||||
.sort((a: any, b: any) => a.order - b.order)
|
||||
.map((part: any) => {
|
||||
.map(async (part: any) => {
|
||||
if (part.generationMethod === "manual") {
|
||||
return "____";
|
||||
}
|
||||
@@ -1077,36 +1112,57 @@ class NumberingRuleService {
|
||||
return "DATEPART"; // 날짜 자리 표시
|
||||
case "category": {
|
||||
// 카테고리 파트: formData에서 실제 값을 가져와서 매핑된 형식 사용
|
||||
const categoryKey = autoConfig.categoryKey;
|
||||
const categoryMappings = autoConfig.categoryMappings || [];
|
||||
const catKey2 = autoConfig.categoryKey;
|
||||
const catMappings2 = autoConfig.categoryMappings || [];
|
||||
|
||||
if (!categoryKey || !formData) {
|
||||
if (!catKey2 || !formData) {
|
||||
return "CATEGORY"; // 폴백
|
||||
}
|
||||
|
||||
const columnName = categoryKey.includes(".")
|
||||
? categoryKey.split(".")[1]
|
||||
: categoryKey;
|
||||
const selectedValue = formData[columnName];
|
||||
const colName2 = catKey2.includes(".")
|
||||
? catKey2.split(".")[1]
|
||||
: catKey2;
|
||||
const selVal2 = formData[colName2];
|
||||
|
||||
if (!selectedValue) {
|
||||
if (!selVal2) {
|
||||
return "CATEGORY"; // 폴백
|
||||
}
|
||||
|
||||
const selectedValueStr = String(selectedValue);
|
||||
const mapping = categoryMappings.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === selectedValueStr)
|
||||
return true;
|
||||
if (m.categoryValueLabel === selectedValueStr) return true;
|
||||
const selValStr2 = String(selVal2);
|
||||
let catMapping2 = catMappings2.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === selValStr2) return true;
|
||||
if (m.categoryValueCode && m.categoryValueCode === selValStr2) return true;
|
||||
if (m.categoryValueLabel === selValStr2) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
return mapping?.format || "CATEGORY";
|
||||
// valueCode → valueId 역변환 시도
|
||||
if (!catMapping2) {
|
||||
try {
|
||||
const pool2 = getPool();
|
||||
const [ct2, cc2] = catKey2.includes(".") ? catKey2.split(".") : [catKey2, catKey2];
|
||||
const cvr2 = await pool2.query(
|
||||
`SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
|
||||
[ct2, cc2, selValStr2]
|
||||
);
|
||||
if (cvr2.rows.length > 0) {
|
||||
const rid2 = cvr2.rows[0].value_id;
|
||||
const rlabel2 = cvr2.rows[0].value_label;
|
||||
catMapping2 = catMappings2.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === String(rid2)) return true;
|
||||
if (m.categoryValueLabel === rlabel2) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return catMapping2?.format || "CATEGORY";
|
||||
}
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
const separator = rule.separator || "";
|
||||
const previewTemplate = previewParts.join(separator);
|
||||
@@ -1150,9 +1206,9 @@ class NumberingRuleService {
|
||||
}
|
||||
|
||||
let manualPartIndex = 0;
|
||||
const parts = rule.parts
|
||||
const parts = await Promise.all(rule.parts
|
||||
.sort((a: any, b: any) => a.order - b.order)
|
||||
.map((part: any) => {
|
||||
.map(async (part: any) => {
|
||||
if (part.generationMethod === "manual") {
|
||||
// 추출된 수동 입력 값 사용, 없으면 기본값 사용
|
||||
const manualValue =
|
||||
@@ -1267,28 +1323,53 @@ class NumberingRuleService {
|
||||
|
||||
// 카테고리 매핑에서 해당 값에 대한 형식 찾기
|
||||
const selectedValueStr = String(selectedValue);
|
||||
const mapping = categoryMappings.find((m: any) => {
|
||||
// ID로 매칭
|
||||
if (m.categoryValueId?.toString() === selectedValueStr)
|
||||
return true;
|
||||
// 라벨로 매칭
|
||||
let allocMapping = categoryMappings.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === selectedValueStr) return true;
|
||||
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) return true;
|
||||
if (m.categoryValueLabel === selectedValueStr) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
if (mapping) {
|
||||
// valueCode → valueId 역변환 시도
|
||||
if (!allocMapping) {
|
||||
try {
|
||||
const pool3 = getPool();
|
||||
const [ct3, cc3] = categoryKey.includes(".") ? categoryKey.split(".") : [categoryKey, categoryKey];
|
||||
const cvr3 = await pool3.query(
|
||||
`SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
|
||||
[ct3, cc3, selectedValueStr]
|
||||
);
|
||||
if (cvr3.rows.length > 0) {
|
||||
const rid3 = cvr3.rows[0].value_id;
|
||||
const rlabel3 = cvr3.rows[0].value_label;
|
||||
allocMapping = categoryMappings.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === String(rid3)) return true;
|
||||
if (m.categoryValueLabel === rlabel3) return true;
|
||||
return false;
|
||||
});
|
||||
if (allocMapping) {
|
||||
logger.info("allocateCode: 카테고리 매핑 역변환 성공", {
|
||||
valueCode: selectedValueStr, resolvedId: rid3, format: allocMapping.format,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (allocMapping) {
|
||||
logger.info("allocateCode: 카테고리 매핑 적용", {
|
||||
selectedValue,
|
||||
format: mapping.format,
|
||||
categoryValueLabel: mapping.categoryValueLabel,
|
||||
format: allocMapping.format,
|
||||
categoryValueLabel: allocMapping.categoryValueLabel,
|
||||
});
|
||||
return mapping.format || "";
|
||||
return allocMapping.format || "";
|
||||
}
|
||||
|
||||
logger.warn("allocateCode: 카테고리 매핑을 찾을 수 없음", {
|
||||
selectedValue,
|
||||
availableMappings: categoryMappings.map((m: any) => ({
|
||||
id: m.categoryValueId,
|
||||
code: m.categoryValueCode,
|
||||
label: m.categoryValueLabel,
|
||||
})),
|
||||
});
|
||||
@@ -1299,7 +1380,7 @@ class NumberingRuleService {
|
||||
logger.warn("알 수 없는 파트 타입", { partType: part.partType });
|
||||
return "";
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
const allocatedCode = parts.join(rule.separator || "");
|
||||
|
||||
|
||||
@@ -1739,7 +1739,7 @@ export class ScreenManagementService {
|
||||
|
||||
// V2 레이아웃이 있으면 V2 형식으로 반환
|
||||
if (v2Layout && v2Layout.layout_data) {
|
||||
console.log(`V2 레이아웃 발견, V2 형식으로 반환`);
|
||||
|
||||
const layoutData = v2Layout.layout_data;
|
||||
|
||||
// URL에서 컴포넌트 타입 추출하는 헬퍼 함수
|
||||
@@ -1799,7 +1799,7 @@ export class ScreenManagementService {
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`V2 레이아웃 없음, V1 테이블 조회`);
|
||||
|
||||
|
||||
const layouts = await query<any>(
|
||||
`SELECT * FROM screen_layouts
|
||||
@@ -4245,16 +4245,16 @@ export class ScreenManagementService {
|
||||
},
|
||||
);
|
||||
|
||||
// V2 레이아웃 저장 (UPSERT)
|
||||
// V2 레이아웃 저장 (UPSERT) - layer_id 포함
|
||||
await client.query(
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, NOW(), NOW())
|
||||
ON CONFLICT (screen_id, company_code)
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layout_data, created_at, updated_at)
|
||||
VALUES ($1, $2, 1, $3, NOW(), NOW())
|
||||
ON CONFLICT (screen_id, company_code, layer_id)
|
||||
DO UPDATE SET layout_data = $3, updated_at = NOW()`,
|
||||
[newScreen.screen_id, targetCompanyCode, JSON.stringify(updatedLayoutData)],
|
||||
);
|
||||
|
||||
console.log(` ✅ V2 레이아웃 복사 완료: ${components.length}개 컴포넌트`);
|
||||
|
||||
} catch (error) {
|
||||
console.error("V2 레이아웃 복사 중 오류:", error);
|
||||
// 레이아웃 복사 실패해도 화면 생성은 유지
|
||||
@@ -5045,8 +5045,7 @@ export class ScreenManagementService {
|
||||
companyCode: string,
|
||||
userType?: string,
|
||||
): Promise<any | null> {
|
||||
console.log(`=== V2 레이아웃 로드 시작 ===`);
|
||||
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}, 사용자 유형: ${userType}`);
|
||||
|
||||
|
||||
// SUPER_ADMIN 여부 확인
|
||||
const isSuperAdmin = userType === "SUPER_ADMIN";
|
||||
@@ -5073,67 +5072,94 @@ export class ScreenManagementService {
|
||||
|
||||
let layout: { layout_data: any } | null = null;
|
||||
|
||||
// 🆕 기본 레이어(layer_id=1)를 우선 로드
|
||||
// SUPER_ADMIN인 경우: 화면의 회사 코드로 레이아웃 조회
|
||||
if (isSuperAdmin) {
|
||||
// 1. 화면 정의의 회사 코드로 레이아웃 조회
|
||||
// 1. 화면 정의의 회사 코드 + 기본 레이어
|
||||
layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2`,
|
||||
WHERE screen_id = $1 AND company_code = $2 AND layer_id = 1`,
|
||||
[screenId, existingScreen.company_code],
|
||||
);
|
||||
|
||||
// 2. 화면 정의의 회사 코드로 없으면, 해당 화면의 모든 레이아웃 중 첫 번째 조회
|
||||
// 2. 기본 레이어 없으면 layer_id 조건 없이 조회 (하위 호환)
|
||||
if (!layout) {
|
||||
layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2
|
||||
ORDER BY layer_id ASC
|
||||
LIMIT 1`,
|
||||
[screenId, existingScreen.company_code],
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 화면 정의의 회사 코드로 없으면, 해당 화면의 모든 레이아웃 중 첫 번째
|
||||
if (!layout) {
|
||||
layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1
|
||||
ORDER BY updated_at DESC
|
||||
ORDER BY layer_id ASC
|
||||
LIMIT 1`,
|
||||
[screenId],
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 일반 사용자: 기존 로직 (회사별 우선, 없으면 공통(*) 조회)
|
||||
// 일반 사용자: 회사별 우선 + 기본 레이어
|
||||
layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2`,
|
||||
WHERE screen_id = $1 AND company_code = $2 AND layer_id = 1`,
|
||||
[screenId, companyCode],
|
||||
);
|
||||
|
||||
// 회사별 기본 레이어 없으면 layer_id 조건 없이 (하위 호환)
|
||||
if (!layout) {
|
||||
layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2
|
||||
ORDER BY layer_id ASC
|
||||
LIMIT 1`,
|
||||
[screenId, companyCode],
|
||||
);
|
||||
}
|
||||
|
||||
// 회사별 레이아웃이 없으면 공통(*) 레이아웃 조회
|
||||
if (!layout && companyCode !== "*") {
|
||||
layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = '*'`,
|
||||
WHERE screen_id = $1 AND company_code = '*'
|
||||
ORDER BY layer_id ASC
|
||||
LIMIT 1`,
|
||||
[screenId],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!layout) {
|
||||
console.log(`V2 레이아웃 없음: screen_id=${screenId}`);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`V2 레이아웃 로드 완료: ${layout.layout_data?.components?.length || 0}개 컴포넌트`,
|
||||
);
|
||||
|
||||
return layout.layout_data;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 레이아웃 저장 (1 레코드 방식)
|
||||
* - screen_layouts_v2 테이블에 화면당 1개 레코드 저장
|
||||
* - layout_data JSON에 모든 컴포넌트 포함
|
||||
* V2 레이아웃 저장 (레이어별 저장)
|
||||
* - screen_layouts_v2 테이블에 화면당 레이어별 1개 레코드 저장
|
||||
* - layout_data JSON에 해당 레이어의 컴포넌트 포함
|
||||
*/
|
||||
async saveLayoutV2(
|
||||
screenId: number,
|
||||
layoutData: any,
|
||||
companyCode: string,
|
||||
): Promise<void> {
|
||||
console.log(`=== V2 레이아웃 저장 시작 ===`);
|
||||
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`);
|
||||
console.log(`컴포넌트 수: ${layoutData.components?.length || 0}`);
|
||||
const layerId = layoutData.layerId || 1;
|
||||
const layerName = layoutData.layerName || (layerId === 1 ? '기본 레이어' : `레이어 ${layerId}`);
|
||||
// conditionConfig가 명시적으로 전달되었는지 확인 (undefined = 미전달, null/object = 명시적 전달)
|
||||
const hasConditionConfig = 'conditionConfig' in layoutData;
|
||||
const conditionConfig = layoutData.conditionConfig || null;
|
||||
|
||||
|
||||
|
||||
// 권한 확인
|
||||
const screens = await query<{ company_code: string | null }>(
|
||||
@@ -5151,22 +5177,666 @@ export class ScreenManagementService {
|
||||
throw new Error("이 화면의 레이아웃을 저장할 권한이 없습니다.");
|
||||
}
|
||||
|
||||
// 버전 정보 추가 (updatedAt은 DB 컬럼 updated_at으로 관리)
|
||||
// 저장할 layout_data에서 레이어 메타 정보 제거 (순수 레이아웃만 저장)
|
||||
const { layerId: _lid, layerName: _ln, conditionConfig: _cc, ...pureLayoutData } = layoutData;
|
||||
const dataToSave = {
|
||||
version: "2.0",
|
||||
...layoutData
|
||||
...pureLayoutData,
|
||||
};
|
||||
|
||||
if (hasConditionConfig) {
|
||||
// conditionConfig가 명시적으로 전달된 경우: condition_config도 함께 저장
|
||||
await query(
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, condition_config, layout_data, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
||||
ON CONFLICT (screen_id, company_code, layer_id)
|
||||
DO UPDATE SET layout_data = $6, layer_name = $4, condition_config = $5, updated_at = NOW()`,
|
||||
[screenId, companyCode, layerId, layerName, conditionConfig ? JSON.stringify(conditionConfig) : null, JSON.stringify(dataToSave)],
|
||||
);
|
||||
} else {
|
||||
// conditionConfig가 전달되지 않은 경우: 기존 condition_config 유지, layout_data만 업데이트
|
||||
await query(
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
|
||||
ON CONFLICT (screen_id, company_code, layer_id)
|
||||
DO UPDATE SET layout_data = $5, layer_name = $4, updated_at = NOW()`,
|
||||
[screenId, companyCode, layerId, layerName, JSON.stringify(dataToSave)],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면의 모든 레이어 목록 조회
|
||||
* 레이어가 없으면 기본 레이어를 자동 생성
|
||||
*/
|
||||
async getScreenLayers(
|
||||
screenId: number,
|
||||
companyCode: string,
|
||||
): Promise<any[]> {
|
||||
let layers;
|
||||
|
||||
if (companyCode === "*") {
|
||||
layers = await query<any>(
|
||||
`SELECT layer_id, layer_name, condition_config,
|
||||
jsonb_array_length(COALESCE(layout_data->'components', '[]'::jsonb)) as component_count,
|
||||
updated_at
|
||||
FROM screen_layouts_v2
|
||||
WHERE screen_id = $1
|
||||
ORDER BY layer_id`,
|
||||
[screenId],
|
||||
);
|
||||
} else {
|
||||
layers = await query<any>(
|
||||
`SELECT layer_id, layer_name, condition_config,
|
||||
jsonb_array_length(COALESCE(layout_data->'components', '[]'::jsonb)) as component_count,
|
||||
updated_at
|
||||
FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2
|
||||
ORDER BY layer_id`,
|
||||
[screenId, companyCode],
|
||||
);
|
||||
|
||||
// 회사별 레이어가 없으면 공통(*) 레이어 조회
|
||||
if (layers.length === 0 && companyCode !== "*") {
|
||||
layers = await query<any>(
|
||||
`SELECT layer_id, layer_name, condition_config,
|
||||
jsonb_array_length(COALESCE(layout_data->'components', '[]'::jsonb)) as component_count,
|
||||
updated_at
|
||||
FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = '*'
|
||||
ORDER BY layer_id`,
|
||||
[screenId],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 레이어가 없으면 기본 레이어 자동 생성
|
||||
if (layers.length === 0) {
|
||||
const defaultLayout = JSON.stringify({ version: "2.0", components: [] });
|
||||
await query(
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, created_at, updated_at)
|
||||
VALUES ($1, $2, 1, '기본 레이어', $3, NOW(), NOW())
|
||||
ON CONFLICT (screen_id, company_code, layer_id) DO NOTHING`,
|
||||
[screenId, companyCode, defaultLayout],
|
||||
);
|
||||
console.log(`기본 레이어 자동 생성: screen_id=${screenId}, company_code=${companyCode}`);
|
||||
|
||||
// 다시 조회
|
||||
layers = await query<any>(
|
||||
`SELECT layer_id, layer_name, condition_config,
|
||||
jsonb_array_length(COALESCE(layout_data->'components', '[]'::jsonb)) as component_count,
|
||||
updated_at
|
||||
FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2
|
||||
ORDER BY layer_id`,
|
||||
[screenId, companyCode],
|
||||
);
|
||||
}
|
||||
|
||||
return layers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 레이어의 레이아웃 조회
|
||||
*/
|
||||
async getLayerLayout(
|
||||
screenId: number,
|
||||
layerId: number,
|
||||
companyCode: string,
|
||||
): Promise<any> {
|
||||
let layout = await queryOne<{ layout_data: any; layer_name: string; condition_config: any }>(
|
||||
`SELECT layout_data, layer_name, condition_config FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`,
|
||||
[screenId, companyCode, layerId],
|
||||
);
|
||||
|
||||
// 회사별 레이어가 없으면 공통(*) 조회
|
||||
if (!layout && companyCode !== "*") {
|
||||
layout = await queryOne<{ layout_data: any; layer_name: string; condition_config: any }>(
|
||||
`SELECT layout_data, layer_name, condition_config FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = '*' AND layer_id = $2`,
|
||||
[screenId, layerId],
|
||||
);
|
||||
}
|
||||
|
||||
if (!layout) return null;
|
||||
|
||||
return {
|
||||
...layout.layout_data,
|
||||
layerId,
|
||||
layerName: layout.layer_name,
|
||||
conditionConfig: layout.condition_config,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이어 삭제
|
||||
*/
|
||||
async deleteLayer(
|
||||
screenId: number,
|
||||
layerId: number,
|
||||
companyCode: string,
|
||||
): Promise<void> {
|
||||
if (layerId === 1) {
|
||||
throw new Error("기본 레이어는 삭제할 수 없습니다.");
|
||||
}
|
||||
|
||||
await query(
|
||||
`DELETE FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`,
|
||||
[screenId, companyCode, layerId],
|
||||
);
|
||||
|
||||
console.log(`레이어 삭제 완료: screen_id=${screenId}, layer_id=${layerId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이어 조건 설정 업데이트
|
||||
*/
|
||||
async updateLayerCondition(
|
||||
screenId: number,
|
||||
layerId: number,
|
||||
companyCode: string,
|
||||
conditionConfig: any,
|
||||
layerName?: string,
|
||||
): Promise<void> {
|
||||
const setClauses = ['condition_config = $4', 'updated_at = NOW()'];
|
||||
const params: any[] = [screenId, companyCode, layerId, conditionConfig ? JSON.stringify(conditionConfig) : null];
|
||||
|
||||
if (layerName) {
|
||||
setClauses.push(`layer_name = $${params.length + 1}`);
|
||||
params.push(layerName);
|
||||
}
|
||||
|
||||
await query(
|
||||
`UPDATE screen_layouts_v2 SET ${setClauses.join(', ')}
|
||||
WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 조건부 영역(Zone) 관리
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 화면의 조건부 영역(Zone) 목록 조회
|
||||
*/
|
||||
async getScreenZones(screenId: number, companyCode: string): Promise<any[]> {
|
||||
let zones;
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 회사 Zone 조회 가능
|
||||
zones = await query<any>(
|
||||
`SELECT * FROM screen_conditional_zones WHERE screen_id = $1 ORDER BY zone_id`,
|
||||
[screenId],
|
||||
);
|
||||
} else {
|
||||
// 일반 회사: 자사 Zone + 공통(*) Zone 조회
|
||||
zones = await query<any>(
|
||||
`SELECT * FROM screen_conditional_zones
|
||||
WHERE screen_id = $1 AND (company_code = $2 OR company_code = '*')
|
||||
ORDER BY zone_id`,
|
||||
[screenId, companyCode],
|
||||
);
|
||||
}
|
||||
return zones;
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건부 영역(Zone) 생성
|
||||
*/
|
||||
async createZone(
|
||||
screenId: number,
|
||||
companyCode: string,
|
||||
zoneData: {
|
||||
zone_name?: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
trigger_component_id?: string;
|
||||
trigger_operator?: string;
|
||||
},
|
||||
): Promise<any> {
|
||||
const result = await queryOne<any>(
|
||||
`INSERT INTO screen_conditional_zones
|
||||
(screen_id, company_code, zone_name, x, y, width, height, trigger_component_id, trigger_operator)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING *`,
|
||||
[
|
||||
screenId,
|
||||
companyCode,
|
||||
zoneData.zone_name || '조건부 영역',
|
||||
zoneData.x,
|
||||
zoneData.y,
|
||||
zoneData.width,
|
||||
zoneData.height,
|
||||
zoneData.trigger_component_id || null,
|
||||
zoneData.trigger_operator || 'eq',
|
||||
],
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건부 영역(Zone) 업데이트 (위치/크기/트리거)
|
||||
*/
|
||||
async updateZone(
|
||||
zoneId: number,
|
||||
companyCode: string,
|
||||
updates: {
|
||||
zone_name?: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
trigger_component_id?: string;
|
||||
trigger_operator?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const setClauses: string[] = ['updated_at = NOW()'];
|
||||
const params: any[] = [zoneId, companyCode];
|
||||
let paramIdx = 3;
|
||||
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value !== undefined) {
|
||||
setClauses.push(`${key} = $${paramIdx}`);
|
||||
params.push(value);
|
||||
paramIdx++;
|
||||
}
|
||||
}
|
||||
|
||||
await query(
|
||||
`UPDATE screen_conditional_zones SET ${setClauses.join(', ')}
|
||||
WHERE zone_id = $1 AND company_code = $2`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건부 영역(Zone) 삭제 + 소속 레이어들의 condition_config 정리
|
||||
*/
|
||||
async deleteZone(zoneId: number, companyCode: string): Promise<void> {
|
||||
// Zone에 소속된 레이어들의 condition_config에서 zone_id 제거
|
||||
await query(
|
||||
`UPDATE screen_layouts_v2 SET condition_config = NULL, updated_at = NOW()
|
||||
WHERE company_code = $1 AND condition_config->>'zone_id' = $2::text`,
|
||||
[companyCode, String(zoneId)],
|
||||
);
|
||||
|
||||
await query(
|
||||
`DELETE FROM screen_conditional_zones WHERE zone_id = $1 AND company_code = $2`,
|
||||
[zoneId, companyCode],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zone에 레이어 추가 (빈 레이아웃으로 새 레이어 생성 + zone_id 할당)
|
||||
*/
|
||||
async addLayerToZone(
|
||||
screenId: number,
|
||||
companyCode: string,
|
||||
zoneId: number,
|
||||
conditionValue: string,
|
||||
layerName?: string,
|
||||
): Promise<{ layerId: number }> {
|
||||
// 다음 layer_id 계산
|
||||
const maxResult = await queryOne<{ max_id: number }>(
|
||||
`SELECT COALESCE(MAX(layer_id), 1) as max_id FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2`,
|
||||
[screenId, companyCode],
|
||||
);
|
||||
const newLayerId = (maxResult?.max_id || 1) + 1;
|
||||
|
||||
// Zone 정보로 캔버스 크기 결정 (company_code 필터링 필수)
|
||||
const zone = await queryOne<any>(
|
||||
`SELECT * FROM screen_conditional_zones WHERE zone_id = $1 AND company_code = $2`,
|
||||
[zoneId, companyCode],
|
||||
);
|
||||
|
||||
const layoutData = {
|
||||
version: "2.1",
|
||||
components: [],
|
||||
screenResolution: zone
|
||||
? { width: zone.width, height: zone.height }
|
||||
: { width: 800, height: 200 },
|
||||
};
|
||||
|
||||
const conditionConfig = {
|
||||
zone_id: zoneId,
|
||||
condition_value: conditionValue,
|
||||
};
|
||||
|
||||
await query(
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, layer_id, layer_name, condition_config)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (screen_id, company_code, layer_id) DO UPDATE
|
||||
SET layout_data = EXCLUDED.layout_data,
|
||||
layer_name = EXCLUDED.layer_name,
|
||||
condition_config = EXCLUDED.condition_config,
|
||||
updated_at = NOW()`,
|
||||
[screenId, companyCode, JSON.stringify(layoutData), newLayerId, layerName || `레이어 ${newLayerId}`, JSON.stringify(conditionConfig)],
|
||||
);
|
||||
|
||||
return { layerId: newLayerId };
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// POP 레이아웃 관리 (모바일/태블릿)
|
||||
// v2.0: 4모드 레이아웃 지원 (태블릿 가로/세로, 모바일 가로/세로)
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* POP v1 → v2 마이그레이션 (백엔드)
|
||||
* - 단일 sections 배열 → 4모드별 layouts + 공유 sections/components
|
||||
*/
|
||||
private migratePopV1ToV2(v1Data: any): any {
|
||||
console.log("POP v1 → v2 마이그레이션 시작");
|
||||
|
||||
// 기본 v2 구조
|
||||
const v2Data: any = {
|
||||
version: "pop-2.0",
|
||||
layouts: {
|
||||
tablet_landscape: { sectionPositions: {}, componentPositions: {} },
|
||||
tablet_portrait: { sectionPositions: {}, componentPositions: {} },
|
||||
mobile_landscape: { sectionPositions: {}, componentPositions: {} },
|
||||
mobile_portrait: { sectionPositions: {}, componentPositions: {} },
|
||||
},
|
||||
sections: {},
|
||||
components: {},
|
||||
dataFlow: {
|
||||
sectionConnections: [],
|
||||
},
|
||||
settings: {
|
||||
touchTargetMin: 48,
|
||||
mode: "normal",
|
||||
canvasGrid: v1Data.canvasGrid || { columns: 24, rowHeight: 20, gap: 4 },
|
||||
},
|
||||
metadata: v1Data.metadata,
|
||||
};
|
||||
|
||||
// v1 섹션 배열 처리
|
||||
const sections = v1Data.sections || [];
|
||||
const modeKeys = ["tablet_landscape", "tablet_portrait", "mobile_landscape", "mobile_portrait"];
|
||||
|
||||
for (const section of sections) {
|
||||
// 섹션 정의 생성
|
||||
v2Data.sections[section.id] = {
|
||||
id: section.id,
|
||||
label: section.label,
|
||||
componentIds: (section.components || []).map((c: any) => c.id),
|
||||
innerGrid: section.innerGrid || { columns: 3, rows: 3, gap: 4 },
|
||||
style: section.style,
|
||||
};
|
||||
|
||||
// 섹션 위치 복사 (4모드 모두 동일)
|
||||
const sectionPos = section.grid || { col: 1, row: 1, colSpan: 3, rowSpan: 4 };
|
||||
for (const mode of modeKeys) {
|
||||
v2Data.layouts[mode].sectionPositions[section.id] = { ...sectionPos };
|
||||
}
|
||||
|
||||
// 컴포넌트별 처리
|
||||
for (const comp of section.components || []) {
|
||||
// 컴포넌트 정의 생성
|
||||
v2Data.components[comp.id] = {
|
||||
id: comp.id,
|
||||
type: comp.type,
|
||||
label: comp.label,
|
||||
dataBinding: comp.dataBinding,
|
||||
style: comp.style,
|
||||
config: comp.config,
|
||||
};
|
||||
|
||||
// 컴포넌트 위치 복사 (4모드 모두 동일)
|
||||
const compPos = comp.grid || { col: 1, row: 1, colSpan: 1, rowSpan: 1 };
|
||||
for (const mode of modeKeys) {
|
||||
v2Data.layouts[mode].componentPositions[comp.id] = { ...compPos };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sectionCount = Object.keys(v2Data.sections).length;
|
||||
const componentCount = Object.keys(v2Data.components).length;
|
||||
console.log(`POP v1 → v2 마이그레이션 완료: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`);
|
||||
|
||||
return v2Data;
|
||||
}
|
||||
|
||||
/**
|
||||
* POP 레이아웃 조회
|
||||
* - screen_layouts_pop 테이블에서 화면당 1개 레코드 조회
|
||||
* - v1 데이터는 자동으로 v2로 마이그레이션하여 반환
|
||||
*/
|
||||
async getLayoutPop(
|
||||
screenId: number,
|
||||
companyCode: string,
|
||||
userType?: string,
|
||||
): Promise<any | null> {
|
||||
console.log(`=== POP 레이아웃 로드 시작 ===`);
|
||||
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}, 사용자 유형: ${userType}`);
|
||||
|
||||
// SUPER_ADMIN 여부 확인
|
||||
const isSuperAdmin = userType === "SUPER_ADMIN";
|
||||
|
||||
// 권한 확인
|
||||
const screens = await query<{
|
||||
company_code: string | null;
|
||||
table_name: string | null;
|
||||
}>(
|
||||
`SELECT company_code, table_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
|
||||
[screenId],
|
||||
);
|
||||
|
||||
if (screens.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existingScreen = screens[0];
|
||||
|
||||
// SUPER_ADMIN이 아니고 회사 코드가 다르면 권한 없음
|
||||
if (!isSuperAdmin && companyCode !== "*" && existingScreen.company_code !== companyCode) {
|
||||
throw new Error("이 화면의 POP 레이아웃을 조회할 권한이 없습니다.");
|
||||
}
|
||||
|
||||
let layout: { layout_data: any } | null = null;
|
||||
|
||||
// SUPER_ADMIN인 경우: 화면의 회사 코드로 레이아웃 조회
|
||||
if (isSuperAdmin) {
|
||||
// 1. 화면 정의의 회사 코드로 레이아웃 조회
|
||||
layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_pop
|
||||
WHERE screen_id = $1 AND company_code = $2`,
|
||||
[screenId, existingScreen.company_code],
|
||||
);
|
||||
|
||||
// 2. 화면 정의의 회사 코드로 없으면, 해당 화면의 모든 레이아웃 중 첫 번째 조회
|
||||
if (!layout) {
|
||||
layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_pop
|
||||
WHERE screen_id = $1
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1`,
|
||||
[screenId],
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 일반 사용자: 회사별 우선, 없으면 공통(*) 조회
|
||||
layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_pop
|
||||
WHERE screen_id = $1 AND company_code = $2`,
|
||||
[screenId, companyCode],
|
||||
);
|
||||
|
||||
// 회사별 레이아웃이 없으면 공통(*) 레이아웃 조회
|
||||
if (!layout && companyCode !== "*") {
|
||||
layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_pop
|
||||
WHERE screen_id = $1 AND company_code = '*'`,
|
||||
[screenId],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!layout) {
|
||||
console.log(`POP 레이아웃 없음: screen_id=${screenId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const layoutData = layout.layout_data;
|
||||
|
||||
// v1 → v2 자동 마이그레이션
|
||||
if (layoutData && layoutData.version === "pop-1.0") {
|
||||
console.log("POP v1 레이아웃 감지, v2로 마이그레이션");
|
||||
return this.migratePopV1ToV2(layoutData);
|
||||
}
|
||||
|
||||
// v2 또는 버전 태그 없는 경우 (버전 태그 없으면 sections 구조 확인)
|
||||
if (layoutData && !layoutData.version && layoutData.sections && Array.isArray(layoutData.sections)) {
|
||||
console.log("버전 태그 없는 v1 레이아웃 감지, v2로 마이그레이션");
|
||||
return this.migratePopV1ToV2({ ...layoutData, version: "pop-1.0" });
|
||||
}
|
||||
|
||||
// v2 레이아웃 그대로 반환
|
||||
const sectionCount = layoutData?.sections ? Object.keys(layoutData.sections).length : 0;
|
||||
const componentCount = layoutData?.components ? Object.keys(layoutData.components).length : 0;
|
||||
console.log(`POP v2 레이아웃 로드 완료: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`);
|
||||
|
||||
return layoutData;
|
||||
}
|
||||
|
||||
/**
|
||||
* POP 레이아웃 저장
|
||||
* - screen_layouts_pop 테이블에 화면당 1개 레코드 저장
|
||||
* - v3 형식 지원 (version: "pop-3.0", 섹션 제거)
|
||||
* - v2/v1 하위 호환
|
||||
*/
|
||||
async saveLayoutPop(
|
||||
screenId: number,
|
||||
layoutData: any,
|
||||
companyCode: string,
|
||||
userId?: string,
|
||||
): Promise<void> {
|
||||
console.log(`=== POP 레이아웃 저장 (v5 그리드 시스템) ===`);
|
||||
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`);
|
||||
|
||||
// v5 그리드 레이아웃만 지원
|
||||
const componentCount = Object.keys(layoutData.components || {}).length;
|
||||
console.log(`컴포넌트: ${componentCount}개`);
|
||||
|
||||
// v5 형식 검증
|
||||
if (layoutData.version && layoutData.version !== "pop-5.0") {
|
||||
console.warn(`레거시 버전 감지 (${layoutData.version}), v5로 변환 필요`);
|
||||
}
|
||||
|
||||
// 권한 확인
|
||||
const screens = await query<{ company_code: string | null }>(
|
||||
`SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
|
||||
[screenId],
|
||||
);
|
||||
|
||||
if (screens.length === 0) {
|
||||
throw new Error("화면을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
const existingScreen = screens[0];
|
||||
|
||||
if (companyCode !== "*" && existingScreen.company_code !== companyCode) {
|
||||
throw new Error("이 화면의 POP 레이아웃을 저장할 권한이 없습니다.");
|
||||
}
|
||||
|
||||
// SUPER_ADMIN인 경우: 화면 정의의 company_code로 저장 (로드와 동일하게)
|
||||
const targetCompanyCode = companyCode === "*"
|
||||
? (existingScreen.company_code || "*")
|
||||
: companyCode;
|
||||
|
||||
console.log(`저장 대상 company_code: ${targetCompanyCode} (사용자: ${companyCode}, 화면: ${existingScreen.company_code})`);
|
||||
|
||||
// v5 그리드 레이아웃으로 저장 (단일 버전)
|
||||
const dataToSave = {
|
||||
...layoutData,
|
||||
version: "pop-5.0",
|
||||
};
|
||||
console.log(`저장: gridConfig=${JSON.stringify(dataToSave.gridConfig || 'default')}`)
|
||||
|
||||
// UPSERT (있으면 업데이트, 없으면 삽입)
|
||||
await query(
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, NOW(), NOW())
|
||||
`INSERT INTO screen_layouts_pop (screen_id, company_code, layout_data, created_at, updated_at, created_by, updated_by)
|
||||
VALUES ($1, $2, $3, NOW(), NOW(), $4, $4)
|
||||
ON CONFLICT (screen_id, company_code)
|
||||
DO UPDATE SET layout_data = $3, updated_at = NOW()`,
|
||||
[screenId, companyCode, JSON.stringify(dataToSave)],
|
||||
DO UPDATE SET layout_data = $3, updated_at = NOW(), updated_by = $4`,
|
||||
[screenId, targetCompanyCode, JSON.stringify(dataToSave), userId || null],
|
||||
);
|
||||
|
||||
console.log(`V2 레이아웃 저장 완료`);
|
||||
console.log(`POP 레이아웃 저장 완료 (version: ${dataToSave.version}, company: ${targetCompanyCode})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* POP 레이아웃이 존재하는 화면 ID 목록 조회
|
||||
* - 옵션 B: POP 레이아웃 존재 여부로 화면 구분
|
||||
*/
|
||||
async getScreenIdsWithPopLayout(
|
||||
companyCode: string,
|
||||
): Promise<number[]> {
|
||||
console.log(`=== POP 레이아웃 존재 화면 ID 조회 ===`);
|
||||
console.log(`회사 코드: ${companyCode}`);
|
||||
|
||||
let result: { screen_id: number }[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 POP 레이아웃 조회
|
||||
result = await query<{ screen_id: number }>(
|
||||
`SELECT DISTINCT screen_id FROM screen_layouts_pop`,
|
||||
[],
|
||||
);
|
||||
} else {
|
||||
// 일반 회사: 해당 회사 또는 공통(*) 레이아웃 조회
|
||||
result = await query<{ screen_id: number }>(
|
||||
`SELECT DISTINCT screen_id FROM screen_layouts_pop
|
||||
WHERE company_code = $1 OR company_code = '*'`,
|
||||
[companyCode],
|
||||
);
|
||||
}
|
||||
|
||||
const screenIds = result.map((r) => r.screen_id);
|
||||
console.log(`POP 레이아웃 존재 화면 수: ${screenIds.length}개`);
|
||||
return screenIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* POP 레이아웃 삭제
|
||||
*/
|
||||
async deleteLayoutPop(
|
||||
screenId: number,
|
||||
companyCode: string,
|
||||
): Promise<boolean> {
|
||||
console.log(`=== POP 레이아웃 삭제 시작 ===`);
|
||||
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`);
|
||||
|
||||
// 권한 확인
|
||||
const screens = await query<{ company_code: string | null }>(
|
||||
`SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
|
||||
[screenId],
|
||||
);
|
||||
|
||||
if (screens.length === 0) {
|
||||
throw new Error("화면을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
const existingScreen = screens[0];
|
||||
|
||||
if (companyCode !== "*" && existingScreen.company_code !== companyCode) {
|
||||
throw new Error("이 화면의 POP 레이아웃을 삭제할 권한이 없습니다.");
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
`DELETE FROM screen_layouts_pop WHERE screen_id = $1 AND company_code = $2`,
|
||||
[screenId, companyCode],
|
||||
);
|
||||
|
||||
console.log(`POP 레이아웃 삭제 완료`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1371,39 +1371,66 @@ class TableCategoryValueService {
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
// 동적으로 파라미터 플레이스홀더 생성
|
||||
const placeholders = valueCodes.map((_, i) => `$${i + 1}`).join(", ");
|
||||
const n = valueCodes.length;
|
||||
|
||||
// 첫 번째 쿼리용 플레이스홀더: $1 ~ $n
|
||||
const placeholders1 = valueCodes.map((_, i) => `$${i + 1}`).join(", ");
|
||||
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 카테고리 값 조회
|
||||
// 최고 관리자: 두 테이블 모두에서 조회 (UNION으로 병합)
|
||||
// 두 번째 쿼리용 플레이스홀더: $n+1 ~ $2n
|
||||
const placeholders2 = valueCodes.map((_, i) => `$${n + i + 1}`).join(", ");
|
||||
query = `
|
||||
SELECT value_code, value_label
|
||||
FROM table_column_category_values
|
||||
WHERE value_code IN (${placeholders})
|
||||
AND is_active = true
|
||||
SELECT value_code, value_label FROM (
|
||||
SELECT value_code, value_label
|
||||
FROM table_column_category_values
|
||||
WHERE value_code IN (${placeholders1})
|
||||
AND is_active = true
|
||||
UNION ALL
|
||||
SELECT value_code, value_label
|
||||
FROM category_values
|
||||
WHERE value_code IN (${placeholders2})
|
||||
AND is_active = true
|
||||
) combined
|
||||
`;
|
||||
params = valueCodes;
|
||||
params = [...valueCodes, ...valueCodes];
|
||||
} else {
|
||||
// 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회
|
||||
// 일반 회사: 두 테이블에서 자신의 카테고리 값 + 공통 카테고리 값 조회
|
||||
// 첫 번째: $1~$n (valueCodes), $n+1 (companyCode)
|
||||
// 두 번째: $n+2~$2n+1 (valueCodes), $2n+2 (companyCode)
|
||||
const companyIdx1 = n + 1;
|
||||
const placeholders2 = valueCodes.map((_, i) => `$${n + 1 + i + 1}`).join(", ");
|
||||
const companyIdx2 = 2 * n + 2;
|
||||
|
||||
query = `
|
||||
SELECT value_code, value_label
|
||||
FROM table_column_category_values
|
||||
WHERE value_code IN (${placeholders})
|
||||
AND is_active = true
|
||||
AND (company_code = $${valueCodes.length + 1} OR company_code = '*')
|
||||
SELECT value_code, value_label FROM (
|
||||
SELECT value_code, value_label
|
||||
FROM table_column_category_values
|
||||
WHERE value_code IN (${placeholders1})
|
||||
AND is_active = true
|
||||
AND (company_code = $${companyIdx1} OR company_code = '*')
|
||||
UNION ALL
|
||||
SELECT value_code, value_label
|
||||
FROM category_values
|
||||
WHERE value_code IN (${placeholders2})
|
||||
AND is_active = true
|
||||
AND (company_code = $${companyIdx2} OR company_code = '*')
|
||||
) combined
|
||||
`;
|
||||
params = [...valueCodes, companyCode];
|
||||
params = [...valueCodes, companyCode, ...valueCodes, companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
// { [code]: label } 형태로 변환
|
||||
// { [code]: label } 형태로 변환 (중복 시 첫 번째 결과 우선)
|
||||
const labels: Record<string, string> = {};
|
||||
for (const row of result.rows) {
|
||||
labels[row.value_code] = row.value_label;
|
||||
if (!labels[row.value_code]) {
|
||||
labels[row.value_code] = row.value_label;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`카테고리 라벨 ${Object.keys(labels).length}개 조회 완료`, { companyCode });
|
||||
|
||||
@@ -2875,10 +2875,11 @@ export class TableManagementService {
|
||||
};
|
||||
}
|
||||
|
||||
// Entity 조인 설정 감지 (화면별 엔티티 설정 전달)
|
||||
// Entity 조인 설정 감지 (화면별 엔티티 설정 + 회사코드 전달)
|
||||
let joinConfigs = await entityJoinService.detectEntityJoins(
|
||||
tableName,
|
||||
options.screenEntityConfigs
|
||||
options.screenEntityConfigs,
|
||||
options.companyCode
|
||||
);
|
||||
|
||||
logger.info(
|
||||
@@ -2978,31 +2979,49 @@ export class TableManagementService {
|
||||
continue; // 기본 Entity 조인과 중복되면 추가하지 않음
|
||||
}
|
||||
|
||||
// 추가 조인 컬럼 설정 생성
|
||||
const additionalJoinConfig: EntityJoinConfig = {
|
||||
sourceTable: tableName,
|
||||
sourceColumn: sourceColumn, // 실제 소스 컬럼 (partner_id)
|
||||
referenceTable:
|
||||
(additionalColumn as any).referenceTable ||
|
||||
baseJoinConfig.referenceTable, // 참조 테이블 (customer_mng)
|
||||
referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (customer_code)
|
||||
displayColumns: [actualColumnName], // 표시할 컬럼들 (customer_name)
|
||||
displayColumn: actualColumnName, // 하위 호환성
|
||||
aliasColumn: correctedJoinAlias, // 수정된 별칭 (partner_id_customer_name)
|
||||
separator: " - ", // 기본 구분자
|
||||
};
|
||||
|
||||
joinConfigs.push(additionalJoinConfig);
|
||||
logger.info(
|
||||
`✅ 추가 조인 컬럼 설정 추가: ${additionalJoinConfig.aliasColumn} -> ${actualColumnName}`
|
||||
// 🆕 같은 sourceColumn + referenceTable 조합의 기존 config가 있으면 displayColumns에 병합
|
||||
const existingConfig = joinConfigs.find(
|
||||
(config) =>
|
||||
config.sourceColumn === sourceColumn &&
|
||||
config.referenceTable === ((additionalColumn as any).referenceTable || baseJoinConfig.referenceTable)
|
||||
);
|
||||
logger.info(`🔍 추가된 조인 설정 상세:`, {
|
||||
sourceTable: additionalJoinConfig.sourceTable,
|
||||
sourceColumn: additionalJoinConfig.sourceColumn,
|
||||
referenceTable: additionalJoinConfig.referenceTable,
|
||||
displayColumns: additionalJoinConfig.displayColumns,
|
||||
aliasColumn: additionalJoinConfig.aliasColumn,
|
||||
});
|
||||
|
||||
if (existingConfig) {
|
||||
// 기존 config에 display column 추가 (중복 방지)
|
||||
if (!existingConfig.displayColumns?.includes(actualColumnName)) {
|
||||
existingConfig.displayColumns = existingConfig.displayColumns || [];
|
||||
existingConfig.displayColumns.push(actualColumnName);
|
||||
logger.info(
|
||||
`🔄 기존 조인 설정에 컬럼 병합: ${existingConfig.aliasColumn} ← ${actualColumnName} (총 ${existingConfig.displayColumns.length}개)`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 새 조인 설정 생성
|
||||
const additionalJoinConfig: EntityJoinConfig = {
|
||||
sourceTable: tableName,
|
||||
sourceColumn: sourceColumn, // 실제 소스 컬럼 (partner_id)
|
||||
referenceTable:
|
||||
(additionalColumn as any).referenceTable ||
|
||||
baseJoinConfig.referenceTable, // 참조 테이블 (customer_mng)
|
||||
referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (customer_code)
|
||||
displayColumns: [actualColumnName], // 표시할 컬럼들 (customer_name)
|
||||
displayColumn: actualColumnName, // 하위 호환성
|
||||
aliasColumn: correctedJoinAlias, // 수정된 별칭 (partner_id_customer_name)
|
||||
separator: " - ", // 기본 구분자
|
||||
};
|
||||
|
||||
joinConfigs.push(additionalJoinConfig);
|
||||
logger.info(
|
||||
`✅ 추가 조인 컬럼 설정 추가: ${additionalJoinConfig.aliasColumn} -> ${actualColumnName}`
|
||||
);
|
||||
logger.info(`🔍 추가된 조인 설정 상세:`, {
|
||||
sourceTable: additionalJoinConfig.sourceTable,
|
||||
sourceColumn: additionalJoinConfig.sourceColumn,
|
||||
referenceTable: additionalJoinConfig.referenceTable,
|
||||
displayColumns: additionalJoinConfig.displayColumns,
|
||||
aliasColumn: additionalJoinConfig.aliasColumn,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3258,6 +3277,28 @@ export class TableManagementService {
|
||||
startTime: number
|
||||
): Promise<EntityJoinResponse> {
|
||||
try {
|
||||
// 🆕 참조 테이블별 전체 컬럼 목록 미리 조회
|
||||
const referenceTableColumns = new Map<string, string[]>();
|
||||
const uniqueRefTables = new Set(
|
||||
joinConfigs
|
||||
.filter((c) => c.referenceTable !== "table_column_category_values") // 카테고리는 제외
|
||||
.map((c) => `${c.referenceTable}:${c.sourceColumn}`)
|
||||
);
|
||||
|
||||
for (const key of uniqueRefTables) {
|
||||
const refTable = key.split(":")[0];
|
||||
if (!referenceTableColumns.has(key)) {
|
||||
const cols = await query<{ column_name: string }>(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = $1 AND table_schema = 'public'
|
||||
ORDER BY ordinal_position`,
|
||||
[refTable]
|
||||
);
|
||||
referenceTableColumns.set(key, cols.map((c) => c.column_name));
|
||||
logger.info(`🔍 참조 테이블 컬럼 조회: ${refTable} → ${cols.length}개`);
|
||||
}
|
||||
}
|
||||
|
||||
// 데이터 조회 쿼리
|
||||
const dataQuery = entityJoinService.buildJoinQuery(
|
||||
tableName,
|
||||
@@ -3266,7 +3307,9 @@ export class TableManagementService {
|
||||
whereClause,
|
||||
orderBy,
|
||||
limit,
|
||||
offset
|
||||
offset,
|
||||
undefined,
|
||||
referenceTableColumns // 🆕 참조 테이블 전체 컬럼 전달
|
||||
).query;
|
||||
|
||||
// 카운트 쿼리
|
||||
@@ -3767,12 +3810,12 @@ export class TableManagementService {
|
||||
reference_table: string;
|
||||
reference_column: string;
|
||||
}>(
|
||||
`SELECT column_name, reference_table, reference_column
|
||||
`SELECT DISTINCT ON (column_name) column_name, reference_table, reference_column
|
||||
FROM table_type_columns
|
||||
WHERE table_name = $1
|
||||
AND input_type = 'entity'
|
||||
AND reference_table = $2
|
||||
AND company_code = '*'
|
||||
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END
|
||||
LIMIT 1`,
|
||||
[tableName, refTable]
|
||||
);
|
||||
@@ -3883,7 +3926,7 @@ export class TableManagementService {
|
||||
/**
|
||||
* 참조 테이블의 표시 컬럼 목록 조회
|
||||
*/
|
||||
async getReferenceTableColumns(tableName: string): Promise<
|
||||
async getReferenceTableColumns(tableName: string, companyCode?: string): Promise<
|
||||
Array<{
|
||||
columnName: string;
|
||||
displayName: string;
|
||||
@@ -3891,7 +3934,7 @@ export class TableManagementService {
|
||||
inputType?: string;
|
||||
}>
|
||||
> {
|
||||
return await entityJoinService.getReferenceTableColumns(tableName);
|
||||
return await entityJoinService.getReferenceTableColumns(tableName, companyCode);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -5005,14 +5048,14 @@ export class TableManagementService {
|
||||
input_type: string;
|
||||
display_column: string | null;
|
||||
}>(
|
||||
`SELECT column_name, reference_column, input_type, display_column
|
||||
`SELECT DISTINCT ON (column_name) column_name, reference_column, input_type, display_column
|
||||
FROM table_type_columns
|
||||
WHERE table_name = $1
|
||||
AND input_type IN ('entity', 'category')
|
||||
AND reference_table = $2
|
||||
AND reference_column IS NOT NULL
|
||||
AND reference_column != ''
|
||||
AND company_code = '*'`,
|
||||
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
|
||||
[rightTable, leftTable]
|
||||
);
|
||||
|
||||
@@ -5034,14 +5077,14 @@ export class TableManagementService {
|
||||
input_type: string;
|
||||
display_column: string | null;
|
||||
}>(
|
||||
`SELECT column_name, reference_column, input_type, display_column
|
||||
`SELECT DISTINCT ON (column_name) column_name, reference_column, input_type, display_column
|
||||
FROM table_type_columns
|
||||
WHERE table_name = $1
|
||||
AND input_type IN ('entity', 'category')
|
||||
AND reference_table = $2
|
||||
AND reference_column IS NOT NULL
|
||||
AND reference_column != ''
|
||||
AND company_code = '*'`,
|
||||
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
|
||||
[leftTable, rightTable]
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user