Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary, ; especially if it merges an updated upstream into a topic branch. ; ; Lines starting with ';' will be ignored, and an empty message aborts ; the commit.
This commit is contained in:
commit
f07448ac17
|
|
@ -30,6 +30,7 @@ export class EntityJoinController {
|
||||||
autoFilter, // 🔒 멀티테넌시 자동 필터
|
autoFilter, // 🔒 멀티테넌시 자동 필터
|
||||||
dataFilter, // 🆕 데이터 필터 (JSON 문자열)
|
dataFilter, // 🆕 데이터 필터 (JSON 문자열)
|
||||||
excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외
|
excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외
|
||||||
|
deduplication, // 🆕 중복 제거 설정 (JSON 문자열)
|
||||||
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
|
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
|
||||||
...otherParams
|
...otherParams
|
||||||
} = req.query;
|
} = req.query;
|
||||||
|
|
@ -139,6 +140,24 @@ export class EntityJoinController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 중복 제거 설정 처리
|
||||||
|
let parsedDeduplication: {
|
||||||
|
enabled: boolean;
|
||||||
|
groupByColumn: string;
|
||||||
|
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
||||||
|
sortColumn?: string;
|
||||||
|
} | undefined = undefined;
|
||||||
|
if (deduplication) {
|
||||||
|
try {
|
||||||
|
parsedDeduplication =
|
||||||
|
typeof deduplication === "string" ? JSON.parse(deduplication) : deduplication;
|
||||||
|
logger.info("중복 제거 설정 파싱 완료:", parsedDeduplication);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("중복 제거 설정 파싱 오류:", error);
|
||||||
|
parsedDeduplication = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await tableManagementService.getTableDataWithEntityJoins(
|
const result = await tableManagementService.getTableDataWithEntityJoins(
|
||||||
tableName,
|
tableName,
|
||||||
{
|
{
|
||||||
|
|
@ -156,13 +175,26 @@ export class EntityJoinController {
|
||||||
screenEntityConfigs: parsedScreenEntityConfigs,
|
screenEntityConfigs: parsedScreenEntityConfigs,
|
||||||
dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달
|
dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달
|
||||||
excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달
|
excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달
|
||||||
|
deduplication: parsedDeduplication, // 🆕 중복 제거 설정 전달
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 🆕 중복 제거 처리 (결과 데이터에 적용)
|
||||||
|
let finalData = result;
|
||||||
|
if (parsedDeduplication?.enabled && parsedDeduplication.groupByColumn && Array.isArray(result.data)) {
|
||||||
|
logger.info(`🔄 중복 제거 시작: 기준 컬럼 = ${parsedDeduplication.groupByColumn}, 전략 = ${parsedDeduplication.keepStrategy}`);
|
||||||
|
const originalCount = result.data.length;
|
||||||
|
finalData = {
|
||||||
|
...result,
|
||||||
|
data: this.deduplicateData(result.data, parsedDeduplication),
|
||||||
|
};
|
||||||
|
logger.info(`✅ 중복 제거 완료: ${originalCount}개 → ${finalData.data.length}개`);
|
||||||
|
}
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "Entity 조인 데이터 조회 성공",
|
message: "Entity 조인 데이터 조회 성공",
|
||||||
data: result,
|
data: finalData,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Entity 조인 데이터 조회 실패", error);
|
logger.error("Entity 조인 데이터 조회 실패", error);
|
||||||
|
|
@ -537,6 +569,98 @@ export class EntityJoinController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 중복 데이터 제거 (메모리 내 처리)
|
||||||
|
*/
|
||||||
|
private deduplicateData(
|
||||||
|
data: any[],
|
||||||
|
config: {
|
||||||
|
groupByColumn: string;
|
||||||
|
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
||||||
|
sortColumn?: string;
|
||||||
|
}
|
||||||
|
): any[] {
|
||||||
|
if (!data || data.length === 0) return data;
|
||||||
|
|
||||||
|
// 그룹별로 데이터 분류
|
||||||
|
const groups: Record<string, any[]> = {};
|
||||||
|
|
||||||
|
for (const row of data) {
|
||||||
|
const groupKey = row[config.groupByColumn];
|
||||||
|
if (groupKey === undefined || groupKey === null) continue;
|
||||||
|
|
||||||
|
if (!groups[groupKey]) {
|
||||||
|
groups[groupKey] = [];
|
||||||
|
}
|
||||||
|
groups[groupKey].push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 각 그룹에서 하나의 행만 선택
|
||||||
|
const result: any[] = [];
|
||||||
|
|
||||||
|
for (const [groupKey, rows] of Object.entries(groups)) {
|
||||||
|
if (rows.length === 0) continue;
|
||||||
|
|
||||||
|
let selectedRow: any;
|
||||||
|
|
||||||
|
switch (config.keepStrategy) {
|
||||||
|
case "latest":
|
||||||
|
// 정렬 컬럼 기준 최신 (가장 큰 값)
|
||||||
|
if (config.sortColumn) {
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
const aVal = a[config.sortColumn!];
|
||||||
|
const bVal = b[config.sortColumn!];
|
||||||
|
if (aVal === bVal) return 0;
|
||||||
|
if (aVal > bVal) return -1;
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
selectedRow = rows[0];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "earliest":
|
||||||
|
// 정렬 컬럼 기준 최초 (가장 작은 값)
|
||||||
|
if (config.sortColumn) {
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
const aVal = a[config.sortColumn!];
|
||||||
|
const bVal = b[config.sortColumn!];
|
||||||
|
if (aVal === bVal) return 0;
|
||||||
|
if (aVal < bVal) return -1;
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
selectedRow = rows[0];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "base_price":
|
||||||
|
// base_price가 true인 행 선택
|
||||||
|
selectedRow = rows.find((r) => r.base_price === true || r.base_price === "true") || rows[0];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "current_date":
|
||||||
|
// 오늘 날짜 기준 유효 기간 내 행 선택
|
||||||
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
selectedRow = rows.find((r) => {
|
||||||
|
const startDate = r.start_date;
|
||||||
|
const endDate = r.end_date;
|
||||||
|
if (!startDate) return true;
|
||||||
|
if (startDate <= today && (!endDate || endDate >= today)) return true;
|
||||||
|
return false;
|
||||||
|
}) || rows[0];
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
selectedRow = rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedRow) {
|
||||||
|
result.push(selectedRow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const entityJoinController = new EntityJoinController();
|
export const entityJoinController = new EntityJoinController();
|
||||||
|
|
|
||||||
|
|
@ -3769,6 +3769,15 @@ export class TableManagementService {
|
||||||
const cacheableJoins: EntityJoinConfig[] = [];
|
const cacheableJoins: EntityJoinConfig[] = [];
|
||||||
const dbJoins: EntityJoinConfig[] = [];
|
const dbJoins: EntityJoinConfig[] = [];
|
||||||
|
|
||||||
|
// 🔒 멀티테넌시: 회사별 데이터 테이블은 캐시 사용 불가 (company_code 필터링 필요)
|
||||||
|
const companySpecificTables = [
|
||||||
|
"supplier_mng",
|
||||||
|
"customer_mng",
|
||||||
|
"item_info",
|
||||||
|
"dept_info",
|
||||||
|
// 필요시 추가
|
||||||
|
];
|
||||||
|
|
||||||
for (const config of joinConfigs) {
|
for (const config of joinConfigs) {
|
||||||
// table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인
|
// table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인
|
||||||
if (config.referenceTable === "table_column_category_values") {
|
if (config.referenceTable === "table_column_category_values") {
|
||||||
|
|
@ -3777,6 +3786,13 @@ export class TableManagementService {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔒 회사별 데이터 테이블은 캐시 사용 불가 (멀티테넌시)
|
||||||
|
if (companySpecificTables.includes(config.referenceTable)) {
|
||||||
|
dbJoins.push(config);
|
||||||
|
console.log(`🔗 DB 조인 (멀티테넌시): ${config.referenceTable}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// 캐시 가능성 확인
|
// 캐시 가능성 확인
|
||||||
const cachedData = await referenceCacheService.getCachedReference(
|
const cachedData = await referenceCacheService.getCachedReference(
|
||||||
config.referenceTable,
|
config.referenceTable,
|
||||||
|
|
|
||||||
|
|
@ -174,8 +174,16 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
// 🆕 editData가 있으면 formData와 originalData로 설정 (수정 모드)
|
// 🆕 editData가 있으면 formData와 originalData로 설정 (수정 모드)
|
||||||
if (editData) {
|
if (editData) {
|
||||||
console.log("📝 [ScreenModal] 수정 데이터 설정:", editData);
|
console.log("📝 [ScreenModal] 수정 데이터 설정:", editData);
|
||||||
setFormData(editData);
|
|
||||||
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
|
// 🆕 배열인 경우 (그룹 레코드) vs 단일 객체 처리
|
||||||
|
if (Array.isArray(editData)) {
|
||||||
|
console.log(`📝 [ScreenModal] 그룹 레코드 ${editData.length}개 설정`);
|
||||||
|
setFormData(editData as any); // 배열 그대로 전달 (SelectedItemsDetailInput에서 처리)
|
||||||
|
setOriginalData(editData[0] || null); // 첫 번째 레코드를 원본으로 저장
|
||||||
|
} else {
|
||||||
|
setFormData(editData);
|
||||||
|
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 🆕 신규 등록 모드: 분할 패널 부모 데이터가 있으면 미리 설정
|
// 🆕 신규 등록 모드: 분할 패널 부모 데이터가 있으면 미리 설정
|
||||||
// 🔧 중요: 신규 등록 시에는 연결 필드(equipment_code 등)만 전달해야 함
|
// 🔧 중요: 신규 등록 시에는 연결 필드(equipment_code 등)만 전달해야 함
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,12 @@ export const entityJoinApi = {
|
||||||
filterColumn?: string;
|
filterColumn?: string;
|
||||||
filterValue?: any;
|
filterValue?: any;
|
||||||
}; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
|
}; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
|
||||||
|
deduplication?: {
|
||||||
|
enabled: boolean;
|
||||||
|
groupByColumn: string;
|
||||||
|
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
||||||
|
sortColumn?: string;
|
||||||
|
}; // 🆕 중복 제거 설정
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<EntityJoinResponse> => {
|
): Promise<EntityJoinResponse> => {
|
||||||
// 🔒 멀티테넌시: company_code 자동 필터링 활성화
|
// 🔒 멀티테넌시: company_code 자동 필터링 활성화
|
||||||
|
|
@ -99,6 +105,7 @@ export const entityJoinApi = {
|
||||||
autoFilter: JSON.stringify(autoFilter), // 🔒 멀티테넌시 필터링
|
autoFilter: JSON.stringify(autoFilter), // 🔒 멀티테넌시 필터링
|
||||||
dataFilter: params.dataFilter ? JSON.stringify(params.dataFilter) : undefined, // 🆕 데이터 필터
|
dataFilter: params.dataFilter ? JSON.stringify(params.dataFilter) : undefined, // 🆕 데이터 필터
|
||||||
excludeFilter: params.excludeFilter ? JSON.stringify(params.excludeFilter) : undefined, // 🆕 제외 필터
|
excludeFilter: params.excludeFilter ? JSON.stringify(params.excludeFilter) : undefined, // 🆕 제외 필터
|
||||||
|
deduplication: params.deduplication ? JSON.stringify(params.deduplication) : undefined, // 🆕 중복 제거 설정
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
|
|
|
||||||
|
|
@ -953,11 +953,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
|
|
||||||
console.log("🔗 [분할패널] 복합키 조건:", searchConditions);
|
console.log("🔗 [분할패널] 복합키 조건:", searchConditions);
|
||||||
|
|
||||||
// 엔티티 조인 API로 데이터 조회
|
// 엔티티 조인 API로 데이터 조회 (🆕 deduplication 전달)
|
||||||
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
|
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
|
||||||
search: searchConditions,
|
search: searchConditions,
|
||||||
enableEntityJoin: true,
|
enableEntityJoin: true,
|
||||||
size: 1000,
|
size: 1000,
|
||||||
|
deduplication: componentConfig.rightPanel?.deduplication, // 🆕 중복 제거 설정 전달
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("🔗 [분할패널] 복합키 조회 결과:", result);
|
console.log("🔗 [분할패널] 복합키 조회 결과:", result);
|
||||||
|
|
@ -1442,7 +1443,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
|
|
||||||
// 수정 버튼 핸들러
|
// 수정 버튼 핸들러
|
||||||
const handleEditClick = useCallback(
|
const handleEditClick = useCallback(
|
||||||
(panel: "left" | "right", item: any) => {
|
async (panel: "left" | "right", item: any) => {
|
||||||
// 🆕 우측 패널 수정 버튼 설정 확인
|
// 🆕 우측 패널 수정 버튼 설정 확인
|
||||||
if (panel === "right" && componentConfig.rightPanel?.editButton?.mode === "modal") {
|
if (panel === "right" && componentConfig.rightPanel?.editButton?.mode === "modal") {
|
||||||
const modalScreenId = componentConfig.rightPanel?.editButton?.modalScreenId;
|
const modalScreenId = componentConfig.rightPanel?.editButton?.modalScreenId;
|
||||||
|
|
@ -1465,11 +1466,86 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
// 🆕 groupByColumns 추출
|
// 🆕 groupByColumns 추출
|
||||||
const groupByColumns = componentConfig.rightPanel?.editButton?.groupByColumns || [];
|
const groupByColumns = componentConfig.rightPanel?.editButton?.groupByColumns || [];
|
||||||
|
|
||||||
console.log("🔧 [SplitPanel] 수정 버튼 클릭 - groupByColumns 확인:", {
|
console.log("========================================");
|
||||||
groupByColumns,
|
console.log("🔧 [SplitPanel] 수정 버튼 클릭!");
|
||||||
editButtonConfig: componentConfig.rightPanel?.editButton,
|
console.log("🔧 groupByColumns:", groupByColumns);
|
||||||
hasGroupByColumns: groupByColumns.length > 0,
|
console.log("🔧 item:", item);
|
||||||
});
|
console.log("🔧 rightData:", rightData);
|
||||||
|
console.log("🔧 rightData length:", rightData?.length);
|
||||||
|
console.log("========================================");
|
||||||
|
|
||||||
|
// 🆕 groupByColumns 기준으로 모든 관련 레코드 조회 (API 직접 호출)
|
||||||
|
let allRelatedRecords = [item]; // 기본값: 현재 아이템만
|
||||||
|
|
||||||
|
if (groupByColumns.length > 0) {
|
||||||
|
// groupByColumns 값으로 검색 조건 생성
|
||||||
|
const matchConditions: Record<string, any> = {};
|
||||||
|
groupByColumns.forEach((col: string) => {
|
||||||
|
if (item[col] !== undefined && item[col] !== null) {
|
||||||
|
matchConditions[col] = item[col];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("🔍 [SplitPanel] 그룹 레코드 조회 시작:", {
|
||||||
|
테이블: rightTableName,
|
||||||
|
조건: matchConditions,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Object.keys(matchConditions).length > 0) {
|
||||||
|
// 🆕 deduplication 없이 원본 데이터 다시 조회 (API 직접 호출)
|
||||||
|
try {
|
||||||
|
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
||||||
|
|
||||||
|
// 🔧 dataFilter로 정확 매칭 조건 생성 (search는 LIKE 검색이라 부정확)
|
||||||
|
const exactMatchFilters = Object.entries(matchConditions).map(([key, value]) => ({
|
||||||
|
id: `exact-${key}`,
|
||||||
|
columnName: key,
|
||||||
|
operator: "equals",
|
||||||
|
value: value,
|
||||||
|
valueType: "text",
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log("🔍 [SplitPanel] 정확 매칭 필터:", exactMatchFilters);
|
||||||
|
|
||||||
|
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
|
||||||
|
// search 대신 dataFilter 사용 (정확 매칭)
|
||||||
|
dataFilter: {
|
||||||
|
enabled: true,
|
||||||
|
matchType: "all",
|
||||||
|
filters: exactMatchFilters,
|
||||||
|
},
|
||||||
|
enableEntityJoin: true,
|
||||||
|
size: 1000,
|
||||||
|
// 🔧 명시적으로 deduplication 비활성화 (모든 레코드 가져오기)
|
||||||
|
deduplication: { enabled: false, groupByColumn: "", keepStrategy: "latest" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🔍 디버깅: API 응답 구조 확인
|
||||||
|
console.log("🔍 [SplitPanel] API 응답 전체:", result);
|
||||||
|
console.log("🔍 [SplitPanel] result.data:", result.data);
|
||||||
|
console.log("🔍 [SplitPanel] result 타입:", typeof result);
|
||||||
|
|
||||||
|
// result 자체가 배열일 수도 있음 (entityJoinApi 응답 구조에 따라)
|
||||||
|
const dataArray = Array.isArray(result) ? result : (result.data || []);
|
||||||
|
|
||||||
|
if (dataArray.length > 0) {
|
||||||
|
allRelatedRecords = dataArray;
|
||||||
|
console.log("✅ [SplitPanel] 그룹 레코드 조회 완료:", {
|
||||||
|
조건: matchConditions,
|
||||||
|
결과수: allRelatedRecords.length,
|
||||||
|
레코드들: allRelatedRecords.map((r: any) => ({ id: r.id, supplier_item_code: r.supplier_item_code })),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn("⚠️ [SplitPanel] 그룹 레코드 조회 결과 없음, 현재 아이템만 사용");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ [SplitPanel] 그룹 레코드 조회 실패:", error);
|
||||||
|
allRelatedRecords = [item];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn("⚠️ [SplitPanel] groupByColumns 값이 없음, 현재 아이템만 사용");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🔧 수정: URL 파라미터 대신 editData로 직접 전달
|
// 🔧 수정: URL 파라미터 대신 editData로 직접 전달
|
||||||
// 이렇게 하면 테이블의 Primary Key가 무엇이든 상관없이 데이터가 정확히 전달됨
|
// 이렇게 하면 테이블의 Primary Key가 무엇이든 상관없이 데이터가 정확히 전달됨
|
||||||
|
|
@ -1477,19 +1553,21 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
new CustomEvent("openScreenModal", {
|
new CustomEvent("openScreenModal", {
|
||||||
detail: {
|
detail: {
|
||||||
screenId: modalScreenId,
|
screenId: modalScreenId,
|
||||||
editData: item, // 전체 데이터를 직접 전달
|
editData: allRelatedRecords, // 🆕 모든 관련 레코드 전달 (배열)
|
||||||
...(groupByColumns.length > 0 && {
|
urlParams: {
|
||||||
urlParams: {
|
mode: "edit", // 🆕 수정 모드 표시
|
||||||
|
...(groupByColumns.length > 0 && {
|
||||||
groupByColumns: JSON.stringify(groupByColumns),
|
groupByColumns: JSON.stringify(groupByColumns),
|
||||||
},
|
}),
|
||||||
}),
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log("✅ [SplitPanel] openScreenModal 이벤트 발생 (editData 직접 전달):", {
|
console.log("✅ [SplitPanel] openScreenModal 이벤트 발생 (editData 직접 전달):", {
|
||||||
screenId: modalScreenId,
|
screenId: modalScreenId,
|
||||||
editData: item,
|
editData: allRelatedRecords,
|
||||||
|
recordCount: allRelatedRecords.length,
|
||||||
groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음",
|
groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue