Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into feat/multilang

This commit is contained in:
kjs
2026-01-13 18:32:04 +09:00
29 changed files with 18421 additions and 684 deletions

View File

@@ -70,11 +70,23 @@ export class EntityJoinController {
const userField = parsedAutoFilter.userField || "companyCode";
const userValue = ((req as any).user as any)[userField];
if (userValue) {
searchConditions[filterColumn] = userValue;
// 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 허용)
let finalCompanyCode = userValue;
if (parsedAutoFilter.companyCodeOverride && userValue === "*") {
// 최고 관리자만 다른 회사 코드로 오버라이드 가능
finalCompanyCode = parsedAutoFilter.companyCodeOverride;
logger.info("🔓 최고 관리자 회사 코드 오버라이드:", {
originalCompanyCode: userValue,
overrideCompanyCode: parsedAutoFilter.companyCodeOverride,
tableName,
});
}
if (finalCompanyCode) {
searchConditions[filterColumn] = finalCompanyCode;
logger.info("🔒 Entity 조인에 멀티테넌시 필터 적용:", {
filterColumn,
userValue,
finalCompanyCode,
tableName,
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -775,18 +775,25 @@ export async function getTableData(
const userField = autoFilter?.userField || "companyCode";
const userValue = (req.user as any)[userField];
// 🆕 최고 관리자(company_code = '*')는 모든 회사 데이터 조회 가능
if (userValue && userValue !== "*") {
enhancedSearch[filterColumn] = userValue;
// 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 허용)
let finalCompanyCode = userValue;
if (autoFilter?.companyCodeOverride && userValue === "*") {
// 최고 관리자만 다른 회사 코드로 오버라이드 가능
finalCompanyCode = autoFilter.companyCodeOverride;
logger.info("🔓 최고 관리자 회사 코드 오버라이드:", {
originalCompanyCode: userValue,
overrideCompanyCode: autoFilter.companyCodeOverride,
tableName,
});
}
if (finalCompanyCode) {
enhancedSearch[filterColumn] = finalCompanyCode;
logger.info("🔍 현재 사용자 필터 적용:", {
filterColumn,
userField,
userValue,
tableName,
});
} else if (userValue === "*") {
logger.info("🔓 최고 관리자 - 회사 필터 미적용 (모든 회사 데이터 조회)", {
userValue: finalCompanyCode,
tableName,
});
} else {
@@ -798,7 +805,10 @@ export async function getTableData(
}
// 🆕 최종 검색 조건 로그
logger.info(`🔍 최종 검색 조건 (enhancedSearch):`, JSON.stringify(enhancedSearch));
logger.info(
`🔍 최종 검색 조건 (enhancedSearch):`,
JSON.stringify(enhancedSearch)
);
// 데이터 조회
const result = await tableManagementService.getTableData(tableName, {
@@ -883,7 +893,10 @@ export async function addTableData(
const companyCode = req.user?.companyCode;
if (companyCode && !data.company_code) {
// 테이블에 company_code 컬럼이 있는지 확인
const hasCompanyCodeColumn = await tableManagementService.hasColumn(tableName, "company_code");
const hasCompanyCodeColumn = await tableManagementService.hasColumn(
tableName,
"company_code"
);
if (hasCompanyCodeColumn) {
data.company_code = companyCode;
logger.info(`멀티테넌시: company_code 자동 추가 - ${companyCode}`);
@@ -893,7 +906,10 @@ export async function addTableData(
// 🆕 writer 컬럼 자동 추가 (테이블에 writer 컬럼이 있고 값이 없는 경우)
const userId = req.user?.userId;
if (userId && !data.writer) {
const hasWriterColumn = await tableManagementService.hasColumn(tableName, "writer");
const hasWriterColumn = await tableManagementService.hasColumn(
tableName,
"writer"
);
if (hasWriterColumn) {
data.writer = userId;
logger.info(`writer 자동 추가 - ${userId}`);
@@ -911,11 +927,13 @@ export async function addTableData(
savedColumns?: string[];
}> = {
success: true,
message: result.skippedColumns.length > 0
? `테이블 데이터를 추가했습니다. (무시된 컬럼 ${result.skippedColumns.length}개: ${result.skippedColumns.join(", ")})`
: "테이블 데이터를 성공적으로 추가했습니다.",
message:
result.skippedColumns.length > 0
? `테이블 데이터를 추가했습니다. (무시된 컬럼 ${result.skippedColumns.length}개: ${result.skippedColumns.join(", ")})`
: "테이블 데이터를 성공적으로 추가했습니다.",
data: {
skippedColumns: result.skippedColumns.length > 0 ? result.skippedColumns : undefined,
skippedColumns:
result.skippedColumns.length > 0 ? result.skippedColumns : undefined,
savedColumns: result.savedColumns,
},
};
@@ -1645,10 +1663,10 @@ export async function toggleLogTable(
/**
* 메뉴의 상위 메뉴들이 설정한 모든 카테고리 타입 컬럼 조회 (계층 구조 상속)
*
*
* @route GET /api/table-management/menu/:menuObjid/category-columns
* @description 현재 메뉴와 상위 메뉴들에서 설정한 category_column_mapping의 모든 카테고리 컬럼 조회
*
*
* 예시:
* - 2레벨 메뉴 "고객사관리"에서 discount_type, rounding_type 설정
* - 3레벨 메뉴 "고객등록", "고객조회" 등에서도 동일하게 보임 (상속)
@@ -1661,7 +1679,10 @@ export async function getCategoryColumnsByMenu(
const { menuObjid } = req.params;
const companyCode = req.user?.companyCode;
logger.info("📥 메뉴별 카테고리 컬럼 조회 요청", { menuObjid, companyCode });
logger.info("📥 메뉴별 카테고리 컬럼 조회 요청", {
menuObjid,
companyCode,
});
if (!menuObjid) {
res.status(400).json({
@@ -1687,8 +1708,11 @@ export async function getCategoryColumnsByMenu(
if (mappingTableExists) {
// 🆕 category_column_mapping을 사용한 계층 구조 기반 조회
logger.info("🔍 category_column_mapping 기반 카테고리 컬럼 조회 (계층 구조 상속)", { menuObjid, companyCode });
logger.info(
"🔍 category_column_mapping 기반 카테고리 컬럼 조회 (계층 구조 상속)",
{ menuObjid, companyCode }
);
// 현재 메뉴와 모든 상위 메뉴의 objid 조회 (재귀)
const ancestorMenuQuery = `
WITH RECURSIVE menu_hierarchy AS (
@@ -1710,17 +1734,21 @@ export async function getCategoryColumnsByMenu(
ARRAY_AGG(menu_name_kor) as menu_names
FROM menu_hierarchy
`;
const ancestorMenuResult = await pool.query(ancestorMenuQuery, [parseInt(menuObjid)]);
const ancestorMenuObjids = ancestorMenuResult.rows[0]?.menu_objids || [parseInt(menuObjid)];
const ancestorMenuResult = await pool.query(ancestorMenuQuery, [
parseInt(menuObjid),
]);
const ancestorMenuObjids = ancestorMenuResult.rows[0]?.menu_objids || [
parseInt(menuObjid),
];
const ancestorMenuNames = ancestorMenuResult.rows[0]?.menu_names || [];
logger.info("✅ 상위 메뉴 계층 조회 완료", {
ancestorMenuObjids,
logger.info("✅ 상위 메뉴 계층 조회 완료", {
ancestorMenuObjids,
ancestorMenuNames,
hierarchyDepth: ancestorMenuObjids.length
hierarchyDepth: ancestorMenuObjids.length,
});
// 상위 메뉴들에 설정된 모든 카테고리 컬럼 조회 (테이블 필터링 제거)
const columnsQuery = `
SELECT DISTINCT
@@ -1750,20 +1778,31 @@ export async function getCategoryColumnsByMenu(
AND ttc.input_type = 'category'
ORDER BY ttc.table_name, ccm.logical_column_name
`;
columnsResult = await pool.query(columnsQuery, [companyCode, ancestorMenuObjids]);
logger.info("✅ category_column_mapping 기반 조회 완료 (계층 구조 상속)", {
rowCount: columnsResult.rows.length,
columns: columnsResult.rows.map((r: any) => `${r.tableName}.${r.columnName}`)
});
columnsResult = await pool.query(columnsQuery, [
companyCode,
ancestorMenuObjids,
]);
logger.info(
"✅ category_column_mapping 기반 조회 완료 (계층 구조 상속)",
{
rowCount: columnsResult.rows.length,
columns: columnsResult.rows.map(
(r: any) => `${r.tableName}.${r.columnName}`
),
}
);
} else {
// 🔄 레거시 방식: 형제 메뉴들의 테이블에서 모든 카테고리 컬럼 조회
logger.info("🔍 레거시 방식: 형제 메뉴 테이블 기반 카테고리 컬럼 조회", { menuObjid, companyCode });
logger.info("🔍 레거시 방식: 형제 메뉴 테이블 기반 카테고리 컬럼 조회", {
menuObjid,
companyCode,
});
// 형제 메뉴 조회
const { getSiblingMenuObjids } = await import("../services/menuService");
const siblingObjids = await getSiblingMenuObjids(parseInt(menuObjid));
// 형제 메뉴들이 사용하는 테이블 조회
const tablesQuery = `
SELECT DISTINCT sd.table_name
@@ -1773,11 +1812,17 @@ export async function getCategoryColumnsByMenu(
AND sma.company_code = $2
AND sd.table_name IS NOT NULL
`;
const tablesResult = await pool.query(tablesQuery, [siblingObjids, companyCode]);
const tablesResult = await pool.query(tablesQuery, [
siblingObjids,
companyCode,
]);
const tableNames = tablesResult.rows.map((row: any) => row.table_name);
logger.info("✅ 형제 메뉴 테이블 조회 완료", { tableNames, count: tableNames.length });
logger.info("✅ 형제 메뉴 테이블 조회 완료", {
tableNames,
count: tableNames.length,
});
if (tableNames.length === 0) {
res.json({
@@ -1787,7 +1832,7 @@ export async function getCategoryColumnsByMenu(
});
return;
}
const columnsQuery = `
SELECT
ttc.table_name AS "tableName",
@@ -1812,13 +1857,15 @@ export async function getCategoryColumnsByMenu(
AND ttc.input_type = 'category'
ORDER BY ttc.table_name, ttc.column_name
`;
columnsResult = await pool.query(columnsQuery, [tableNames, companyCode]);
logger.info("✅ 레거시 방식 조회 완료", { rowCount: columnsResult.rows.length });
logger.info("✅ 레거시 방식 조회 완료", {
rowCount: columnsResult.rows.length,
});
}
logger.info("✅ 카테고리 컬럼 조회 완료", {
columnCount: columnsResult.rows.length
logger.info("✅ 카테고리 컬럼 조회 완료", {
columnCount: columnsResult.rows.length,
});
res.json({
@@ -1843,9 +1890,9 @@ export async function getCategoryColumnsByMenu(
/**
* 범용 다중 테이블 저장 API
*
*
* 메인 테이블과 서브 테이블(들)에 트랜잭션으로 데이터를 저장합니다.
*
*
* 요청 본문:
* {
* mainTable: { tableName: string, primaryKeyColumn: string },
@@ -1915,23 +1962,29 @@ export async function multiTableSave(
}
let mainResult: any;
if (isUpdate && pkValue) {
// UPDATE
const updateColumns = Object.keys(mainData)
.filter(col => col !== pkColumn)
.filter((col) => col !== pkColumn)
.map((col, idx) => `"${col}" = $${idx + 1}`)
.join(", ");
const updateValues = Object.keys(mainData)
.filter(col => col !== pkColumn)
.map(col => mainData[col]);
.filter((col) => col !== pkColumn)
.map((col) => mainData[col]);
// updated_at 컬럼 존재 여부 확인
const hasUpdatedAt = await client.query(`
const hasUpdatedAt = await client.query(
`
SELECT 1 FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'updated_at'
`, [mainTableName]);
const updatedAtClause = hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 ? ", updated_at = NOW()" : "";
`,
[mainTableName]
);
const updatedAtClause =
hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0
? ", updated_at = NOW()"
: "";
const updateQuery = `
UPDATE "${mainTableName}"
@@ -1940,29 +1993,43 @@ export async function multiTableSave(
${companyCode !== "*" ? `AND company_code = $${updateValues.length + 2}` : ""}
RETURNING *
`;
const updateParams = companyCode !== "*"
? [...updateValues, pkValue, companyCode]
: [...updateValues, pkValue];
logger.info("메인 테이블 UPDATE:", { query: updateQuery, paramsCount: updateParams.length });
const updateParams =
companyCode !== "*"
? [...updateValues, pkValue, companyCode]
: [...updateValues, pkValue];
logger.info("메인 테이블 UPDATE:", {
query: updateQuery,
paramsCount: updateParams.length,
});
mainResult = await client.query(updateQuery, updateParams);
} else {
// INSERT
const columns = Object.keys(mainData).map(col => `"${col}"`).join(", ");
const placeholders = Object.keys(mainData).map((_, idx) => `$${idx + 1}`).join(", ");
const columns = Object.keys(mainData)
.map((col) => `"${col}"`)
.join(", ");
const placeholders = Object.keys(mainData)
.map((_, idx) => `$${idx + 1}`)
.join(", ");
const values = Object.values(mainData);
// updated_at 컬럼 존재 여부 확인
const hasUpdatedAt = await client.query(`
const hasUpdatedAt = await client.query(
`
SELECT 1 FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'updated_at'
`, [mainTableName]);
const updatedAtClause = hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 ? ", updated_at = NOW()" : "";
`,
[mainTableName]
);
const updatedAtClause =
hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0
? ", updated_at = NOW()"
: "";
const updateSetClause = Object.keys(mainData)
.filter(col => col !== pkColumn)
.map(col => `"${col}" = EXCLUDED."${col}"`)
.filter((col) => col !== pkColumn)
.map((col) => `"${col}" = EXCLUDED."${col}"`)
.join(", ");
const insertQuery = `
@@ -1973,7 +2040,10 @@ export async function multiTableSave(
RETURNING *
`;
logger.info("메인 테이블 INSERT/UPSERT:", { query: insertQuery, paramsCount: values.length });
logger.info("메인 테이블 INSERT/UPSERT:", {
query: insertQuery,
paramsCount: values.length,
});
mainResult = await client.query(insertQuery, values);
}
@@ -1992,12 +2062,15 @@ export async function multiTableSave(
const { tableName, linkColumn, items, options } = subTableConfig;
// saveMainAsFirst가 활성화된 경우, items가 비어있어도 메인 데이터를 서브 테이블에 저장해야 함
const hasSaveMainAsFirst = options?.saveMainAsFirst &&
options?.mainFieldMappings &&
options.mainFieldMappings.length > 0;
const hasSaveMainAsFirst =
options?.saveMainAsFirst &&
options?.mainFieldMappings &&
options.mainFieldMappings.length > 0;
if (!tableName || (!items?.length && !hasSaveMainAsFirst)) {
logger.info(`서브 테이블 ${tableName} 스킵: 데이터 없음 (saveMainAsFirst: ${hasSaveMainAsFirst})`);
logger.info(
`서브 테이블 ${tableName} 스킵: 데이터 없음 (saveMainAsFirst: ${hasSaveMainAsFirst})`
);
continue;
}
@@ -2010,15 +2083,20 @@ export async function multiTableSave(
// 기존 데이터 삭제 옵션
if (options?.deleteExistingBefore && linkColumn?.subColumn) {
const deleteQuery = options?.deleteOnlySubItems && options?.mainMarkerColumn
? `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1 AND "${options.mainMarkerColumn}" = $2`
: `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1`;
const deleteParams = options?.deleteOnlySubItems && options?.mainMarkerColumn
? [savedPkValue, options.subMarkerValue ?? false]
: [savedPkValue];
const deleteQuery =
options?.deleteOnlySubItems && options?.mainMarkerColumn
? `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1 AND "${options.mainMarkerColumn}" = $2`
: `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1`;
logger.info(`서브 테이블 ${tableName} 기존 데이터 삭제:`, { deleteQuery, deleteParams });
const deleteParams =
options?.deleteOnlySubItems && options?.mainMarkerColumn
? [savedPkValue, options.subMarkerValue ?? false]
: [savedPkValue];
logger.info(`서브 테이블 ${tableName} 기존 데이터 삭제:`, {
deleteQuery,
deleteParams,
});
await client.query(deleteQuery, deleteParams);
}
@@ -2031,7 +2109,12 @@ export async function multiTableSave(
linkColumn,
mainDataKeys: Object.keys(mainData),
});
if (options?.saveMainAsFirst && options?.mainFieldMappings && options.mainFieldMappings.length > 0 && linkColumn?.subColumn) {
if (
options?.saveMainAsFirst &&
options?.mainFieldMappings &&
options.mainFieldMappings.length > 0 &&
linkColumn?.subColumn
) {
const mainSubItem: Record<string, any> = {
[linkColumn.subColumn]: savedPkValue,
};
@@ -2045,7 +2128,8 @@ export async function multiTableSave(
// 메인 마커 설정
if (options.mainMarkerColumn) {
mainSubItem[options.mainMarkerColumn] = options.mainMarkerValue ?? true;
mainSubItem[options.mainMarkerColumn] =
options.mainMarkerValue ?? true;
}
// company_code 추가
@@ -2068,20 +2152,30 @@ export async function multiTableSave(
if (companyCode !== "*") {
checkParams.push(companyCode);
}
const existingResult = await client.query(checkQuery, checkParams);
if (existingResult.rows.length > 0) {
// UPDATE
const updateColumns = Object.keys(mainSubItem)
.filter(col => col !== linkColumn.subColumn && col !== options.mainMarkerColumn && col !== "company_code")
.filter(
(col) =>
col !== linkColumn.subColumn &&
col !== options.mainMarkerColumn &&
col !== "company_code"
)
.map((col, idx) => `"${col}" = $${idx + 1}`)
.join(", ");
const updateValues = Object.keys(mainSubItem)
.filter(col => col !== linkColumn.subColumn && col !== options.mainMarkerColumn && col !== "company_code")
.map(col => mainSubItem[col]);
.filter(
(col) =>
col !== linkColumn.subColumn &&
col !== options.mainMarkerColumn &&
col !== "company_code"
)
.map((col) => mainSubItem[col]);
if (updateColumns) {
const updateQuery = `
UPDATE "${tableName}"
@@ -2100,14 +2194,26 @@ export async function multiTableSave(
}
const updateResult = await client.query(updateQuery, updateParams);
subTableResults.push({ tableName, type: "main", data: updateResult.rows[0] });
subTableResults.push({
tableName,
type: "main",
data: updateResult.rows[0],
});
} else {
subTableResults.push({ tableName, type: "main", data: existingResult.rows[0] });
subTableResults.push({
tableName,
type: "main",
data: existingResult.rows[0],
});
}
} else {
// INSERT
const mainSubColumns = Object.keys(mainSubItem).map(col => `"${col}"`).join(", ");
const mainSubPlaceholders = Object.keys(mainSubItem).map((_, idx) => `$${idx + 1}`).join(", ");
const mainSubColumns = Object.keys(mainSubItem)
.map((col) => `"${col}"`)
.join(", ");
const mainSubPlaceholders = Object.keys(mainSubItem)
.map((_, idx) => `$${idx + 1}`)
.join(", ");
const mainSubValues = Object.values(mainSubItem);
const insertQuery = `
@@ -2117,7 +2223,11 @@ export async function multiTableSave(
`;
const insertResult = await client.query(insertQuery, mainSubValues);
subTableResults.push({ tableName, type: "main", data: insertResult.rows[0] });
subTableResults.push({
tableName,
type: "main",
data: insertResult.rows[0],
});
}
}
@@ -2133,8 +2243,12 @@ export async function multiTableSave(
item.company_code = companyCode;
}
const subColumns = Object.keys(item).map(col => `"${col}"`).join(", ");
const subPlaceholders = Object.keys(item).map((_, idx) => `$${idx + 1}`).join(", ");
const subColumns = Object.keys(item)
.map((col) => `"${col}"`)
.join(", ");
const subPlaceholders = Object.keys(item)
.map((_, idx) => `$${idx + 1}`)
.join(", ");
const subValues = Object.values(item);
const subInsertQuery = `
@@ -2143,9 +2257,16 @@ export async function multiTableSave(
RETURNING *
`;
logger.info(`서브 테이블 ${tableName} 아이템 저장:`, { subInsertQuery, subValuesCount: subValues.length });
logger.info(`서브 테이블 ${tableName} 아이템 저장:`, {
subInsertQuery,
subValuesCount: subValues.length,
});
const subResult = await client.query(subInsertQuery, subValues);
subTableResults.push({ tableName, type: "sub", data: subResult.rows[0] });
subTableResults.push({
tableName,
type: "sub",
data: subResult.rows[0],
});
}
logger.info(`서브 테이블 ${tableName} 저장 완료`);
@@ -2188,7 +2309,7 @@ export async function multiTableSave(
/**
* 두 테이블 간의 엔티티 관계 자동 감지
* GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy
*
*
* column_labels에서 정의된 엔티티/카테고리 타입 설정을 기반으로
* 두 테이블 간의 외래키 관계를 자동으로 감지합니다.
*/
@@ -2199,7 +2320,9 @@ export async function getTableEntityRelations(
try {
const { leftTable, rightTable } = req.query;
logger.info(`=== 테이블 엔티티 관계 조회 시작: ${leftTable} <-> ${rightTable} ===`);
logger.info(
`=== 테이블 엔티티 관계 조회 시작: ${leftTable} <-> ${rightTable} ===`
);
if (!leftTable || !rightTable) {
const response: ApiResponse<null> = {
@@ -2248,4 +2371,3 @@ export async function getTableEntityRelations(
res.status(500).json(response);
}
}