Merge branch 'jskim-node' of https://g.wace.me/jskim/vexplor_dev into ycshin-node
This commit is contained in:
@@ -3,25 +3,26 @@
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
#
|
||||
# ⚠️ 주의: 이 파일은 Git에 커밋됩니다!
|
||||
# 팀원들이 동일한 API 키를 사용합니다.
|
||||
# 실제 API 키는 .env 파일에 설정하세요.
|
||||
# 여기에는 키 형식 예시만 기록합니다.
|
||||
#
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
# 한국은행 환율 API 키
|
||||
# 발급: https://www.bok.or.kr/portal/openapi/OpenApiGuide.do
|
||||
BOK_API_KEY=OXIGPQXH68NUKVKL5KT9
|
||||
BOK_API_KEY=your_bok_api_key_here
|
||||
|
||||
# 기상청 API Hub 키
|
||||
# 발급: https://apihub.kma.go.kr/
|
||||
KMA_API_KEY=ogdXr2e9T4iHV69nvV-IwA
|
||||
KMA_API_KEY=your_kma_api_key_here
|
||||
|
||||
# ITS 국가교통정보센터 API 키
|
||||
# 발급: https://www.its.go.kr/
|
||||
ITS_API_KEY=d6b9befec3114d648284674b8fddcc32
|
||||
ITS_API_KEY=your_its_api_key_here
|
||||
|
||||
# 한국도로공사 OpenOASIS API 키
|
||||
# 발급: https://data.ex.co.kr/ (OpenOASIS 신청)
|
||||
EXWAY_API_KEY=7820214492
|
||||
EXWAY_API_KEY=your_exway_api_key_here
|
||||
|
||||
# ExchangeRate API 키 (백업용, 선택사항)
|
||||
# 발급: https://www.exchangerate-api.com/
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
### ✅ 작동 중인 API
|
||||
|
||||
1. **기상청 특보 API** (완벽 작동!)
|
||||
- API 키: `ogdXr2e9T4iHV69nvV-IwA`
|
||||
- API 키: `${KMA_API_KEY}`
|
||||
- 상태: ✅ 14건 실시간 특보 수신 중
|
||||
- 제공 데이터: 대설/강풍/한파/태풍/폭염 특보
|
||||
|
||||
2. **한국은행 환율 API** (완벽 작동!)
|
||||
- API 키: `OXIGPQXH68NUKVKL5KT9`
|
||||
- API 키: `${BOK_API_KEY}`
|
||||
- 상태: ✅ 환율 위젯 작동 중
|
||||
|
||||
### ⚠️ 더미 데이터 사용 중
|
||||
@@ -59,7 +59,7 @@ docker restart pms-backend-mac
|
||||
|
||||
### 발급된 키
|
||||
```
|
||||
EXWAY_API_KEY=7820214492
|
||||
EXWAY_API_KEY=${EXWAY_API_KEY}
|
||||
```
|
||||
|
||||
### 문제 상황
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
## ✅ 완벽 작동 중
|
||||
|
||||
### 1. 기상청 API Hub
|
||||
- **API 키**: `ogdXr2e9T4iHV69nvV-IwA`
|
||||
- **API 키**: `${KMA_API_KEY}`
|
||||
- **상태**: ✅ 14건 실시간 특보 수신 중
|
||||
- **제공 데이터**: 대설/강풍/한파/태풍/폭염 특보
|
||||
- **코드 위치**: `backend-node/src/services/riskAlertService.ts`
|
||||
|
||||
### 2. 한국은행 환율 API
|
||||
- **API 키**: `OXIGPQXH68NUKVKL5KT9`
|
||||
- **API 키**: `${BOK_API_KEY}`
|
||||
- **상태**: ✅ 환율 위젯 작동 중
|
||||
- **제공 데이터**: USD/EUR/JPY/CNY 환율
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
## ⚠️ 연동 대기 중
|
||||
|
||||
### 3. 한국도로공사 OpenOASIS API
|
||||
- **API 키**: `7820214492`
|
||||
- **API 키**: `${EXWAY_API_KEY}`
|
||||
- **상태**: ❌ 엔드포인트 URL 불명
|
||||
- **문제**:
|
||||
- 발급 이메일에 사용법 없음
|
||||
@@ -34,7 +34,7 @@
|
||||
시스템 장애: 070-8656-8771
|
||||
|
||||
문의 내용:
|
||||
"OpenOASIS API 인증키(7820214492)를 발급받았는데
|
||||
"OpenOASIS API 인증키(${EXWAY_API_KEY})를 발급받았는데
|
||||
사용 방법과 엔드포인트 URL을 알려주세요.
|
||||
- 돌발상황정보 API
|
||||
- 교통사고 정보
|
||||
@@ -42,7 +42,7 @@
|
||||
```
|
||||
|
||||
### 4. 국토교통부 ITS API
|
||||
- **API 키**: `d6b9befec3114d648284674b8fddcc32`
|
||||
- **API 키**: `${ITS_API_KEY}`
|
||||
- **상태**: ❌ 엔드포인트 URL 불명
|
||||
- **승인 API**:
|
||||
- 교통소통정보
|
||||
@@ -63,7 +63,7 @@
|
||||
이메일: its@ex.co.kr
|
||||
|
||||
문의 내용:
|
||||
"ITS API 인증키(d6b9befec3114d648284674b8fddcc32)를
|
||||
"ITS API 인증키(${ITS_API_KEY})를
|
||||
발급받았는데 매뉴얼에 엔드포인트 URL이 없습니다.
|
||||
돌발상황정보 API의 정확한 URL과 파라미터를
|
||||
알려주세요."
|
||||
@@ -88,8 +88,8 @@
|
||||
### 연동 방법
|
||||
```bash
|
||||
# .env 파일에 추가
|
||||
ITS_API_KEY=d6b9befec3114d648284674b8fddcc32
|
||||
EXWAY_API_KEY=7820214492
|
||||
ITS_API_KEY=${ITS_API_KEY}
|
||||
EXWAY_API_KEY=${EXWAY_API_KEY}
|
||||
|
||||
# 백엔드 재시작
|
||||
docker restart pms-backend-mac
|
||||
|
||||
@@ -48,7 +48,7 @@ npm install
|
||||
`.env` 파일을 생성하고 다음 내용을 추가하세요:
|
||||
|
||||
```env
|
||||
DATABASE_URL="postgresql://postgres:ph0909!!@39.117.244.52:11132/plm"
|
||||
DATABASE_URL="postgresql://postgres:YOUR_PASSWORD@YOUR_HOST:YOUR_PORT/YOUR_DB"
|
||||
JWT_SECRET="your-super-secret-jwt-key-change-in-production"
|
||||
JWT_EXPIRES_IN="24h"
|
||||
PORT=8080
|
||||
|
||||
@@ -19,19 +19,19 @@ cp .env.shared .env
|
||||
|
||||
### ✅ 한국은행 환율 API
|
||||
- 용도: 환율 정보 조회
|
||||
- 키: `OXIGPQXH68NUKVKL5KT9`
|
||||
- 키: `${BOK_API_KEY}`
|
||||
|
||||
### ✅ 기상청 API Hub
|
||||
- 용도: 날씨특보, 기상정보
|
||||
- 키: `ogdXr2e9T4iHV69nvV-IwA`
|
||||
- 키: `${KMA_API_KEY}`
|
||||
|
||||
### ✅ ITS 국가교통정보센터
|
||||
- 용도: 교통사고, 도로공사 정보
|
||||
- 키: `d6b9befec3114d648284674b8fddcc32`
|
||||
- 키: `${ITS_API_KEY}`
|
||||
|
||||
### ✅ 한국도로공사 OpenOASIS
|
||||
- 용도: 고속도로 교통정보
|
||||
- 키: `7820214492`
|
||||
- 키: `${EXWAY_API_KEY}`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function addButtonWebType() {
|
||||
try {
|
||||
console.log("🔍 버튼 웹타입 확인 중...");
|
||||
|
||||
// 기존 button 웹타입 확인
|
||||
const existingButton = await prisma.web_type_standards.findUnique({
|
||||
where: { web_type: "button" },
|
||||
});
|
||||
|
||||
if (existingButton) {
|
||||
console.log("✅ 버튼 웹타입이 이미 존재합니다.");
|
||||
console.log("📄 기존 설정:", JSON.stringify(existingButton, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("➕ 버튼 웹타입 추가 중...");
|
||||
|
||||
// 버튼 웹타입 추가
|
||||
const buttonWebType = await prisma.web_type_standards.create({
|
||||
data: {
|
||||
web_type: "button",
|
||||
type_name: "버튼",
|
||||
type_name_eng: "Button",
|
||||
description: "클릭 가능한 버튼 컴포넌트",
|
||||
category: "action",
|
||||
component_name: "ButtonWidget",
|
||||
config_panel: "ButtonConfigPanel",
|
||||
default_config: {
|
||||
actionType: "custom",
|
||||
variant: "default",
|
||||
},
|
||||
sort_order: 100,
|
||||
is_active: "Y",
|
||||
created_by: "system",
|
||||
updated_by: "system",
|
||||
},
|
||||
});
|
||||
|
||||
console.log("✅ 버튼 웹타입이 성공적으로 추가되었습니다!");
|
||||
console.log("📄 추가된 설정:", JSON.stringify(buttonWebType, null, 2));
|
||||
} catch (error) {
|
||||
console.error("❌ 버튼 웹타입 추가 실패:", error);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
addButtonWebType();
|
||||
@@ -1,34 +0,0 @@
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function addDataMappingColumn() {
|
||||
try {
|
||||
console.log(
|
||||
"🔄 external_call_configs 테이블에 data_mapping_config 컬럼 추가 중..."
|
||||
);
|
||||
|
||||
// data_mapping_config JSONB 컬럼 추가
|
||||
await prisma.$executeRaw`
|
||||
ALTER TABLE external_call_configs
|
||||
ADD COLUMN IF NOT EXISTS data_mapping_config JSONB
|
||||
`;
|
||||
|
||||
console.log("✅ data_mapping_config 컬럼이 성공적으로 추가되었습니다.");
|
||||
|
||||
// 기존 레코드에 기본값 설정
|
||||
await prisma.$executeRaw`
|
||||
UPDATE external_call_configs
|
||||
SET data_mapping_config = '{"direction": "none"}'::jsonb
|
||||
WHERE data_mapping_config IS NULL
|
||||
`;
|
||||
|
||||
console.log("✅ 기존 레코드에 기본값이 설정되었습니다.");
|
||||
} catch (error) {
|
||||
console.error("❌ 컬럼 추가 실패:", error);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
addDataMappingColumn();
|
||||
@@ -27,11 +27,11 @@ async function addExternalDbConnection() {
|
||||
name: "운영_외부_PostgreSQL",
|
||||
description: "운영용 외부 PostgreSQL 데이터베이스",
|
||||
dbType: "postgresql",
|
||||
host: "39.117.244.52",
|
||||
port: 11132,
|
||||
databaseName: "plm",
|
||||
username: "postgres",
|
||||
password: "ph0909!!", // 이 값은 암호화되어 저장됩니다
|
||||
host: process.env.EXT_DB_HOST || "localhost",
|
||||
port: parseInt(process.env.EXT_DB_PORT || "5432"),
|
||||
databaseName: process.env.EXT_DB_NAME || "vexplor_dev",
|
||||
username: process.env.EXT_DB_USER || "postgres",
|
||||
password: process.env.EXT_DB_PASSWORD || "", // 환경변수로 전달
|
||||
sslEnabled: false,
|
||||
isActive: true,
|
||||
},
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function addMissingColumns() {
|
||||
try {
|
||||
console.log("🔄 누락된 컬럼들을 screen_layouts 테이블에 추가 중...");
|
||||
|
||||
// layout_type 컬럼 추가
|
||||
try {
|
||||
await prisma.$executeRaw`
|
||||
ALTER TABLE screen_layouts
|
||||
ADD COLUMN IF NOT EXISTS layout_type VARCHAR(50);
|
||||
`;
|
||||
console.log("✅ layout_type 컬럼 추가 완료");
|
||||
} catch (error) {
|
||||
console.log(
|
||||
"ℹ️ layout_type 컬럼이 이미 존재하거나 추가 중 오류:",
|
||||
error.message
|
||||
);
|
||||
}
|
||||
|
||||
// layout_config 컬럼 추가
|
||||
try {
|
||||
await prisma.$executeRaw`
|
||||
ALTER TABLE screen_layouts
|
||||
ADD COLUMN IF NOT EXISTS layout_config JSONB;
|
||||
`;
|
||||
console.log("✅ layout_config 컬럼 추가 완료");
|
||||
} catch (error) {
|
||||
console.log(
|
||||
"ℹ️ layout_config 컬럼이 이미 존재하거나 추가 중 오류:",
|
||||
error.message
|
||||
);
|
||||
}
|
||||
|
||||
// zones_config 컬럼 추가
|
||||
try {
|
||||
await prisma.$executeRaw`
|
||||
ALTER TABLE screen_layouts
|
||||
ADD COLUMN IF NOT EXISTS zones_config JSONB;
|
||||
`;
|
||||
console.log("✅ zones_config 컬럼 추가 완료");
|
||||
} catch (error) {
|
||||
console.log(
|
||||
"ℹ️ zones_config 컬럼이 이미 존재하거나 추가 중 오류:",
|
||||
error.message
|
||||
);
|
||||
}
|
||||
|
||||
// zone_id 컬럼 추가
|
||||
try {
|
||||
await prisma.$executeRaw`
|
||||
ALTER TABLE screen_layouts
|
||||
ADD COLUMN IF NOT EXISTS zone_id VARCHAR(100);
|
||||
`;
|
||||
console.log("✅ zone_id 컬럼 추가 완료");
|
||||
} catch (error) {
|
||||
console.log(
|
||||
"ℹ️ zone_id 컬럼이 이미 존재하거나 추가 중 오류:",
|
||||
error.message
|
||||
);
|
||||
}
|
||||
|
||||
// 인덱스 생성 (성능 향상)
|
||||
try {
|
||||
await prisma.$executeRaw`
|
||||
CREATE INDEX IF NOT EXISTS idx_screen_layouts_layout_type
|
||||
ON screen_layouts(layout_type);
|
||||
`;
|
||||
console.log("✅ layout_type 인덱스 생성 완료");
|
||||
} catch (error) {
|
||||
console.log("ℹ️ layout_type 인덱스 생성 중 오류:", error.message);
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.$executeRaw`
|
||||
CREATE INDEX IF NOT EXISTS idx_screen_layouts_zone_id
|
||||
ON screen_layouts(zone_id);
|
||||
`;
|
||||
console.log("✅ zone_id 인덱스 생성 완료");
|
||||
} catch (error) {
|
||||
console.log("ℹ️ zone_id 인덱스 생성 중 오류:", error.message);
|
||||
}
|
||||
|
||||
// 최종 테이블 구조 확인
|
||||
const columns = await prisma.$queryRaw`
|
||||
SELECT column_name, data_type, is_nullable, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'screen_layouts'
|
||||
ORDER BY ordinal_position
|
||||
`;
|
||||
|
||||
console.log("\n📋 screen_layouts 테이블 최종 구조:");
|
||||
console.table(columns);
|
||||
|
||||
console.log("\n🎉 모든 누락된 컬럼 추가 작업이 완료되었습니다!");
|
||||
} catch (error) {
|
||||
console.error("❌ 컬럼 추가 중 오류 발생:", error);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
addMissingColumns();
|
||||
@@ -1,318 +0,0 @@
|
||||
/**
|
||||
* 탑씰(company_7) 버튼 스타일 일괄 변경 스크립트
|
||||
*
|
||||
* 사용법:
|
||||
* npx ts-node scripts/btn-bulk-update-company7.ts --test # 1건만 테스트 (ROLLBACK)
|
||||
* npx ts-node scripts/btn-bulk-update-company7.ts --run # 전체 실행 (COMMIT)
|
||||
* npx ts-node scripts/btn-bulk-update-company7.ts --backup # 백업 테이블만 생성
|
||||
* npx ts-node scripts/btn-bulk-update-company7.ts --restore # 백업에서 원복
|
||||
*/
|
||||
|
||||
import { Pool } from "pg";
|
||||
|
||||
// ── 배포 DB 연결 ──
|
||||
const pool = new Pool({
|
||||
connectionString:
|
||||
"postgresql://postgres:vexplor0909!!@211.115.91.141:11134/vexplor",
|
||||
});
|
||||
|
||||
const COMPANY_CODE = "COMPANY_7";
|
||||
const BACKUP_TABLE = "screen_layouts_v2_backup_20260313";
|
||||
|
||||
// ── 액션별 기본 아이콘 매핑 (frontend/lib/button-icon-map.tsx 기준) ──
|
||||
const actionIconMap: Record<string, string> = {
|
||||
save: "Check",
|
||||
delete: "Trash2",
|
||||
edit: "Pencil",
|
||||
navigate: "ArrowRight",
|
||||
modal: "Maximize2",
|
||||
transferData: "SendHorizontal",
|
||||
excel_download: "Download",
|
||||
excel_upload: "Upload",
|
||||
quickInsert: "Zap",
|
||||
control: "Settings",
|
||||
barcode_scan: "ScanLine",
|
||||
operation_control: "Truck",
|
||||
event: "Send",
|
||||
copy: "Copy",
|
||||
};
|
||||
const FALLBACK_ICON = "SquareMousePointer";
|
||||
|
||||
function getIconForAction(actionType?: string): string {
|
||||
if (actionType && actionIconMap[actionType]) {
|
||||
return actionIconMap[actionType];
|
||||
}
|
||||
return FALLBACK_ICON;
|
||||
}
|
||||
|
||||
// ── 버튼 컴포넌트인지 판별 (최상위 + 탭 내부 둘 다 지원) ──
|
||||
function isTopLevelButton(comp: any): boolean {
|
||||
return (
|
||||
comp.url?.includes("v2-button-primary") ||
|
||||
comp.overrides?.type === "v2-button-primary"
|
||||
);
|
||||
}
|
||||
|
||||
function isTabChildButton(comp: any): boolean {
|
||||
return comp.componentType === "v2-button-primary";
|
||||
}
|
||||
|
||||
function isButtonComponent(comp: any): boolean {
|
||||
return isTopLevelButton(comp) || isTabChildButton(comp);
|
||||
}
|
||||
|
||||
// ── 탭 위젯인지 판별 ──
|
||||
function isTabsWidget(comp: any): boolean {
|
||||
return (
|
||||
comp.url?.includes("v2-tabs-widget") ||
|
||||
comp.overrides?.type === "v2-tabs-widget"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 버튼 스타일 변경 (최상위 버튼용: overrides 사용) ──
|
||||
function applyButtonStyle(config: any, actionType: string | undefined) {
|
||||
const iconName = getIconForAction(actionType);
|
||||
|
||||
config.displayMode = "icon-text";
|
||||
|
||||
config.icon = {
|
||||
name: iconName,
|
||||
type: "lucide",
|
||||
size: "보통",
|
||||
...(config.icon?.color ? { color: config.icon.color } : {}),
|
||||
};
|
||||
|
||||
config.iconTextPosition = "right";
|
||||
config.iconGap = 6;
|
||||
|
||||
if (!config.style) config.style = {};
|
||||
delete config.style.width; // 레거시 하드코딩 너비 제거 (size.width만 사용)
|
||||
config.style.borderRadius = "8px";
|
||||
config.style.labelColor = "#FFFFFF";
|
||||
config.style.fontSize = "12px";
|
||||
config.style.fontWeight = "normal";
|
||||
config.style.labelTextAlign = "left";
|
||||
|
||||
if (actionType === "delete") {
|
||||
config.style.backgroundColor = "#F04544";
|
||||
} else if (actionType === "excel_upload" || actionType === "excel_download") {
|
||||
config.style.backgroundColor = "#212121";
|
||||
} else {
|
||||
config.style.backgroundColor = "#3B83F6";
|
||||
}
|
||||
}
|
||||
|
||||
function updateButtonStyle(comp: any): boolean {
|
||||
if (isTopLevelButton(comp)) {
|
||||
const overrides = comp.overrides || {};
|
||||
const actionType = overrides.action?.type;
|
||||
|
||||
if (!comp.size) comp.size = {};
|
||||
comp.size.height = 40;
|
||||
|
||||
applyButtonStyle(overrides, actionType);
|
||||
comp.overrides = overrides;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isTabChildButton(comp)) {
|
||||
const config = comp.componentConfig || {};
|
||||
const actionType = config.action?.type;
|
||||
|
||||
if (!comp.size) comp.size = {};
|
||||
comp.size.height = 40;
|
||||
|
||||
applyButtonStyle(config, actionType);
|
||||
comp.componentConfig = config;
|
||||
|
||||
// 탭 내부 버튼은 렌더러가 comp.style (최상위)에서 스타일을 읽음
|
||||
if (!comp.style) comp.style = {};
|
||||
comp.style.borderRadius = "8px";
|
||||
comp.style.labelColor = "#FFFFFF";
|
||||
comp.style.fontSize = "12px";
|
||||
comp.style.fontWeight = "normal";
|
||||
comp.style.labelTextAlign = "left";
|
||||
comp.style.backgroundColor = config.style.backgroundColor;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── 백업 테이블 생성 ──
|
||||
async function createBackup() {
|
||||
console.log(`\n=== 백업 테이블 생성: ${BACKUP_TABLE} ===`);
|
||||
|
||||
const exists = await pool.query(
|
||||
`SELECT to_regclass($1) AS tbl`,
|
||||
[BACKUP_TABLE],
|
||||
);
|
||||
if (exists.rows[0].tbl) {
|
||||
console.log(`백업 테이블이 이미 존재합니다: ${BACKUP_TABLE}`);
|
||||
const count = await pool.query(`SELECT COUNT(*) FROM ${BACKUP_TABLE}`);
|
||||
console.log(`기존 백업 레코드 수: ${count.rows[0].count}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`CREATE TABLE ${BACKUP_TABLE} AS
|
||||
SELECT * FROM screen_layouts_v2
|
||||
WHERE company_code = $1`,
|
||||
[COMPANY_CODE],
|
||||
);
|
||||
|
||||
const count = await pool.query(`SELECT COUNT(*) FROM ${BACKUP_TABLE}`);
|
||||
console.log(`백업 완료. 레코드 수: ${count.rows[0].count}`);
|
||||
}
|
||||
|
||||
// ── 백업에서 원복 ──
|
||||
async function restoreFromBackup() {
|
||||
console.log(`\n=== 백업에서 원복: ${BACKUP_TABLE} ===`);
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE screen_layouts_v2 AS target
|
||||
SET layout_data = backup.layout_data,
|
||||
updated_at = backup.updated_at
|
||||
FROM ${BACKUP_TABLE} AS backup
|
||||
WHERE target.screen_id = backup.screen_id
|
||||
AND target.company_code = backup.company_code
|
||||
AND target.layer_id = backup.layer_id`,
|
||||
);
|
||||
console.log(`원복 완료. 변경된 레코드 수: ${result.rowCount}`);
|
||||
}
|
||||
|
||||
// ── 메인: 버튼 일괄 변경 ──
|
||||
async function updateButtons(testMode: boolean) {
|
||||
const modeLabel = testMode ? "테스트 (1건, ROLLBACK)" : "전체 실행 (COMMIT)";
|
||||
console.log(`\n=== 버튼 일괄 변경 시작 [${modeLabel}] ===`);
|
||||
|
||||
// company_7 레코드 조회
|
||||
const rows = await pool.query(
|
||||
`SELECT screen_id, layer_id, company_code, layout_data
|
||||
FROM screen_layouts_v2
|
||||
WHERE company_code = $1
|
||||
ORDER BY screen_id, layer_id`,
|
||||
[COMPANY_CODE],
|
||||
);
|
||||
console.log(`대상 레코드 수: ${rows.rowCount}`);
|
||||
|
||||
if (!rows.rowCount) {
|
||||
console.log("변경할 레코드가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
let totalUpdated = 0;
|
||||
let totalButtons = 0;
|
||||
const targetRows = testMode ? [rows.rows[0]] : rows.rows;
|
||||
|
||||
for (const row of targetRows) {
|
||||
const layoutData = row.layout_data;
|
||||
if (!layoutData?.components || !Array.isArray(layoutData.components)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let buttonsInRow = 0;
|
||||
for (const comp of layoutData.components) {
|
||||
// 최상위 버튼 처리
|
||||
if (updateButtonStyle(comp)) {
|
||||
buttonsInRow++;
|
||||
}
|
||||
|
||||
// 탭 위젯 내부 버튼 처리
|
||||
if (isTabsWidget(comp)) {
|
||||
const tabs = comp.overrides?.tabs || [];
|
||||
for (const tab of tabs) {
|
||||
const tabComps = tab.components || [];
|
||||
for (const tabComp of tabComps) {
|
||||
if (updateButtonStyle(tabComp)) {
|
||||
buttonsInRow++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (buttonsInRow > 0) {
|
||||
await client.query(
|
||||
`UPDATE screen_layouts_v2
|
||||
SET layout_data = $1, updated_at = NOW()
|
||||
WHERE screen_id = $2 AND company_code = $3 AND layer_id = $4`,
|
||||
[JSON.stringify(layoutData), row.screen_id, row.company_code, row.layer_id],
|
||||
);
|
||||
totalUpdated++;
|
||||
totalButtons += buttonsInRow;
|
||||
|
||||
console.log(
|
||||
` screen_id=${row.screen_id}, layer_id=${row.layer_id} → 버튼 ${buttonsInRow}개 변경`,
|
||||
);
|
||||
|
||||
// 테스트 모드: 변경 전후 비교를 위해 첫 번째 버튼 출력
|
||||
if (testMode) {
|
||||
const sampleBtn = layoutData.components.find(isButtonComponent);
|
||||
if (sampleBtn) {
|
||||
console.log("\n--- 변경 후 샘플 버튼 ---");
|
||||
console.log(JSON.stringify(sampleBtn, null, 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n--- 결과 ---`);
|
||||
console.log(`변경된 레코드: ${totalUpdated}개`);
|
||||
console.log(`변경된 버튼: ${totalButtons}개`);
|
||||
|
||||
if (testMode) {
|
||||
await client.query("ROLLBACK");
|
||||
console.log("\n[테스트 모드] ROLLBACK 완료. 실제 DB 변경 없음.");
|
||||
} else {
|
||||
await client.query("COMMIT");
|
||||
console.log("\nCOMMIT 완료.");
|
||||
}
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK");
|
||||
console.error("\n에러 발생. ROLLBACK 완료.", err);
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// ── CLI 진입점 ──
|
||||
async function main() {
|
||||
const arg = process.argv[2];
|
||||
|
||||
if (!arg || !["--test", "--run", "--backup", "--restore"].includes(arg)) {
|
||||
console.log("사용법:");
|
||||
console.log(" --test : 1건 테스트 (ROLLBACK, DB 변경 없음)");
|
||||
console.log(" --run : 전체 실행 (COMMIT)");
|
||||
console.log(" --backup : 백업 테이블 생성");
|
||||
console.log(" --restore : 백업에서 원복");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
if (arg === "--backup") {
|
||||
await createBackup();
|
||||
} else if (arg === "--restore") {
|
||||
await restoreFromBackup();
|
||||
} else if (arg === "--test") {
|
||||
await createBackup();
|
||||
await updateButtons(true);
|
||||
} else if (arg === "--run") {
|
||||
await createBackup();
|
||||
await updateButtons(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("스크립트 실행 실패:", err);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* dashboards 테이블 구조 확인 스크립트
|
||||
*/
|
||||
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm';
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: databaseUrl,
|
||||
});
|
||||
|
||||
async function checkDashboardStructure() {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
console.log('🔍 dashboards 테이블 구조 확인 중...\n');
|
||||
|
||||
// 컬럼 정보 조회
|
||||
const columns = await client.query(`
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable,
|
||||
column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'dashboards'
|
||||
ORDER BY ordinal_position
|
||||
`);
|
||||
|
||||
console.log('📋 dashboards 테이블 컬럼:\n');
|
||||
columns.rows.forEach((col, index) => {
|
||||
console.log(`${index + 1}. ${col.column_name} (${col.data_type}) - Nullable: ${col.is_nullable}`);
|
||||
});
|
||||
|
||||
// 샘플 데이터 조회
|
||||
console.log('\n📊 샘플 데이터 (첫 1개):');
|
||||
const sample = await client.query(`
|
||||
SELECT * FROM dashboards LIMIT 1
|
||||
`);
|
||||
|
||||
if (sample.rows.length > 0) {
|
||||
console.log(JSON.stringify(sample.rows[0], null, 2));
|
||||
} else {
|
||||
console.log('❌ 데이터가 없습니다.');
|
||||
}
|
||||
|
||||
// dashboard_elements 테이블도 확인
|
||||
console.log('\n🔍 dashboard_elements 테이블 구조 확인 중...\n');
|
||||
|
||||
const elemColumns = await client.query(`
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'dashboard_elements'
|
||||
ORDER BY ordinal_position
|
||||
`);
|
||||
|
||||
console.log('📋 dashboard_elements 테이블 컬럼:\n');
|
||||
elemColumns.rows.forEach((col, index) => {
|
||||
console.log(`${index + 1}. ${col.column_name} (${col.data_type}) - Nullable: ${col.is_nullable}`);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 오류 발생:', error.message);
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
checkDashboardStructure();
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* 데이터베이스 테이블 확인 스크립트
|
||||
*/
|
||||
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm';
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: databaseUrl,
|
||||
});
|
||||
|
||||
async function checkTables() {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
console.log('🔍 데이터베이스 테이블 확인 중...\n');
|
||||
|
||||
// 테이블 목록 조회
|
||||
const result = await client.query(`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
ORDER BY table_name
|
||||
`);
|
||||
|
||||
console.log(`📊 총 ${result.rows.length}개의 테이블 발견:\n`);
|
||||
result.rows.forEach((row, index) => {
|
||||
console.log(`${index + 1}. ${row.table_name}`);
|
||||
});
|
||||
|
||||
// dashboard 관련 테이블 검색
|
||||
console.log('\n🔎 dashboard 관련 테이블:');
|
||||
const dashboardTables = result.rows.filter(row =>
|
||||
row.table_name.toLowerCase().includes('dashboard')
|
||||
);
|
||||
|
||||
if (dashboardTables.length === 0) {
|
||||
console.log('❌ dashboard 관련 테이블을 찾을 수 없습니다.');
|
||||
} else {
|
||||
dashboardTables.forEach(row => {
|
||||
console.log(`✅ ${row.table_name}`);
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 오류 발생:', error.message);
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
checkTables();
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function createComponentTable() {
|
||||
try {
|
||||
console.log("🔧 component_standards 테이블 생성 중...");
|
||||
|
||||
// 테이블 생성 SQL
|
||||
await prisma.$executeRaw`
|
||||
CREATE TABLE IF NOT EXISTS component_standards (
|
||||
component_code VARCHAR(50) PRIMARY KEY,
|
||||
component_name VARCHAR(100) NOT NULL,
|
||||
component_name_eng VARCHAR(100),
|
||||
description TEXT,
|
||||
category VARCHAR(50) NOT NULL,
|
||||
icon_name VARCHAR(50),
|
||||
default_size JSON,
|
||||
component_config JSON NOT NULL,
|
||||
preview_image VARCHAR(255),
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
is_active CHAR(1) DEFAULT 'Y',
|
||||
is_public CHAR(1) DEFAULT 'Y',
|
||||
company_code VARCHAR(50) NOT NULL,
|
||||
created_date TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by VARCHAR(50),
|
||||
updated_date TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_by VARCHAR(50)
|
||||
)
|
||||
`;
|
||||
|
||||
console.log("✅ component_standards 테이블 생성 완료");
|
||||
|
||||
// 인덱스 생성
|
||||
await prisma.$executeRaw`
|
||||
CREATE INDEX IF NOT EXISTS idx_component_standards_category
|
||||
ON component_standards (category)
|
||||
`;
|
||||
|
||||
await prisma.$executeRaw`
|
||||
CREATE INDEX IF NOT EXISTS idx_component_standards_company
|
||||
ON component_standards (company_code)
|
||||
`;
|
||||
|
||||
console.log("✅ 인덱스 생성 완료");
|
||||
|
||||
// 테이블 코멘트 추가
|
||||
await prisma.$executeRaw`
|
||||
COMMENT ON TABLE component_standards IS 'UI 컴포넌트 표준 정보를 저장하는 테이블'
|
||||
`;
|
||||
|
||||
console.log("✅ 테이블 코멘트 추가 완료");
|
||||
} catch (error) {
|
||||
console.error("❌ 테이블 생성 실패:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// 실행
|
||||
if (require.main === module) {
|
||||
createComponentTable()
|
||||
.then(() => {
|
||||
console.log("🎉 테이블 생성 완료!");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("💥 테이블 생성 실패:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { createComponentTable };
|
||||
@@ -1,309 +0,0 @@
|
||||
/**
|
||||
* 레이아웃 표준 데이터 초기화 스크립트
|
||||
* 기본 레이아웃들을 layout_standards 테이블에 삽입합니다.
|
||||
*/
|
||||
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 기본 레이아웃 데이터
|
||||
const PREDEFINED_LAYOUTS = [
|
||||
{
|
||||
layout_code: "GRID_2X2_001",
|
||||
layout_name: "2x2 그리드",
|
||||
layout_name_eng: "2x2 Grid",
|
||||
description: "2행 2열의 균등한 그리드 레이아웃입니다.",
|
||||
layout_type: "grid",
|
||||
category: "basic",
|
||||
icon_name: "grid",
|
||||
default_size: { width: 800, height: 600 },
|
||||
layout_config: {
|
||||
grid: { rows: 2, columns: 2, gap: 16 },
|
||||
},
|
||||
zones_config: [
|
||||
{
|
||||
id: "zone1",
|
||||
name: "상단 좌측",
|
||||
position: { row: 0, column: 0 },
|
||||
size: { width: "50%", height: "50%" },
|
||||
},
|
||||
{
|
||||
id: "zone2",
|
||||
name: "상단 우측",
|
||||
position: { row: 0, column: 1 },
|
||||
size: { width: "50%", height: "50%" },
|
||||
},
|
||||
{
|
||||
id: "zone3",
|
||||
name: "하단 좌측",
|
||||
position: { row: 1, column: 0 },
|
||||
size: { width: "50%", height: "50%" },
|
||||
},
|
||||
{
|
||||
id: "zone4",
|
||||
name: "하단 우측",
|
||||
position: { row: 1, column: 1 },
|
||||
size: { width: "50%", height: "50%" },
|
||||
},
|
||||
],
|
||||
sort_order: 1,
|
||||
is_active: "Y",
|
||||
is_public: "Y",
|
||||
company_code: "DEFAULT",
|
||||
},
|
||||
{
|
||||
layout_code: "FORM_TWO_COLUMN_001",
|
||||
layout_name: "2단 폼 레이아웃",
|
||||
layout_name_eng: "Two Column Form",
|
||||
description: "좌우 2단으로 구성된 폼 레이아웃입니다.",
|
||||
layout_type: "grid",
|
||||
category: "form",
|
||||
icon_name: "columns",
|
||||
default_size: { width: 800, height: 400 },
|
||||
layout_config: {
|
||||
grid: { rows: 1, columns: 2, gap: 24 },
|
||||
},
|
||||
zones_config: [
|
||||
{
|
||||
id: "left",
|
||||
name: "좌측 입력 영역",
|
||||
position: { row: 0, column: 0 },
|
||||
size: { width: "50%", height: "100%" },
|
||||
},
|
||||
{
|
||||
id: "right",
|
||||
name: "우측 입력 영역",
|
||||
position: { row: 0, column: 1 },
|
||||
size: { width: "50%", height: "100%" },
|
||||
},
|
||||
],
|
||||
sort_order: 2,
|
||||
is_active: "Y",
|
||||
is_public: "Y",
|
||||
company_code: "DEFAULT",
|
||||
},
|
||||
{
|
||||
layout_code: "FLEXBOX_ROW_001",
|
||||
layout_name: "가로 플렉스박스",
|
||||
layout_name_eng: "Horizontal Flexbox",
|
||||
description: "가로 방향으로 배치되는 플렉스박스 레이아웃입니다.",
|
||||
layout_type: "flexbox",
|
||||
category: "basic",
|
||||
icon_name: "flex",
|
||||
default_size: { width: 800, height: 300 },
|
||||
layout_config: {
|
||||
flexbox: {
|
||||
direction: "row",
|
||||
justify: "flex-start",
|
||||
align: "stretch",
|
||||
wrap: "nowrap",
|
||||
gap: 16,
|
||||
},
|
||||
},
|
||||
zones_config: [
|
||||
{
|
||||
id: "left",
|
||||
name: "좌측 영역",
|
||||
position: {},
|
||||
size: { width: "50%", height: "100%" },
|
||||
},
|
||||
{
|
||||
id: "right",
|
||||
name: "우측 영역",
|
||||
position: {},
|
||||
size: { width: "50%", height: "100%" },
|
||||
},
|
||||
],
|
||||
sort_order: 3,
|
||||
is_active: "Y",
|
||||
is_public: "Y",
|
||||
company_code: "DEFAULT",
|
||||
},
|
||||
{
|
||||
layout_code: "SPLIT_HORIZONTAL_001",
|
||||
layout_name: "수평 분할",
|
||||
layout_name_eng: "Horizontal Split",
|
||||
description: "크기 조절이 가능한 수평 분할 레이아웃입니다.",
|
||||
layout_type: "split",
|
||||
category: "basic",
|
||||
icon_name: "separator-horizontal",
|
||||
default_size: { width: 800, height: 400 },
|
||||
layout_config: {
|
||||
split: {
|
||||
direction: "horizontal",
|
||||
ratio: [50, 50],
|
||||
minSize: [200, 200],
|
||||
resizable: true,
|
||||
splitterSize: 4,
|
||||
},
|
||||
},
|
||||
zones_config: [
|
||||
{
|
||||
id: "left",
|
||||
name: "좌측 패널",
|
||||
position: {},
|
||||
size: { width: "50%", height: "100%" },
|
||||
isResizable: true,
|
||||
},
|
||||
{
|
||||
id: "right",
|
||||
name: "우측 패널",
|
||||
position: {},
|
||||
size: { width: "50%", height: "100%" },
|
||||
isResizable: true,
|
||||
},
|
||||
],
|
||||
sort_order: 4,
|
||||
is_active: "Y",
|
||||
is_public: "Y",
|
||||
company_code: "DEFAULT",
|
||||
},
|
||||
{
|
||||
layout_code: "TABS_HORIZONTAL_001",
|
||||
layout_name: "수평 탭",
|
||||
layout_name_eng: "Horizontal Tabs",
|
||||
description: "상단에 탭이 있는 탭 레이아웃입니다.",
|
||||
layout_type: "tabs",
|
||||
category: "navigation",
|
||||
icon_name: "tabs",
|
||||
default_size: { width: 800, height: 500 },
|
||||
layout_config: {
|
||||
tabs: {
|
||||
position: "top",
|
||||
variant: "default",
|
||||
size: "md",
|
||||
defaultTab: "tab1",
|
||||
closable: false,
|
||||
},
|
||||
},
|
||||
zones_config: [
|
||||
{
|
||||
id: "tab1",
|
||||
name: "첫 번째 탭",
|
||||
position: {},
|
||||
size: { width: "100%", height: "100%" },
|
||||
},
|
||||
{
|
||||
id: "tab2",
|
||||
name: "두 번째 탭",
|
||||
position: {},
|
||||
size: { width: "100%", height: "100%" },
|
||||
},
|
||||
{
|
||||
id: "tab3",
|
||||
name: "세 번째 탭",
|
||||
position: {},
|
||||
size: { width: "100%", height: "100%" },
|
||||
},
|
||||
],
|
||||
sort_order: 5,
|
||||
is_active: "Y",
|
||||
is_public: "Y",
|
||||
company_code: "DEFAULT",
|
||||
},
|
||||
{
|
||||
layout_code: "TABLE_WITH_FILTERS_001",
|
||||
layout_name: "필터가 있는 테이블",
|
||||
layout_name_eng: "Table with Filters",
|
||||
description: "상단에 필터가 있고 하단에 테이블이 있는 레이아웃입니다.",
|
||||
layout_type: "flexbox",
|
||||
category: "table",
|
||||
icon_name: "table",
|
||||
default_size: { width: 1000, height: 600 },
|
||||
layout_config: {
|
||||
flexbox: {
|
||||
direction: "column",
|
||||
justify: "flex-start",
|
||||
align: "stretch",
|
||||
wrap: "nowrap",
|
||||
gap: 16,
|
||||
},
|
||||
},
|
||||
zones_config: [
|
||||
{
|
||||
id: "filters",
|
||||
name: "검색 필터",
|
||||
position: {},
|
||||
size: { width: "100%", height: "auto" },
|
||||
},
|
||||
{
|
||||
id: "table",
|
||||
name: "데이터 테이블",
|
||||
position: {},
|
||||
size: { width: "100%", height: "1fr" },
|
||||
},
|
||||
],
|
||||
sort_order: 6,
|
||||
is_active: "Y",
|
||||
is_public: "Y",
|
||||
company_code: "DEFAULT",
|
||||
},
|
||||
];
|
||||
|
||||
async function initializeLayoutStandards() {
|
||||
try {
|
||||
console.log("🏗️ 레이아웃 표준 데이터 초기화 시작...");
|
||||
|
||||
// 기존 데이터 확인
|
||||
const existingLayouts = await prisma.layout_standards.count();
|
||||
if (existingLayouts > 0) {
|
||||
console.log(`⚠️ 이미 ${existingLayouts}개의 레이아웃이 존재합니다.`);
|
||||
console.log(
|
||||
"기존 데이터를 삭제하고 새로 생성하시겠습니까? (기본값: 건너뛰기)"
|
||||
);
|
||||
|
||||
// 기존 데이터가 있으면 건너뛰기 (안전을 위해)
|
||||
console.log("💡 기존 데이터를 유지하고 건너뜁니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 데이터 삽입
|
||||
let insertedCount = 0;
|
||||
|
||||
for (const layoutData of PREDEFINED_LAYOUTS) {
|
||||
try {
|
||||
await prisma.layout_standards.create({
|
||||
data: {
|
||||
...layoutData,
|
||||
created_date: new Date(),
|
||||
updated_date: new Date(),
|
||||
created_by: "SYSTEM",
|
||||
updated_by: "SYSTEM",
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ ${layoutData.layout_name} 생성 완료`);
|
||||
insertedCount++;
|
||||
} catch (error) {
|
||||
console.error(`❌ ${layoutData.layout_name} 생성 실패:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`🎉 레이아웃 표준 데이터 초기화 완료! (${insertedCount}/${PREDEFINED_LAYOUTS.length})`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("❌ 레이아웃 표준 데이터 초기화 실패:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 스크립트 실행
|
||||
if (require.main === module) {
|
||||
initializeLayoutStandards()
|
||||
.then(() => {
|
||||
console.log("✨ 스크립트 실행 완료");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("💥 스크립트 실행 실패:", error);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { initializeLayoutStandards };
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
/**
|
||||
* 🔥 버튼 제어관리 성능 최적화 인덱스 설치 스크립트
|
||||
*
|
||||
* 사용법:
|
||||
* node scripts/install-dataflow-indexes.js
|
||||
*/
|
||||
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function installDataflowIndexes() {
|
||||
try {
|
||||
console.log("🔥 Starting Button Dataflow Performance Optimization...\n");
|
||||
|
||||
// SQL 파일 읽기
|
||||
const sqlFilePath = path.join(
|
||||
__dirname,
|
||||
"../database/migrations/add_button_dataflow_indexes.sql"
|
||||
);
|
||||
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
|
||||
|
||||
console.log("📖 Reading SQL migration file...");
|
||||
console.log(`📁 File: ${sqlFilePath}\n`);
|
||||
|
||||
// 데이터베이스 연결 확인
|
||||
console.log("🔍 Checking database connection...");
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
console.log("✅ Database connection OK\n");
|
||||
|
||||
// 기존 인덱스 상태 확인
|
||||
console.log("🔍 Checking existing indexes...");
|
||||
const existingIndexes = await prisma.$queryRaw`
|
||||
SELECT indexname, tablename
|
||||
FROM pg_indexes
|
||||
WHERE tablename = 'dataflow_diagrams'
|
||||
AND indexname LIKE 'idx_dataflow%'
|
||||
ORDER BY indexname;
|
||||
`;
|
||||
|
||||
if (existingIndexes.length > 0) {
|
||||
console.log("📋 Existing dataflow indexes:");
|
||||
existingIndexes.forEach((idx) => {
|
||||
console.log(` - ${idx.indexname}`);
|
||||
});
|
||||
} else {
|
||||
console.log("📋 No existing dataflow indexes found");
|
||||
}
|
||||
console.log("");
|
||||
|
||||
// 테이블 상태 확인
|
||||
console.log("🔍 Checking dataflow_diagrams table stats...");
|
||||
const tableStats = await prisma.$queryRaw`
|
||||
SELECT
|
||||
COUNT(*) as total_rows,
|
||||
COUNT(*) FILTER (WHERE control IS NOT NULL) as with_control,
|
||||
COUNT(*) FILTER (WHERE plan IS NOT NULL) as with_plan,
|
||||
COUNT(*) FILTER (WHERE category IS NOT NULL) as with_category,
|
||||
COUNT(DISTINCT company_code) as companies
|
||||
FROM dataflow_diagrams;
|
||||
`;
|
||||
|
||||
if (tableStats.length > 0) {
|
||||
const stats = tableStats[0];
|
||||
console.log(`📊 Table Statistics:`);
|
||||
console.log(` - Total rows: ${stats.total_rows}`);
|
||||
console.log(` - With control: ${stats.with_control}`);
|
||||
console.log(` - With plan: ${stats.with_plan}`);
|
||||
console.log(` - With category: ${stats.with_category}`);
|
||||
console.log(` - Companies: ${stats.companies}`);
|
||||
}
|
||||
console.log("");
|
||||
|
||||
// SQL 실행
|
||||
console.log("🚀 Installing performance indexes...");
|
||||
console.log("⏳ This may take a few minutes for large datasets...\n");
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// SQL을 문장별로 나누어 실행 (PostgreSQL 함수 때문에)
|
||||
const sqlStatements = sqlContent
|
||||
.split(/;\s*(?=\n|$)/)
|
||||
.filter(
|
||||
(stmt) =>
|
||||
stmt.trim().length > 0 &&
|
||||
!stmt.trim().startsWith("--") &&
|
||||
!stmt.trim().startsWith("/*")
|
||||
);
|
||||
|
||||
for (let i = 0; i < sqlStatements.length; i++) {
|
||||
const statement = sqlStatements[i].trim();
|
||||
if (statement.length === 0) continue;
|
||||
|
||||
try {
|
||||
// DO 블록이나 복합 문장 처리
|
||||
if (
|
||||
statement.includes("DO $$") ||
|
||||
statement.includes("CREATE OR REPLACE VIEW")
|
||||
) {
|
||||
console.log(
|
||||
`⚡ Executing statement ${i + 1}/${sqlStatements.length}...`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(statement + ";");
|
||||
} else if (statement.startsWith("CREATE INDEX")) {
|
||||
const indexName =
|
||||
statement.match(/CREATE INDEX[^"]*"?([^"\s]+)"?/)?.[1] || "unknown";
|
||||
console.log(`🔧 Creating index: ${indexName}...`);
|
||||
await prisma.$executeRawUnsafe(statement + ";");
|
||||
} else if (statement.startsWith("ANALYZE")) {
|
||||
console.log(`📊 Analyzing table statistics...`);
|
||||
await prisma.$executeRawUnsafe(statement + ";");
|
||||
} else {
|
||||
await prisma.$executeRawUnsafe(statement + ";");
|
||||
}
|
||||
} catch (error) {
|
||||
// 이미 존재하는 인덱스 에러는 무시
|
||||
if (error.message.includes("already exists")) {
|
||||
console.log(`⚠️ Index already exists, skipping...`);
|
||||
} else {
|
||||
console.error(`❌ Error executing statement: ${error.message}`);
|
||||
console.error(`📝 Statement: ${statement.substring(0, 100)}...`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const endTime = Date.now();
|
||||
const executionTime = (endTime - startTime) / 1000;
|
||||
|
||||
console.log(
|
||||
`\n✅ Index installation completed in ${executionTime.toFixed(2)} seconds!`
|
||||
);
|
||||
|
||||
// 설치된 인덱스 확인
|
||||
console.log("\n🔍 Verifying installed indexes...");
|
||||
const newIndexes = await prisma.$queryRaw`
|
||||
SELECT
|
||||
indexname,
|
||||
pg_size_pretty(pg_relation_size(indexrelid)) as size
|
||||
FROM pg_stat_user_indexes
|
||||
WHERE tablename = 'dataflow_diagrams'
|
||||
AND indexname LIKE 'idx_dataflow%'
|
||||
ORDER BY indexname;
|
||||
`;
|
||||
|
||||
if (newIndexes.length > 0) {
|
||||
console.log("📋 Installed indexes:");
|
||||
newIndexes.forEach((idx) => {
|
||||
console.log(` ✅ ${idx.indexname} (${idx.size})`);
|
||||
});
|
||||
}
|
||||
|
||||
// 성능 통계 조회
|
||||
console.log("\n📊 Performance statistics:");
|
||||
try {
|
||||
const perfStats =
|
||||
await prisma.$queryRaw`SELECT * FROM dataflow_performance_stats;`;
|
||||
if (perfStats.length > 0) {
|
||||
const stats = perfStats[0];
|
||||
console.log(` - Table size: ${stats.table_size}`);
|
||||
console.log(` - Total diagrams: ${stats.total_rows}`);
|
||||
console.log(` - With control: ${stats.with_control}`);
|
||||
console.log(` - Companies: ${stats.companies}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(" ⚠️ Performance view not available yet");
|
||||
}
|
||||
|
||||
console.log("\n🎯 Performance Optimization Complete!");
|
||||
console.log("Expected improvements:");
|
||||
console.log(" - Button dataflow lookup: 500ms+ → 10-50ms ⚡");
|
||||
console.log(" - Category filtering: 200ms+ → 5-20ms ⚡");
|
||||
console.log(" - Company queries: 100ms+ → 5-15ms ⚡");
|
||||
|
||||
console.log("\n💡 Monitor performance with:");
|
||||
console.log(" SELECT * FROM dataflow_performance_stats;");
|
||||
console.log(" SELECT * FROM dataflow_index_efficiency;");
|
||||
} catch (error) {
|
||||
console.error("\n❌ Error installing dataflow indexes:", error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// 실행
|
||||
if (require.main === module) {
|
||||
installDataflowIndexes()
|
||||
.then(() => {
|
||||
console.log("\n🎉 Installation completed successfully!");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("\n💥 Installation failed:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { installDataflowIndexes };
|
||||
@@ -1,46 +0,0 @@
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function getComponents() {
|
||||
try {
|
||||
const components = await prisma.component_standards.findMany({
|
||||
where: { is_active: "Y" },
|
||||
select: {
|
||||
component_code: true,
|
||||
component_name: true,
|
||||
category: true,
|
||||
component_config: true,
|
||||
},
|
||||
orderBy: [{ category: "asc" }, { sort_order: "asc" }],
|
||||
});
|
||||
|
||||
console.log("📋 데이터베이스 컴포넌트 목록:");
|
||||
console.log("=".repeat(60));
|
||||
|
||||
const grouped = components.reduce((acc, comp) => {
|
||||
if (!acc[comp.category]) {
|
||||
acc[comp.category] = [];
|
||||
}
|
||||
acc[comp.category].push(comp);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
Object.entries(grouped).forEach(([category, comps]) => {
|
||||
console.log(`\n🏷️ ${category.toUpperCase()} 카테고리:`);
|
||||
comps.forEach((comp) => {
|
||||
const type = comp.component_config?.type || "unknown";
|
||||
console.log(
|
||||
` - ${comp.component_code}: ${comp.component_name} (type: ${type})`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`\n총 ${components.length}개 컴포넌트 발견`);
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
getComponents();
|
||||
@@ -1,168 +0,0 @@
|
||||
import { query } from "../src/database/db";
|
||||
import { logger } from "../src/utils/logger";
|
||||
|
||||
/**
|
||||
* input_type을 web_type으로 마이그레이션하는 스크립트
|
||||
*
|
||||
* 목적:
|
||||
* - column_labels 테이블의 input_type 값을 읽어서
|
||||
* - 해당하는 기본 web_type 값으로 변환
|
||||
* - web_type이 null인 경우에만 업데이트
|
||||
*/
|
||||
|
||||
// input_type → 기본 web_type 매핑
|
||||
const INPUT_TYPE_TO_WEB_TYPE: Record<string, string> = {
|
||||
text: "text", // 일반 텍스트
|
||||
number: "number", // 정수
|
||||
date: "date", // 날짜
|
||||
code: "code", // 코드 선택박스
|
||||
entity: "entity", // 엔티티 참조
|
||||
select: "select", // 선택박스
|
||||
checkbox: "checkbox", // 체크박스
|
||||
radio: "radio", // 라디오버튼
|
||||
direct: "text", // direct는 text로 매핑
|
||||
};
|
||||
|
||||
async function migrateInputTypeToWebType() {
|
||||
try {
|
||||
logger.info("=".repeat(60));
|
||||
logger.info("input_type → web_type 마이그레이션 시작");
|
||||
logger.info("=".repeat(60));
|
||||
|
||||
// 1. 현재 상태 확인
|
||||
const stats = await query<{
|
||||
total: string;
|
||||
has_input_type: string;
|
||||
has_web_type: string;
|
||||
needs_migration: string;
|
||||
}>(
|
||||
`SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(input_type) FILTER (WHERE input_type IS NOT NULL) as has_input_type,
|
||||
COUNT(web_type) FILTER (WHERE web_type IS NOT NULL) as has_web_type,
|
||||
COUNT(*) FILTER (WHERE input_type IS NOT NULL AND web_type IS NULL) as needs_migration
|
||||
FROM column_labels`
|
||||
);
|
||||
|
||||
const stat = stats[0];
|
||||
logger.info("\n📊 현재 상태:");
|
||||
logger.info(` - 전체 컬럼: ${stat.total}개`);
|
||||
logger.info(` - input_type 있음: ${stat.has_input_type}개`);
|
||||
logger.info(` - web_type 있음: ${stat.has_web_type}개`);
|
||||
logger.info(` - 마이그레이션 필요: ${stat.needs_migration}개`);
|
||||
|
||||
if (parseInt(stat.needs_migration) === 0) {
|
||||
logger.info("\n✅ 마이그레이션이 필요한 데이터가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. input_type별 분포 확인
|
||||
const distribution = await query<{
|
||||
input_type: string;
|
||||
count: string;
|
||||
}>(
|
||||
`SELECT
|
||||
input_type,
|
||||
COUNT(*) as count
|
||||
FROM column_labels
|
||||
WHERE input_type IS NOT NULL AND web_type IS NULL
|
||||
GROUP BY input_type
|
||||
ORDER BY input_type`
|
||||
);
|
||||
|
||||
logger.info("\n📋 input_type별 분포:");
|
||||
distribution.forEach((item) => {
|
||||
const webType =
|
||||
INPUT_TYPE_TO_WEB_TYPE[item.input_type] || item.input_type;
|
||||
logger.info(` - ${item.input_type} → ${webType}: ${item.count}개`);
|
||||
});
|
||||
|
||||
// 3. 마이그레이션 실행
|
||||
logger.info("\n🔄 마이그레이션 실행 중...");
|
||||
|
||||
let totalUpdated = 0;
|
||||
|
||||
for (const [inputType, webType] of Object.entries(INPUT_TYPE_TO_WEB_TYPE)) {
|
||||
const result = await query(
|
||||
`UPDATE column_labels
|
||||
SET
|
||||
web_type = $1,
|
||||
updated_date = NOW()
|
||||
WHERE input_type = $2
|
||||
AND web_type IS NULL
|
||||
RETURNING id, table_name, column_name`,
|
||||
[webType, inputType]
|
||||
);
|
||||
|
||||
if (result.length > 0) {
|
||||
logger.info(
|
||||
` ✓ ${inputType} → ${webType}: ${result.length}개 업데이트`
|
||||
);
|
||||
totalUpdated += result.length;
|
||||
|
||||
// 처음 5개만 출력
|
||||
result.slice(0, 5).forEach((row: any) => {
|
||||
logger.info(` - ${row.table_name}.${row.column_name}`);
|
||||
});
|
||||
if (result.length > 5) {
|
||||
logger.info(` ... 외 ${result.length - 5}개`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 결과 확인
|
||||
const afterStats = await query<{
|
||||
total: string;
|
||||
has_web_type: string;
|
||||
}>(
|
||||
`SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(web_type) FILTER (WHERE web_type IS NOT NULL) as has_web_type
|
||||
FROM column_labels`
|
||||
);
|
||||
|
||||
const afterStat = afterStats[0];
|
||||
|
||||
logger.info("\n" + "=".repeat(60));
|
||||
logger.info("✅ 마이그레이션 완료!");
|
||||
logger.info("=".repeat(60));
|
||||
logger.info(`📊 최종 통계:`);
|
||||
logger.info(` - 전체 컬럼: ${afterStat.total}개`);
|
||||
logger.info(` - web_type 설정됨: ${afterStat.has_web_type}개`);
|
||||
logger.info(` - 업데이트된 컬럼: ${totalUpdated}개`);
|
||||
logger.info("=".repeat(60));
|
||||
|
||||
// 5. 샘플 데이터 출력
|
||||
logger.info("\n📝 샘플 데이터 (check_report_mng 테이블):");
|
||||
const samples = await query<{
|
||||
column_name: string;
|
||||
input_type: string;
|
||||
web_type: string;
|
||||
detail_settings: string;
|
||||
}>(
|
||||
`SELECT
|
||||
column_name,
|
||||
input_type,
|
||||
web_type,
|
||||
detail_settings
|
||||
FROM column_labels
|
||||
WHERE table_name = 'check_report_mng'
|
||||
ORDER BY column_name
|
||||
LIMIT 10`
|
||||
);
|
||||
|
||||
samples.forEach((sample) => {
|
||||
logger.info(
|
||||
` ${sample.column_name}: ${sample.input_type} → ${sample.web_type}`
|
||||
);
|
||||
});
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
logger.error("❌ 마이그레이션 실패:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 스크립트 실행
|
||||
migrateInputTypeToWebType();
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* system_notice 테이블 생성 마이그레이션 실행
|
||||
*/
|
||||
const { Pool } = require('pg');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm',
|
||||
ssl: false,
|
||||
});
|
||||
|
||||
async function run() {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const sqlPath = path.join(__dirname, '../../db/migrations/1050_create_system_notice.sql');
|
||||
const sql = fs.readFileSync(sqlPath, 'utf8');
|
||||
await client.query(sql);
|
||||
console.log('OK: system_notice 테이블 생성 완료');
|
||||
|
||||
// 검증
|
||||
const result = await client.query(
|
||||
"SELECT column_name FROM information_schema.columns WHERE table_name='system_notice' ORDER BY ordinal_position"
|
||||
);
|
||||
console.log('컬럼:', result.rows.map(r => r.column_name).join(', '));
|
||||
} catch (e) {
|
||||
console.error('ERROR:', e.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* SQL 마이그레이션 실행 스크립트
|
||||
* 사용법: node scripts/run-migration.js
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { Pool } = require('pg');
|
||||
|
||||
// DATABASE_URL에서 연결 정보 파싱
|
||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm';
|
||||
|
||||
// 데이터베이스 연결 설정
|
||||
const pool = new Pool({
|
||||
connectionString: databaseUrl,
|
||||
});
|
||||
|
||||
async function runMigration() {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
console.log('🔄 마이그레이션 시작...\n');
|
||||
|
||||
// SQL 파일 읽기 (Docker 컨테이너 내부 경로)
|
||||
const sqlPath = '/tmp/migration.sql';
|
||||
const sql = fs.readFileSync(sqlPath, 'utf8');
|
||||
|
||||
console.log('📄 SQL 파일 로드 완료');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
||||
|
||||
// SQL 실행
|
||||
await client.query(sql);
|
||||
|
||||
console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('✅ 마이그레이션 성공적으로 완료되었습니다!');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.error('❌ 마이그레이션 실패:');
|
||||
console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.error(error);
|
||||
console.error('\n💡 롤백이 필요한 경우 롤백 스크립트를 실행하세요.');
|
||||
process.exit(1);
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
// 실행
|
||||
runMigration();
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* system_notice 마이그레이션 실행 스크립트
|
||||
* 사용법: node scripts/run-notice-migration.js
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm',
|
||||
ssl: false,
|
||||
});
|
||||
|
||||
async function run() {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const sqlPath = path.join(__dirname, '../../db/migrations/1050_create_system_notice.sql');
|
||||
const sql = fs.readFileSync(sqlPath, 'utf8');
|
||||
|
||||
console.log('마이그레이션 실행 중...');
|
||||
await client.query(sql);
|
||||
console.log('마이그레이션 완료');
|
||||
|
||||
// 컬럼 확인
|
||||
const check = await client.query(
|
||||
"SELECT column_name FROM information_schema.columns WHERE table_name='system_notice' ORDER BY ordinal_position"
|
||||
);
|
||||
console.log('테이블 컬럼:', check.rows.map(r => r.column_name).join(', '));
|
||||
} catch (e) {
|
||||
console.error('오류:', e.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -1,294 +0,0 @@
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 기본 템플릿 데이터 정의
|
||||
const defaultTemplates = [
|
||||
{
|
||||
template_code: "advanced-data-table-v2",
|
||||
template_name: "고급 데이터 테이블 v2",
|
||||
template_name_eng: "Advanced Data Table v2",
|
||||
description:
|
||||
"검색, 필터링, 페이징, CRUD 기능이 포함된 완전한 데이터 테이블 컴포넌트",
|
||||
category: "table",
|
||||
icon_name: "table",
|
||||
default_size: {
|
||||
width: 1000,
|
||||
height: 680,
|
||||
},
|
||||
layout_config: {
|
||||
components: [
|
||||
{
|
||||
type: "datatable",
|
||||
label: "고급 데이터 테이블",
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 1000, height: 680 },
|
||||
style: {
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: "#ffffff",
|
||||
padding: "0",
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
id: "id",
|
||||
label: "ID",
|
||||
type: "number",
|
||||
visible: true,
|
||||
sortable: true,
|
||||
filterable: false,
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
label: "이름",
|
||||
type: "text",
|
||||
visible: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
id: "email",
|
||||
label: "이메일",
|
||||
type: "email",
|
||||
visible: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
label: "상태",
|
||||
type: "select",
|
||||
visible: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
id: "created_date",
|
||||
label: "생성일",
|
||||
type: "date",
|
||||
visible: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 120,
|
||||
},
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
id: "status",
|
||||
label: "상태",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "전체", value: "" },
|
||||
{ label: "활성", value: "active" },
|
||||
{ label: "비활성", value: "inactive" },
|
||||
],
|
||||
},
|
||||
{ id: "name", label: "이름", type: "text" },
|
||||
{ id: "email", label: "이메일", type: "text" },
|
||||
],
|
||||
pagination: {
|
||||
enabled: true,
|
||||
pageSize: 10,
|
||||
pageSizeOptions: [5, 10, 20, 50, 100],
|
||||
showPageSizeSelector: true,
|
||||
showPageInfo: true,
|
||||
showFirstLast: true,
|
||||
},
|
||||
actions: {
|
||||
showSearchButton: true,
|
||||
searchButtonText: "검색",
|
||||
enableExport: true,
|
||||
enableRefresh: true,
|
||||
enableAdd: true,
|
||||
enableEdit: true,
|
||||
enableDelete: true,
|
||||
addButtonText: "추가",
|
||||
editButtonText: "수정",
|
||||
deleteButtonText: "삭제",
|
||||
},
|
||||
addModalConfig: {
|
||||
title: "새 데이터 추가",
|
||||
description: "테이블에 새로운 데이터를 추가합니다.",
|
||||
width: "lg",
|
||||
layout: "two-column",
|
||||
gridColumns: 2,
|
||||
fieldOrder: ["name", "email", "status"],
|
||||
requiredFields: ["name", "email"],
|
||||
hiddenFields: ["id", "created_date"],
|
||||
advancedFieldConfigs: {
|
||||
status: {
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "활성", value: "active" },
|
||||
{ label: "비활성", value: "inactive" },
|
||||
],
|
||||
},
|
||||
},
|
||||
submitButtonText: "추가",
|
||||
cancelButtonText: "취소",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
sort_order: 1,
|
||||
is_active: "Y",
|
||||
is_public: "Y",
|
||||
company_code: "*",
|
||||
created_by: "system",
|
||||
updated_by: "system",
|
||||
},
|
||||
{
|
||||
template_code: "universal-button",
|
||||
template_name: "범용 버튼",
|
||||
template_name_eng: "Universal Button",
|
||||
description:
|
||||
"다양한 기능을 설정할 수 있는 범용 버튼. 상세설정에서 기능을 선택하세요.",
|
||||
category: "button",
|
||||
icon_name: "mouse-pointer",
|
||||
default_size: {
|
||||
width: 80,
|
||||
height: 36,
|
||||
},
|
||||
layout_config: {
|
||||
components: [
|
||||
{
|
||||
type: "widget",
|
||||
widgetType: "button",
|
||||
label: "버튼",
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 80, height: 36 },
|
||||
style: {
|
||||
backgroundColor: "#3b82f6",
|
||||
color: "#ffffff",
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
fontSize: "14px",
|
||||
fontWeight: "500",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
sort_order: 2,
|
||||
is_active: "Y",
|
||||
is_public: "Y",
|
||||
company_code: "*",
|
||||
created_by: "system",
|
||||
updated_by: "system",
|
||||
},
|
||||
{
|
||||
template_code: "file-upload",
|
||||
template_name: "파일 첨부",
|
||||
template_name_eng: "File Upload",
|
||||
description: "드래그앤드롭 파일 업로드 영역",
|
||||
category: "file",
|
||||
icon_name: "upload",
|
||||
default_size: {
|
||||
width: 300,
|
||||
height: 120,
|
||||
},
|
||||
layout_config: {
|
||||
components: [
|
||||
{
|
||||
type: "widget",
|
||||
widgetType: "file",
|
||||
label: "파일 첨부",
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 300, height: 120 },
|
||||
style: {
|
||||
border: "2px dashed #d1d5db",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: "#f9fafb",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "14px",
|
||||
color: "#6b7280",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
sort_order: 3,
|
||||
is_active: "Y",
|
||||
is_public: "Y",
|
||||
company_code: "*",
|
||||
created_by: "system",
|
||||
updated_by: "system",
|
||||
},
|
||||
{
|
||||
template_code: "form-container",
|
||||
template_name: "폼 컨테이너",
|
||||
template_name_eng: "Form Container",
|
||||
description: "입력 폼을 위한 기본 컨테이너 레이아웃",
|
||||
category: "form",
|
||||
icon_name: "form",
|
||||
default_size: {
|
||||
width: 400,
|
||||
height: 300,
|
||||
},
|
||||
layout_config: {
|
||||
components: [
|
||||
{
|
||||
type: "container",
|
||||
label: "폼 컨테이너",
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 400, height: 300 },
|
||||
style: {
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: "#ffffff",
|
||||
padding: "16px",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
sort_order: 4,
|
||||
is_active: "Y",
|
||||
is_public: "Y",
|
||||
company_code: "*",
|
||||
created_by: "system",
|
||||
updated_by: "system",
|
||||
},
|
||||
];
|
||||
|
||||
async function seedTemplates() {
|
||||
console.log("🌱 템플릿 시드 데이터 삽입 시작...");
|
||||
|
||||
try {
|
||||
// 기존 템플릿이 있는지 확인하고 없는 경우에만 삽입
|
||||
for (const template of defaultTemplates) {
|
||||
const existing = await prisma.template_standards.findUnique({
|
||||
where: { template_code: template.template_code },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
await prisma.template_standards.create({
|
||||
data: template,
|
||||
});
|
||||
console.log(`✅ 템플릿 '${template.template_name}' 생성됨`);
|
||||
} else {
|
||||
console.log(`⏭️ 템플릿 '${template.template_name}' 이미 존재함`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("🎉 템플릿 시드 데이터 삽입 완료!");
|
||||
} catch (error) {
|
||||
console.error("❌ 템플릿 시드 데이터 삽입 실패:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// 스크립트가 직접 실행될 때만 시드 함수 실행
|
||||
if (require.main === module) {
|
||||
seedTemplates().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { seedTemplates };
|
||||
@@ -1,411 +0,0 @@
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 실제 UI 구성에 필요한 컴포넌트들
|
||||
const uiComponents = [
|
||||
// === 액션 컴포넌트 ===
|
||||
{
|
||||
component_code: "button-primary",
|
||||
component_name: "기본 버튼",
|
||||
component_name_eng: "Primary Button",
|
||||
description: "일반적인 액션을 위한 기본 버튼 컴포넌트",
|
||||
category: "action",
|
||||
icon_name: "MousePointer",
|
||||
default_size: { width: 100, height: 36 },
|
||||
component_config: {
|
||||
type: "button",
|
||||
variant: "primary",
|
||||
text: "버튼",
|
||||
action: "custom",
|
||||
style: {
|
||||
backgroundColor: "#3b82f6",
|
||||
color: "#ffffff",
|
||||
borderRadius: "6px",
|
||||
fontSize: "14px",
|
||||
fontWeight: "500",
|
||||
},
|
||||
},
|
||||
sort_order: 10,
|
||||
},
|
||||
{
|
||||
component_code: "button-secondary",
|
||||
component_name: "보조 버튼",
|
||||
component_name_eng: "Secondary Button",
|
||||
description: "보조 액션을 위한 버튼 컴포넌트",
|
||||
category: "action",
|
||||
icon_name: "MousePointer",
|
||||
default_size: { width: 100, height: 36 },
|
||||
component_config: {
|
||||
type: "button",
|
||||
variant: "secondary",
|
||||
text: "취소",
|
||||
action: "cancel",
|
||||
style: {
|
||||
backgroundColor: "#f1f5f9",
|
||||
color: "#475569",
|
||||
borderRadius: "6px",
|
||||
fontSize: "14px",
|
||||
},
|
||||
},
|
||||
sort_order: 11,
|
||||
},
|
||||
|
||||
// === 레이아웃 컴포넌트 ===
|
||||
{
|
||||
component_code: "card-basic",
|
||||
component_name: "기본 카드",
|
||||
component_name_eng: "Basic Card",
|
||||
description: "정보를 그룹화하는 기본 카드 컴포넌트",
|
||||
category: "layout",
|
||||
icon_name: "Square",
|
||||
default_size: { width: 400, height: 300 },
|
||||
component_config: {
|
||||
type: "card",
|
||||
title: "카드 제목",
|
||||
showHeader: true,
|
||||
showFooter: false,
|
||||
style: {
|
||||
backgroundColor: "#ffffff",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "8px",
|
||||
padding: "16px",
|
||||
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.1)",
|
||||
},
|
||||
},
|
||||
sort_order: 20,
|
||||
},
|
||||
{
|
||||
component_code: "dashboard-grid",
|
||||
component_name: "대시보드 그리드",
|
||||
component_name_eng: "Dashboard Grid",
|
||||
description: "대시보드를 위한 그리드 레이아웃 컴포넌트",
|
||||
category: "layout",
|
||||
icon_name: "LayoutGrid",
|
||||
default_size: { width: 800, height: 600 },
|
||||
component_config: {
|
||||
type: "dashboard",
|
||||
columns: 3,
|
||||
gap: 16,
|
||||
items: [],
|
||||
style: {
|
||||
backgroundColor: "#f8fafc",
|
||||
padding: "20px",
|
||||
borderRadius: "8px",
|
||||
},
|
||||
},
|
||||
sort_order: 21,
|
||||
},
|
||||
{
|
||||
component_code: "panel-collapsible",
|
||||
component_name: "접을 수 있는 패널",
|
||||
component_name_eng: "Collapsible Panel",
|
||||
description: "접고 펼칠 수 있는 패널 컴포넌트",
|
||||
category: "layout",
|
||||
icon_name: "ChevronDown",
|
||||
default_size: { width: 500, height: 200 },
|
||||
component_config: {
|
||||
type: "panel",
|
||||
title: "패널 제목",
|
||||
collapsible: true,
|
||||
defaultExpanded: true,
|
||||
style: {
|
||||
backgroundColor: "#ffffff",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "8px",
|
||||
},
|
||||
},
|
||||
sort_order: 22,
|
||||
},
|
||||
|
||||
// === 데이터 표시 컴포넌트 ===
|
||||
{
|
||||
component_code: "stats-card",
|
||||
component_name: "통계 카드",
|
||||
component_name_eng: "Statistics Card",
|
||||
description: "수치와 통계를 표시하는 카드 컴포넌트",
|
||||
category: "data",
|
||||
icon_name: "BarChart3",
|
||||
default_size: { width: 250, height: 120 },
|
||||
component_config: {
|
||||
type: "stats",
|
||||
title: "총 판매량",
|
||||
value: "1,234",
|
||||
unit: "개",
|
||||
trend: "up",
|
||||
percentage: "+12.5%",
|
||||
style: {
|
||||
backgroundColor: "#ffffff",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "8px",
|
||||
padding: "20px",
|
||||
},
|
||||
},
|
||||
sort_order: 30,
|
||||
},
|
||||
{
|
||||
component_code: "progress-bar",
|
||||
component_name: "진행률 표시",
|
||||
component_name_eng: "Progress Bar",
|
||||
description: "작업 진행률을 표시하는 컴포넌트",
|
||||
category: "data",
|
||||
icon_name: "BarChart2",
|
||||
default_size: { width: 300, height: 60 },
|
||||
component_config: {
|
||||
type: "progress",
|
||||
label: "진행률",
|
||||
value: 65,
|
||||
max: 100,
|
||||
showPercentage: true,
|
||||
style: {
|
||||
backgroundColor: "#f1f5f9",
|
||||
borderRadius: "4px",
|
||||
height: "8px",
|
||||
},
|
||||
},
|
||||
sort_order: 31,
|
||||
},
|
||||
{
|
||||
component_code: "chart-basic",
|
||||
component_name: "기본 차트",
|
||||
component_name_eng: "Basic Chart",
|
||||
description: "데이터를 시각화하는 기본 차트 컴포넌트",
|
||||
category: "data",
|
||||
icon_name: "TrendingUp",
|
||||
default_size: { width: 500, height: 300 },
|
||||
component_config: {
|
||||
type: "chart",
|
||||
chartType: "line",
|
||||
title: "차트 제목",
|
||||
data: [],
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { position: "top" },
|
||||
},
|
||||
},
|
||||
},
|
||||
sort_order: 32,
|
||||
},
|
||||
|
||||
// === 네비게이션 컴포넌트 ===
|
||||
{
|
||||
component_code: "breadcrumb",
|
||||
component_name: "브레드크럼",
|
||||
component_name_eng: "Breadcrumb",
|
||||
description: "현재 위치를 표시하는 네비게이션 컴포넌트",
|
||||
category: "navigation",
|
||||
icon_name: "ChevronRight",
|
||||
default_size: { width: 400, height: 32 },
|
||||
component_config: {
|
||||
type: "breadcrumb",
|
||||
items: [
|
||||
{ label: "홈", href: "/" },
|
||||
{ label: "관리자", href: "/admin" },
|
||||
{ label: "현재 페이지" },
|
||||
],
|
||||
separator: ">",
|
||||
},
|
||||
sort_order: 40,
|
||||
},
|
||||
{
|
||||
component_code: "tabs-horizontal",
|
||||
component_name: "가로 탭",
|
||||
component_name_eng: "Horizontal Tabs",
|
||||
description: "컨텐츠를 탭으로 구분하는 네비게이션 컴포넌트",
|
||||
category: "navigation",
|
||||
icon_name: "Tabs",
|
||||
default_size: { width: 500, height: 300 },
|
||||
component_config: {
|
||||
type: "tabs",
|
||||
orientation: "horizontal",
|
||||
tabs: [
|
||||
{ id: "tab1", label: "탭 1", content: "첫 번째 탭 내용" },
|
||||
{ id: "tab2", label: "탭 2", content: "두 번째 탭 내용" },
|
||||
],
|
||||
defaultTab: "tab1",
|
||||
},
|
||||
sort_order: 41,
|
||||
},
|
||||
{
|
||||
component_code: "pagination",
|
||||
component_name: "페이지네이션",
|
||||
component_name_eng: "Pagination",
|
||||
description: "페이지를 나눠서 표시하는 네비게이션 컴포넌트",
|
||||
category: "navigation",
|
||||
icon_name: "ChevronLeft",
|
||||
default_size: { width: 300, height: 40 },
|
||||
component_config: {
|
||||
type: "pagination",
|
||||
currentPage: 1,
|
||||
totalPages: 10,
|
||||
showFirst: true,
|
||||
showLast: true,
|
||||
showPrevNext: true,
|
||||
},
|
||||
sort_order: 42,
|
||||
},
|
||||
|
||||
// === 피드백 컴포넌트 ===
|
||||
{
|
||||
component_code: "alert-info",
|
||||
component_name: "정보 알림",
|
||||
component_name_eng: "Info Alert",
|
||||
description: "정보를 사용자에게 알리는 컴포넌트",
|
||||
category: "feedback",
|
||||
icon_name: "Info",
|
||||
default_size: { width: 400, height: 60 },
|
||||
component_config: {
|
||||
type: "alert",
|
||||
variant: "info",
|
||||
title: "알림",
|
||||
message: "중요한 정보를 확인해주세요.",
|
||||
dismissible: true,
|
||||
icon: true,
|
||||
},
|
||||
sort_order: 50,
|
||||
},
|
||||
{
|
||||
component_code: "badge-status",
|
||||
component_name: "상태 뱃지",
|
||||
component_name_eng: "Status Badge",
|
||||
description: "상태나 카테고리를 표시하는 뱃지 컴포넌트",
|
||||
category: "feedback",
|
||||
icon_name: "Tag",
|
||||
default_size: { width: 80, height: 24 },
|
||||
component_config: {
|
||||
type: "badge",
|
||||
text: "활성",
|
||||
variant: "success",
|
||||
size: "sm",
|
||||
style: {
|
||||
backgroundColor: "#10b981",
|
||||
color: "#ffffff",
|
||||
borderRadius: "12px",
|
||||
fontSize: "12px",
|
||||
},
|
||||
},
|
||||
sort_order: 51,
|
||||
},
|
||||
{
|
||||
component_code: "loading-spinner",
|
||||
component_name: "로딩 스피너",
|
||||
component_name_eng: "Loading Spinner",
|
||||
description: "로딩 상태를 표시하는 스피너 컴포넌트",
|
||||
category: "feedback",
|
||||
icon_name: "RefreshCw",
|
||||
default_size: { width: 100, height: 100 },
|
||||
component_config: {
|
||||
type: "loading",
|
||||
variant: "spinner",
|
||||
size: "md",
|
||||
message: "로딩 중...",
|
||||
overlay: false,
|
||||
},
|
||||
sort_order: 52,
|
||||
},
|
||||
|
||||
// === 입력 컴포넌트 ===
|
||||
{
|
||||
component_code: "search-box",
|
||||
component_name: "검색 박스",
|
||||
component_name_eng: "Search Box",
|
||||
description: "검색 기능이 있는 입력 컴포넌트",
|
||||
category: "input",
|
||||
icon_name: "Search",
|
||||
default_size: { width: 300, height: 40 },
|
||||
component_config: {
|
||||
type: "search",
|
||||
placeholder: "검색어를 입력하세요...",
|
||||
showButton: true,
|
||||
debounce: 500,
|
||||
style: {
|
||||
borderRadius: "20px",
|
||||
border: "1px solid #d1d5db",
|
||||
},
|
||||
},
|
||||
sort_order: 60,
|
||||
},
|
||||
{
|
||||
component_code: "filter-dropdown",
|
||||
component_name: "필터 드롭다운",
|
||||
component_name_eng: "Filter Dropdown",
|
||||
description: "데이터 필터링을 위한 드롭다운 컴포넌트",
|
||||
category: "input",
|
||||
icon_name: "Filter",
|
||||
default_size: { width: 200, height: 40 },
|
||||
component_config: {
|
||||
type: "filter",
|
||||
label: "필터",
|
||||
options: [
|
||||
{ value: "all", label: "전체" },
|
||||
{ value: "active", label: "활성" },
|
||||
{ value: "inactive", label: "비활성" },
|
||||
],
|
||||
defaultValue: "all",
|
||||
multiple: false,
|
||||
},
|
||||
sort_order: 61,
|
||||
},
|
||||
];
|
||||
|
||||
async function seedUIComponents() {
|
||||
try {
|
||||
console.log("🚀 UI 컴포넌트 시딩 시작...");
|
||||
|
||||
// 기존 데이터 삭제
|
||||
console.log("📝 기존 컴포넌트 데이터 삭제 중...");
|
||||
await prisma.$executeRaw`DELETE FROM component_standards`;
|
||||
|
||||
// 새 컴포넌트 데이터 삽입
|
||||
console.log("📦 새로운 UI 컴포넌트 삽입 중...");
|
||||
|
||||
for (const component of uiComponents) {
|
||||
await prisma.component_standards.create({
|
||||
data: {
|
||||
...component,
|
||||
company_code: "DEFAULT",
|
||||
created_by: "system",
|
||||
updated_by: "system",
|
||||
},
|
||||
});
|
||||
console.log(`✅ ${component.component_name} 컴포넌트 생성됨`);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\n🎉 총 ${uiComponents.length}개의 UI 컴포넌트가 성공적으로 생성되었습니다!`
|
||||
);
|
||||
|
||||
// 카테고리별 통계
|
||||
const categoryCounts = {};
|
||||
uiComponents.forEach((component) => {
|
||||
categoryCounts[component.category] =
|
||||
(categoryCounts[component.category] || 0) + 1;
|
||||
});
|
||||
|
||||
console.log("\n📊 카테고리별 컴포넌트 수:");
|
||||
Object.entries(categoryCounts).forEach(([category, count]) => {
|
||||
console.log(` ${category}: ${count}개`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ UI 컴포넌트 시딩 실패:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// 실행
|
||||
if (require.main === module) {
|
||||
seedUIComponents()
|
||||
.then(() => {
|
||||
console.log("✨ UI 컴포넌트 시딩 완료!");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("💥 시딩 실패:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { seedUIComponents, uiComponents };
|
||||
@@ -1,209 +0,0 @@
|
||||
/**
|
||||
* 디지털 트윈 외부 DB (DO_DY) 연결 및 쿼리 테스트 스크립트
|
||||
* READ-ONLY: SELECT 쿼리만 실행
|
||||
*/
|
||||
|
||||
import { Pool } from "pg";
|
||||
import mysql from "mysql2/promise";
|
||||
import { CredentialEncryption } from "../src/utils/credentialEncryption";
|
||||
|
||||
async function testDigitalTwinDb() {
|
||||
// 내부 DB 연결 (연결 정보 저장용)
|
||||
const internalPool = new Pool({
|
||||
host: process.env.DB_HOST || "localhost",
|
||||
port: parseInt(process.env.DB_PORT || "5432"),
|
||||
database: process.env.DB_NAME || "plm",
|
||||
user: process.env.DB_USER || "postgres",
|
||||
password: process.env.DB_PASSWORD || "ph0909!!",
|
||||
});
|
||||
|
||||
const encryptionKey =
|
||||
process.env.ENCRYPTION_SECRET_KEY || "default-secret-key-for-development";
|
||||
const encryption = new CredentialEncryption(encryptionKey);
|
||||
|
||||
try {
|
||||
console.log("🚀 디지털 트윈 외부 DB 연결 테스트 시작\n");
|
||||
|
||||
// 디지털 트윈 외부 DB 연결 정보
|
||||
const digitalTwinConnection = {
|
||||
name: "디지털트윈_DO_DY",
|
||||
description: "디지털 트윈 후판(자재) 재고 정보 데이터베이스 (MariaDB)",
|
||||
dbType: "mysql", // MariaDB는 MySQL 프로토콜 사용
|
||||
host: "1.240.13.83",
|
||||
port: 4307,
|
||||
databaseName: "DO_DY",
|
||||
username: "root",
|
||||
password: "pohangms619!#",
|
||||
sslEnabled: false,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
console.log("📝 연결 정보:");
|
||||
console.log(` - 이름: ${digitalTwinConnection.name}`);
|
||||
console.log(` - DB 타입: ${digitalTwinConnection.dbType}`);
|
||||
console.log(` - 호스트: ${digitalTwinConnection.host}:${digitalTwinConnection.port}`);
|
||||
console.log(` - 데이터베이스: ${digitalTwinConnection.databaseName}\n`);
|
||||
|
||||
// 1. 외부 DB 직접 연결 테스트
|
||||
console.log("🔍 외부 DB 직접 연결 테스트 중...");
|
||||
|
||||
const externalConnection = await mysql.createConnection({
|
||||
host: digitalTwinConnection.host,
|
||||
port: digitalTwinConnection.port,
|
||||
database: digitalTwinConnection.databaseName,
|
||||
user: digitalTwinConnection.username,
|
||||
password: digitalTwinConnection.password,
|
||||
connectTimeout: 10000,
|
||||
});
|
||||
|
||||
console.log("✅ 외부 DB 연결 성공!\n");
|
||||
|
||||
// 2. SELECT 쿼리 실행
|
||||
console.log("📊 WSTKKY 테이블 쿼리 실행 중...\n");
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
SKUMKEY -- 제품번호
|
||||
, SKUDESC -- 자재명
|
||||
, SKUTHIC -- 두께
|
||||
, SKUWIDT -- 폭
|
||||
, SKULENG -- 길이
|
||||
, SKUWEIG -- 중량
|
||||
, STOTQTY -- 수량
|
||||
, SUOMKEY -- 단위
|
||||
FROM DO_DY.WSTKKY
|
||||
LIMIT 10
|
||||
`;
|
||||
|
||||
const [rows] = await externalConnection.execute(query);
|
||||
|
||||
console.log("✅ 쿼리 실행 성공!\n");
|
||||
console.log(`📦 조회된 데이터: ${Array.isArray(rows) ? rows.length : 0}건\n`);
|
||||
|
||||
if (Array.isArray(rows) && rows.length > 0) {
|
||||
console.log("🔍 샘플 데이터 (첫 3건):\n");
|
||||
rows.slice(0, 3).forEach((row: any, index: number) => {
|
||||
console.log(`[${index + 1}]`);
|
||||
console.log(` 제품번호(SKUMKEY): ${row.SKUMKEY}`);
|
||||
console.log(` 자재명(SKUDESC): ${row.SKUDESC}`);
|
||||
console.log(` 두께(SKUTHIC): ${row.SKUTHIC}`);
|
||||
console.log(` 폭(SKUWIDT): ${row.SKUWIDT}`);
|
||||
console.log(` 길이(SKULENG): ${row.SKULENG}`);
|
||||
console.log(` 중량(SKUWEIG): ${row.SKUWEIG}`);
|
||||
console.log(` 수량(STOTQTY): ${row.STOTQTY}`);
|
||||
console.log(` 단위(SUOMKEY): ${row.SUOMKEY}\n`);
|
||||
});
|
||||
|
||||
// 전체 데이터 JSON 출력
|
||||
console.log("📄 전체 데이터 (JSON):");
|
||||
console.log(JSON.stringify(rows, null, 2));
|
||||
console.log("\n");
|
||||
}
|
||||
|
||||
await externalConnection.end();
|
||||
|
||||
// 3. 내부 DB에 연결 정보 저장
|
||||
console.log("💾 내부 DB에 연결 정보 저장 중...");
|
||||
|
||||
const encryptedPassword = encryption.encrypt(digitalTwinConnection.password);
|
||||
|
||||
// 중복 체크
|
||||
const existingResult = await internalPool.query(
|
||||
"SELECT id FROM flow_external_db_connection WHERE name = $1",
|
||||
[digitalTwinConnection.name]
|
||||
);
|
||||
|
||||
let connectionId: number;
|
||||
|
||||
if (existingResult.rows.length > 0) {
|
||||
connectionId = existingResult.rows[0].id;
|
||||
console.log(`⚠️ 이미 존재하는 연결 (ID: ${connectionId})`);
|
||||
|
||||
// 기존 연결 업데이트
|
||||
await internalPool.query(
|
||||
`UPDATE flow_external_db_connection
|
||||
SET description = $1,
|
||||
db_type = $2,
|
||||
host = $3,
|
||||
port = $4,
|
||||
database_name = $5,
|
||||
username = $6,
|
||||
password_encrypted = $7,
|
||||
ssl_enabled = $8,
|
||||
is_active = $9,
|
||||
updated_at = NOW(),
|
||||
updated_by = 'system'
|
||||
WHERE name = $10`,
|
||||
[
|
||||
digitalTwinConnection.description,
|
||||
digitalTwinConnection.dbType,
|
||||
digitalTwinConnection.host,
|
||||
digitalTwinConnection.port,
|
||||
digitalTwinConnection.databaseName,
|
||||
digitalTwinConnection.username,
|
||||
encryptedPassword,
|
||||
digitalTwinConnection.sslEnabled,
|
||||
digitalTwinConnection.isActive,
|
||||
digitalTwinConnection.name,
|
||||
]
|
||||
);
|
||||
console.log(`✅ 연결 정보 업데이트 완료`);
|
||||
} else {
|
||||
// 새 연결 추가
|
||||
const result = await internalPool.query(
|
||||
`INSERT INTO flow_external_db_connection (
|
||||
name,
|
||||
description,
|
||||
db_type,
|
||||
host,
|
||||
port,
|
||||
database_name,
|
||||
username,
|
||||
password_encrypted,
|
||||
ssl_enabled,
|
||||
is_active,
|
||||
created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'system')
|
||||
RETURNING id`,
|
||||
[
|
||||
digitalTwinConnection.name,
|
||||
digitalTwinConnection.description,
|
||||
digitalTwinConnection.dbType,
|
||||
digitalTwinConnection.host,
|
||||
digitalTwinConnection.port,
|
||||
digitalTwinConnection.databaseName,
|
||||
digitalTwinConnection.username,
|
||||
encryptedPassword,
|
||||
digitalTwinConnection.sslEnabled,
|
||||
digitalTwinConnection.isActive,
|
||||
]
|
||||
);
|
||||
connectionId = result.rows[0].id;
|
||||
console.log(`✅ 새 연결 추가 완료 (ID: ${connectionId})`);
|
||||
}
|
||||
|
||||
console.log("\n✅ 모든 테스트 완료!");
|
||||
console.log(`\n📌 연결 ID: ${connectionId}`);
|
||||
console.log(" 이 ID를 사용하여 플로우 관리나 제어 관리에서 외부 DB를 연동할 수 있습니다.");
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("\n❌ 오류 발생:", error.message);
|
||||
console.error("상세 정보:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
await internalPool.end();
|
||||
}
|
||||
}
|
||||
|
||||
// 스크립트 실행
|
||||
testDigitalTwinDb()
|
||||
.then(() => {
|
||||
console.log("\n🎉 스크립트 완료");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("\n💥 스크립트 실패:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function testTemplateCreation() {
|
||||
console.log("🧪 템플릿 생성 테스트 시작...");
|
||||
|
||||
try {
|
||||
// 1. 테이블 존재 여부 확인
|
||||
console.log("1. 템플릿 테이블 존재 여부 확인 중...");
|
||||
|
||||
try {
|
||||
const count = await prisma.template_standards.count();
|
||||
console.log(`✅ template_standards 테이블 발견 (현재 ${count}개 레코드)`);
|
||||
} catch (error) {
|
||||
if (error.code === "P2021") {
|
||||
console.log("❌ template_standards 테이블이 존재하지 않습니다.");
|
||||
console.log("👉 데이터베이스 마이그레이션이 필요합니다.");
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 2. 샘플 템플릿 생성 테스트
|
||||
console.log("2. 샘플 템플릿 생성 중...");
|
||||
|
||||
const sampleTemplate = {
|
||||
template_code: "test-button-" + Date.now(),
|
||||
template_name: "테스트 버튼",
|
||||
template_name_eng: "Test Button",
|
||||
description: "테스트용 버튼 템플릿",
|
||||
category: "button",
|
||||
icon_name: "mouse-pointer",
|
||||
default_size: {
|
||||
width: 80,
|
||||
height: 36,
|
||||
},
|
||||
layout_config: {
|
||||
components: [
|
||||
{
|
||||
type: "widget",
|
||||
widgetType: "button",
|
||||
label: "테스트 버튼",
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 80, height: 36 },
|
||||
style: {
|
||||
backgroundColor: "#3b82f6",
|
||||
color: "#ffffff",
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
sort_order: 999,
|
||||
is_active: "Y",
|
||||
is_public: "Y",
|
||||
company_code: "*",
|
||||
created_by: "test",
|
||||
updated_by: "test",
|
||||
};
|
||||
|
||||
const created = await prisma.template_standards.create({
|
||||
data: sampleTemplate,
|
||||
});
|
||||
|
||||
console.log("✅ 샘플 템플릿 생성 성공:", created.template_code);
|
||||
|
||||
// 3. 생성된 템플릿 조회 테스트
|
||||
console.log("3. 템플릿 조회 테스트 중...");
|
||||
|
||||
const retrieved = await prisma.template_standards.findUnique({
|
||||
where: { template_code: created.template_code },
|
||||
});
|
||||
|
||||
if (retrieved) {
|
||||
console.log("✅ 템플릿 조회 성공:", retrieved.template_name);
|
||||
console.log(
|
||||
"📄 Layout Config:",
|
||||
JSON.stringify(retrieved.layout_config, null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
// 4. 카테고리 목록 조회 테스트
|
||||
console.log("4. 카테고리 목록 조회 테스트 중...");
|
||||
|
||||
const categories = await prisma.template_standards.findMany({
|
||||
where: { is_active: "Y" },
|
||||
select: { category: true },
|
||||
distinct: ["category"],
|
||||
});
|
||||
|
||||
console.log(
|
||||
"✅ 발견된 카테고리:",
|
||||
categories.map((c) => c.category)
|
||||
);
|
||||
|
||||
// 5. 테스트 데이터 정리
|
||||
console.log("5. 테스트 데이터 정리 중...");
|
||||
|
||||
await prisma.template_standards.delete({
|
||||
where: { template_code: created.template_code },
|
||||
});
|
||||
|
||||
console.log("✅ 테스트 데이터 정리 완료");
|
||||
|
||||
console.log("🎉 모든 테스트 통과!");
|
||||
} catch (error) {
|
||||
console.error("❌ 테스트 실패:", error);
|
||||
console.error("📋 상세 정보:", {
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
stack: error.stack?.split("\n").slice(0, 5),
|
||||
});
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// 스크립트 실행
|
||||
testTemplateCreation();
|
||||
@@ -1,86 +0,0 @@
|
||||
/**
|
||||
* 마이그레이션 검증 스크립트
|
||||
*/
|
||||
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm';
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: databaseUrl,
|
||||
});
|
||||
|
||||
async function verifyMigration() {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
console.log('🔍 마이그레이션 결과 검증 중...\n');
|
||||
|
||||
// 전체 요소 수
|
||||
const total = await client.query(`
|
||||
SELECT COUNT(*) as count FROM dashboard_elements
|
||||
`);
|
||||
|
||||
// 새로운 subtype별 개수
|
||||
const mapV2 = await client.query(`
|
||||
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'map-summary-v2'
|
||||
`);
|
||||
|
||||
const chart = await client.query(`
|
||||
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'chart'
|
||||
`);
|
||||
|
||||
const listV2 = await client.query(`
|
||||
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'list-v2'
|
||||
`);
|
||||
|
||||
const metricV2 = await client.query(`
|
||||
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'custom-metric-v2'
|
||||
`);
|
||||
|
||||
const alertV2 = await client.query(`
|
||||
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'risk-alert-v2'
|
||||
`);
|
||||
|
||||
// 테스트 subtype 남아있는지 확인
|
||||
const remaining = await client.query(`
|
||||
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype LIKE '%-test%'
|
||||
`);
|
||||
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('📊 마이그레이션 결과 요약');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log(`전체 요소 수: ${total.rows[0].count}`);
|
||||
console.log(`map-summary-v2: ${mapV2.rows[0].count}`);
|
||||
console.log(`chart: ${chart.rows[0].count}`);
|
||||
console.log(`list-v2: ${listV2.rows[0].count}`);
|
||||
console.log(`custom-metric-v2: ${metricV2.rows[0].count}`);
|
||||
console.log(`risk-alert-v2: ${alertV2.rows[0].count}`);
|
||||
console.log('');
|
||||
|
||||
if (parseInt(remaining.rows[0].count) > 0) {
|
||||
console.log(`⚠️ 테스트 subtype이 ${remaining.rows[0].count}개 남아있습니다!`);
|
||||
} else {
|
||||
console.log('✅ 모든 테스트 subtype이 정상적으로 변경되었습니다!');
|
||||
}
|
||||
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('');
|
||||
console.log('🎉 마이그레이션이 성공적으로 완료되었습니다!');
|
||||
console.log('');
|
||||
console.log('다음 단계:');
|
||||
console.log('1. 프론트엔드 애플리케이션을 새로고침하세요');
|
||||
console.log('2. 대시보드를 열어 위젯이 정상적으로 작동하는지 확인하세요');
|
||||
console.log('3. 문제가 발생하면 백업에서 복원하세요');
|
||||
console.log('');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 오류 발생:', error.message);
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
verifyMigration();
|
||||
|
||||
@@ -93,7 +93,7 @@ const config: Config = {
|
||||
|
||||
// JWT 설정
|
||||
jwt: {
|
||||
secret: process.env.JWT_SECRET || "ilshin-plm-super-secret-jwt-key-2024",
|
||||
secret: process.env.JWT_SECRET || "change-this-jwt-secret-in-env",
|
||||
expiresIn: process.env.JWT_EXPIRES_IN || "24h",
|
||||
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || "7d",
|
||||
},
|
||||
|
||||
@@ -286,6 +286,11 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
received_qty = CAST(
|
||||
COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1 AS text
|
||||
),
|
||||
balance_qty = CAST(
|
||||
COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
|
||||
- COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0)
|
||||
- $1 AS text
|
||||
),
|
||||
updated_date = NOW()
|
||||
WHERE id = $2 AND company_code = $3`,
|
||||
[item.inbound_qty || 0, item.source_id, companyCode]
|
||||
@@ -783,7 +788,7 @@ export async function getShipments(req: AuthenticatedRequest, res: Response) {
|
||||
export async function getItems(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword, page, pageSize } = req.query;
|
||||
const { keyword, page, pageSize, division } = req.query;
|
||||
const currentPage = Math.max(1, Number(page) || 1);
|
||||
const limit = Math.min(500, Math.max(1, Number(pageSize) || 20));
|
||||
const offset = (currentPage - 1) * limit;
|
||||
@@ -800,6 +805,12 @@ export async function getItems(req: AuthenticatedRequest, res: Response) {
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (division) {
|
||||
conditions.push(`division ILIKE $${paramIdx}`);
|
||||
params.push(`%${division}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(" AND ");
|
||||
const pool = getPool();
|
||||
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
/**
|
||||
* 리포트 관리 컨트롤러
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { Response, NextFunction } from "express";
|
||||
import reportService from "../services/reportService";
|
||||
import {
|
||||
CreateReportRequest,
|
||||
UpdateReportRequest,
|
||||
SaveLayoutRequest,
|
||||
CreateTemplateRequest,
|
||||
GetReportsParams,
|
||||
} from "../types/report";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { logger } from "../utils/logger";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import {
|
||||
@@ -35,92 +34,91 @@ import {
|
||||
import { WatermarkConfig } from "../types/report";
|
||||
import bwipjs from "bwip-js";
|
||||
|
||||
function getUserInfo(req: AuthenticatedRequest) {
|
||||
return {
|
||||
userId: req.user?.userId || "SYSTEM",
|
||||
companyCode: req.user?.companyCode || "*",
|
||||
};
|
||||
}
|
||||
|
||||
export class ReportController {
|
||||
/**
|
||||
* 리포트 목록 조회
|
||||
* GET /api/admin/reports
|
||||
*/
|
||||
async getReports(req: Request, res: Response, next: NextFunction) {
|
||||
async getReports(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { companyCode } = getUserInfo(req);
|
||||
const {
|
||||
page = "1",
|
||||
limit = "20",
|
||||
searchText = "",
|
||||
reportType = "",
|
||||
useYn = "Y",
|
||||
sortBy = "created_at",
|
||||
sortOrder = "DESC",
|
||||
page = "1", limit = "20", searchText = "", searchField,
|
||||
startDate, endDate, reportType = "", useYn = "Y",
|
||||
sortBy = "created_at", sortOrder = "DESC",
|
||||
} = req.query;
|
||||
|
||||
const result = await reportService.getReports({
|
||||
page: parseInt(page as string, 10),
|
||||
limit: parseInt(limit as string, 10),
|
||||
searchText: searchText as string,
|
||||
searchField: searchField as GetReportsParams["searchField"],
|
||||
startDate: startDate as string | undefined,
|
||||
endDate: endDate as string | undefined,
|
||||
reportType: reportType as string,
|
||||
useYn: useYn as string,
|
||||
sortBy: sortBy as string,
|
||||
sortOrder: sortOrder as "ASC" | "DESC",
|
||||
});
|
||||
}, companyCode);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
return res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 리포트 상세 조회
|
||||
* GET /api/admin/reports/:reportId
|
||||
*/
|
||||
async getReportById(req: Request, res: Response, next: NextFunction) {
|
||||
async getReportById(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { companyCode } = getUserInfo(req);
|
||||
const { reportId } = req.params;
|
||||
|
||||
const report = await reportService.getReportById(reportId);
|
||||
const report = await reportService.getReportById(reportId, companyCode);
|
||||
|
||||
if (!report) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "리포트를 찾을 수 없습니다.",
|
||||
});
|
||||
return res.status(404).json({ success: false, message: "리포트를 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: report,
|
||||
});
|
||||
return res.json({ success: true, data: report });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 리포트 생성
|
||||
* POST /api/admin/reports
|
||||
*/
|
||||
async createReport(req: Request, res: Response, next: NextFunction) {
|
||||
async getReportsByMenuObjid(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data: CreateReportRequest = req.body;
|
||||
const userId = (req as any).user?.userId || "SYSTEM";
|
||||
const { companyCode } = getUserInfo(req);
|
||||
const { menuObjid } = req.params;
|
||||
const menuObjidNum = parseInt(menuObjid, 10);
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!data.reportNameKor || !data.reportType) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "리포트명과 리포트 타입은 필수입니다.",
|
||||
});
|
||||
if (isNaN(menuObjidNum)) {
|
||||
return res.status(400).json({ success: false, message: "menuObjid는 숫자여야 합니다." });
|
||||
}
|
||||
|
||||
const result = await reportService.getReportsByMenuObjid(menuObjidNum, companyCode);
|
||||
return res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createReport(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { userId, companyCode } = getUserInfo(req);
|
||||
const data: CreateReportRequest = req.body;
|
||||
|
||||
if (!data.reportNameKor || !data.reportType) {
|
||||
return res.status(400).json({ success: false, message: "리포트명과 리포트 타입은 필수입니다." });
|
||||
}
|
||||
|
||||
data.companyCode = companyCode;
|
||||
const reportId = await reportService.createReport(data, userId);
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: {
|
||||
reportId,
|
||||
},
|
||||
data: { reportId },
|
||||
message: "리포트가 생성되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -128,83 +126,56 @@ export class ReportController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 리포트 수정
|
||||
* PUT /api/admin/reports/:reportId
|
||||
*/
|
||||
async updateReport(req: Request, res: Response, next: NextFunction) {
|
||||
async updateReport(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { userId, companyCode } = getUserInfo(req);
|
||||
const { reportId } = req.params;
|
||||
const data: UpdateReportRequest = req.body;
|
||||
const userId = (req as any).user?.userId || "SYSTEM";
|
||||
|
||||
const success = await reportService.updateReport(reportId, data, userId);
|
||||
const success = await reportService.updateReport(reportId, data, userId, companyCode);
|
||||
|
||||
if (!success) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "수정할 내용이 없습니다.",
|
||||
});
|
||||
return res.status(400).json({ success: false, message: "수정할 내용이 없습니다." });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "리포트가 수정되었습니다.",
|
||||
});
|
||||
return res.json({ success: true, message: "리포트가 수정되었습니다." });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 리포트 삭제
|
||||
* DELETE /api/admin/reports/:reportId
|
||||
*/
|
||||
async deleteReport(req: Request, res: Response, next: NextFunction) {
|
||||
async deleteReport(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { companyCode } = getUserInfo(req);
|
||||
const { reportId } = req.params;
|
||||
|
||||
const success = await reportService.deleteReport(reportId);
|
||||
const success = await reportService.deleteReport(reportId, companyCode);
|
||||
|
||||
if (!success) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "리포트를 찾을 수 없습니다.",
|
||||
});
|
||||
return res.status(404).json({ success: false, message: "리포트를 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "리포트가 삭제되었습니다.",
|
||||
});
|
||||
return res.json({ success: true, message: "리포트가 삭제되었습니다." });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 리포트 복사
|
||||
* POST /api/admin/reports/:reportId/copy
|
||||
*/
|
||||
async copyReport(req: Request, res: Response, next: NextFunction) {
|
||||
async copyReport(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { userId, companyCode } = getUserInfo(req);
|
||||
const { reportId } = req.params;
|
||||
const userId = (req as any).user?.userId || "SYSTEM";
|
||||
const { newName } = req.body;
|
||||
|
||||
const newReportId = await reportService.copyReport(reportId, userId);
|
||||
const newReportId = await reportService.copyReport(reportId, userId, companyCode, newName);
|
||||
|
||||
if (!newReportId) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "리포트를 찾을 수 없습니다.",
|
||||
});
|
||||
return res.status(404).json({ success: false, message: "리포트를 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: {
|
||||
reportId: newReportId,
|
||||
},
|
||||
data: { reportId: newReportId },
|
||||
message: "리포트가 복사되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -212,132 +183,92 @@ export class ReportController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 조회
|
||||
* GET /api/admin/reports/:reportId/layout
|
||||
*/
|
||||
async getLayout(req: Request, res: Response, next: NextFunction) {
|
||||
async getLayout(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { companyCode } = getUserInfo(req);
|
||||
const { reportId } = req.params;
|
||||
|
||||
const layout = await reportService.getLayout(reportId);
|
||||
const layout = await reportService.getLayout(reportId, companyCode);
|
||||
|
||||
if (!layout) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "레이아웃을 찾을 수 없습니다.",
|
||||
});
|
||||
return res.status(404).json({ success: false, message: "레이아웃을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
// components 컬럼에서 JSON 파싱
|
||||
const parsedComponents = layout.components
|
||||
? JSON.parse(layout.components)
|
||||
: null;
|
||||
|
||||
const storedData = layout.components;
|
||||
let layoutData;
|
||||
// 새 구조 (layoutConfig.pages)인지 확인
|
||||
|
||||
if (
|
||||
parsedComponents &&
|
||||
parsedComponents.pages &&
|
||||
Array.isArray(parsedComponents.pages)
|
||||
storedData &&
|
||||
typeof storedData === "object" &&
|
||||
!Array.isArray(storedData) &&
|
||||
Array.isArray((storedData as Record<string, unknown>).pages)
|
||||
) {
|
||||
// pages 배열을 직접 포함하여 반환
|
||||
const parsed = storedData as Record<string, unknown>;
|
||||
layoutData = {
|
||||
...layout,
|
||||
pages: parsedComponents.pages,
|
||||
components: [], // 호환성을 위해 빈 배열
|
||||
pages: parsed.pages,
|
||||
watermark: parsed.watermark,
|
||||
components: storedData,
|
||||
};
|
||||
} else {
|
||||
// 기존 구조: components 배열
|
||||
layoutData = {
|
||||
...layout,
|
||||
components: parsedComponents || [],
|
||||
};
|
||||
layoutData = { ...layout, components: storedData || [] };
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: layoutData,
|
||||
});
|
||||
return res.json({ success: true, data: layoutData });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 저장
|
||||
* PUT /api/admin/reports/:reportId/layout
|
||||
*/
|
||||
async saveLayout(req: Request, res: Response, next: NextFunction) {
|
||||
async saveLayout(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { userId, companyCode } = getUserInfo(req);
|
||||
const { reportId } = req.params;
|
||||
const data: SaveLayoutRequest = req.body;
|
||||
const userId = (req as any).user?.userId || "SYSTEM";
|
||||
|
||||
// 필수 필드 검증 (페이지 기반 구조)
|
||||
if (
|
||||
!data.layoutConfig ||
|
||||
!data.layoutConfig.pages ||
|
||||
data.layoutConfig.pages.length === 0
|
||||
) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "레이아웃 설정이 필요합니다.",
|
||||
});
|
||||
if (!data.layoutConfig?.pages?.length) {
|
||||
return res.status(400).json({ success: false, message: "레이아웃 설정이 필요합니다." });
|
||||
}
|
||||
|
||||
await reportService.saveLayout(reportId, data, userId);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "레이아웃이 저장되었습니다.",
|
||||
});
|
||||
await reportService.saveLayout(reportId, data, userId, companyCode);
|
||||
return res.json({ success: true, message: "레이아웃이 저장되었습니다." });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 목록 조회
|
||||
* GET /api/admin/reports/templates
|
||||
*/
|
||||
async getTemplates(req: Request, res: Response, next: NextFunction) {
|
||||
async getTemplates(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const templates = await reportService.getTemplates();
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: templates,
|
||||
});
|
||||
return res.json({ success: true, data: templates });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 생성
|
||||
* POST /api/admin/reports/templates
|
||||
*/
|
||||
async createTemplate(req: Request, res: Response, next: NextFunction) {
|
||||
async getCategories(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data: CreateTemplateRequest = req.body;
|
||||
const userId = (req as any).user?.userId || "SYSTEM";
|
||||
const categories = await reportService.getCategories();
|
||||
return res.json({ success: true, data: categories });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createTemplate(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { userId } = getUserInfo(req);
|
||||
const data: CreateTemplateRequest = req.body;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!data.templateNameKor || !data.templateType) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "템플릿명과 템플릿 타입은 필수입니다.",
|
||||
});
|
||||
return res.status(400).json({ success: false, message: "템플릿명과 템플릿 타입은 필수입니다." });
|
||||
}
|
||||
|
||||
const templateId = await reportService.createTemplate(data, userId);
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: {
|
||||
templateId,
|
||||
},
|
||||
data: { templateId },
|
||||
message: "템플릿이 생성되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -345,37 +276,23 @@ export class ReportController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 리포트를 템플릿으로 저장
|
||||
* POST /api/admin/reports/:reportId/save-as-template
|
||||
*/
|
||||
async saveAsTemplate(req: Request, res: Response, next: NextFunction) {
|
||||
async saveAsTemplate(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { userId } = getUserInfo(req);
|
||||
const { reportId } = req.params;
|
||||
const { templateNameKor, templateNameEng, description } = req.body;
|
||||
const userId = (req as any).user?.userId || "SYSTEM";
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!templateNameKor) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "템플릿명은 필수입니다.",
|
||||
});
|
||||
return res.status(400).json({ success: false, message: "템플릿명은 필수입니다." });
|
||||
}
|
||||
|
||||
const templateId = await reportService.saveAsTemplate(
|
||||
reportId,
|
||||
templateNameKor,
|
||||
templateNameEng,
|
||||
description,
|
||||
userId
|
||||
reportId, templateNameKor, templateNameEng, description, userId
|
||||
);
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: {
|
||||
templateId,
|
||||
},
|
||||
data: { templateId },
|
||||
message: "템플릿이 저장되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -383,39 +300,20 @@ export class ReportController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요)
|
||||
* POST /api/admin/reports/templates/create-from-layout
|
||||
*/
|
||||
async createTemplateFromLayout(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
async createTemplateFromLayout(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { userId } = getUserInfo(req);
|
||||
const {
|
||||
templateNameKor,
|
||||
templateNameEng,
|
||||
templateType,
|
||||
description,
|
||||
layoutConfig,
|
||||
defaultQueries = [],
|
||||
templateNameKor, templateNameEng, templateType,
|
||||
description, layoutConfig, defaultQueries = [],
|
||||
} = req.body;
|
||||
const userId = (req as any).user?.userId || "SYSTEM";
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!templateNameKor) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "템플릿명은 필수입니다.",
|
||||
});
|
||||
return res.status(400).json({ success: false, message: "템플릿명은 필수입니다." });
|
||||
}
|
||||
|
||||
if (!layoutConfig) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "레이아웃 설정은 필수입니다.",
|
||||
});
|
||||
return res.status(400).json({ success: false, message: "레이아웃 설정은 필수입니다." });
|
||||
}
|
||||
|
||||
const templateId = await reportService.createTemplateFromLayout(
|
||||
@@ -440,78 +338,47 @@ export class ReportController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 삭제
|
||||
* DELETE /api/admin/reports/templates/:templateId
|
||||
*/
|
||||
async deleteTemplate(req: Request, res: Response, next: NextFunction) {
|
||||
async deleteTemplate(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { templateId } = req.params;
|
||||
|
||||
const success = await reportService.deleteTemplate(templateId);
|
||||
|
||||
if (!success) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "템플릿을 찾을 수 없거나 시스템 템플릿입니다.",
|
||||
});
|
||||
return res.status(404).json({ success: false, message: "템플릿을 찾을 수 없거나 시스템 템플릿입니다." });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "템플릿이 삭제되었습니다.",
|
||||
});
|
||||
return res.json({ success: true, message: "템플릿이 삭제되었습니다." });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿼리 실행
|
||||
* POST /api/admin/reports/:reportId/queries/:queryId/execute
|
||||
*/
|
||||
async executeQuery(req: Request, res: Response, next: NextFunction) {
|
||||
async executeQuery(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { reportId, queryId } = req.params;
|
||||
const { parameters = {}, sqlQuery, externalConnectionId } = req.body;
|
||||
|
||||
const result = await reportService.executeQuery(
|
||||
reportId,
|
||||
queryId,
|
||||
parameters,
|
||||
sqlQuery,
|
||||
externalConnectionId
|
||||
reportId, queryId, parameters, sqlQuery, externalConnectionId
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: error.message || "쿼리 실행에 실패했습니다.",
|
||||
});
|
||||
return res.json({ success: true, data: result });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "쿼리 실행에 실패했습니다.";
|
||||
return res.status(400).json({ success: false, message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 DB 연결 목록 조회 (활성화된 것만)
|
||||
* GET /api/admin/reports/external-connections
|
||||
*/
|
||||
async getExternalConnections(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
async getExternalConnections(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { companyCode } = getUserInfo(req);
|
||||
const { ExternalDbConnectionService } = await import(
|
||||
"../services/externalDbConnectionService"
|
||||
);
|
||||
|
||||
const result = await ExternalDbConnectionService.getConnections({
|
||||
is_active: "Y",
|
||||
company_code: req.body.companyCode || "",
|
||||
company_code: companyCode,
|
||||
});
|
||||
|
||||
return res.json(result);
|
||||
@@ -520,52 +387,34 @@ export class ReportController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 파일 업로드
|
||||
* POST /api/admin/reports/upload-image
|
||||
*/
|
||||
async uploadImage(req: Request, res: Response, next: NextFunction) {
|
||||
async uploadImage(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "이미지 파일이 필요합니다.",
|
||||
});
|
||||
return res.status(400).json({ success: false, message: "이미지 파일이 필요합니다." });
|
||||
}
|
||||
|
||||
const companyCode = req.body.companyCode || "SYSTEM";
|
||||
const { companyCode } = getUserInfo(req);
|
||||
const file = req.file;
|
||||
|
||||
// 파일 저장 경로 생성
|
||||
const uploadDir = path.join(
|
||||
process.cwd(),
|
||||
"uploads",
|
||||
`company_${companyCode}`,
|
||||
"reports"
|
||||
);
|
||||
const uploadDir = path.join(process.cwd(), "uploads", `company_${companyCode}`, "reports");
|
||||
|
||||
// 디렉토리가 없으면 생성
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 고유한 파일명 생성 (타임스탬프 + 원본 파일명)
|
||||
const timestamp = Date.now();
|
||||
const safeFileName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||
const fileName = `${timestamp}_${safeFileName}`;
|
||||
const filePath = path.join(uploadDir, fileName);
|
||||
|
||||
// 파일 저장
|
||||
fs.writeFileSync(filePath, file.buffer);
|
||||
|
||||
// 웹에서 접근 가능한 URL 반환
|
||||
const fileUrl = `/uploads/company_${companyCode}/reports/${fileName}`;
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
fileName,
|
||||
fileUrl,
|
||||
fileName, fileUrl,
|
||||
originalName: file.originalname,
|
||||
size: file.size,
|
||||
mimeType: file.mimetype,
|
||||
@@ -576,11 +425,7 @@ export class ReportController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트 데이터를 WORD(DOCX)로 변환
|
||||
* POST /api/admin/reports/export-word
|
||||
*/
|
||||
async exportToWord(req: Request, res: Response, next: NextFunction) {
|
||||
async exportToWord(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { layoutConfig, queryResults, fileName = "리포트" } = req.body;
|
||||
|
||||
@@ -591,22 +436,15 @@ export class ReportController {
|
||||
});
|
||||
}
|
||||
|
||||
// mm를 twip으로 변환
|
||||
const mmToTwip = (mm: number) => convertMillimetersToTwip(mm);
|
||||
|
||||
// 프론트엔드와 동일한 MM_TO_PX 상수 (캔버스에서 mm를 px로 변환할 때 사용하는 값)
|
||||
const MM_TO_PX = 4;
|
||||
// 1mm = 56.692913386 twip (docx 라이브러리 기준)
|
||||
// px를 twip으로 변환: px -> mm -> twip
|
||||
const MM_TO_PX = 4; // 프론트엔드와 동일, 1mm = 56.692913386 twip (docx)
|
||||
const pxToTwip = (px: number) => Math.round((px / MM_TO_PX) * 56.692913386);
|
||||
|
||||
// 쿼리 결과 맵
|
||||
const queryResultsMap: Record<
|
||||
string,
|
||||
{ fields: string[]; rows: Record<string, unknown>[] }
|
||||
> = queryResults || {};
|
||||
|
||||
// 컴포넌트 값 가져오기
|
||||
const getComponentValue = (component: any): string => {
|
||||
if (component.queryId && component.fieldName) {
|
||||
const queryResult = queryResultsMap[component.queryId];
|
||||
@@ -621,11 +459,9 @@ export class ReportController {
|
||||
return component.defaultValue || "";
|
||||
};
|
||||
|
||||
// px → half-point 변환 (1px = 0.75pt, Word는 half-pt 단위 사용)
|
||||
// px * 0.75 * 2 = px * 1.5
|
||||
// px → half-point (1px = 0.75pt, px * 1.5)
|
||||
const pxToHalfPt = (px: number) => Math.round(px * 1.5);
|
||||
|
||||
// 셀 내용 생성 헬퍼 함수 (가로 배치용)
|
||||
const createCellContent = (
|
||||
component: any,
|
||||
displayValue: string,
|
||||
@@ -1557,7 +1393,7 @@ export class ReportController {
|
||||
const base64 = png.toString("base64");
|
||||
return `data:image/png;base64,${base64}`;
|
||||
} catch (error) {
|
||||
console.error("바코드 생성 오류:", error);
|
||||
logger.error("바코드 생성 오류:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -1891,7 +1727,7 @@ export class ReportController {
|
||||
children.push(paragraph);
|
||||
lastBottomY = adjustedY + component.height;
|
||||
} catch (imgError) {
|
||||
console.error("이미지 처리 오류:", imgError);
|
||||
logger.error("이미지 처리 오류:", imgError);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2005,7 +1841,7 @@ export class ReportController {
|
||||
});
|
||||
children.push(paragraph);
|
||||
} catch (imgError) {
|
||||
console.error("서명 이미지 오류:", imgError);
|
||||
logger.error("서명 이미지 오류:", imgError);
|
||||
textRuns.push(
|
||||
new TextRun({
|
||||
text: "_".repeat(20),
|
||||
@@ -2083,7 +1919,7 @@ export class ReportController {
|
||||
});
|
||||
children.push(paragraph);
|
||||
} catch (imgError) {
|
||||
console.error("도장 이미지 오류:", imgError);
|
||||
logger.error("도장 이미지 오류:", imgError);
|
||||
textRuns.push(
|
||||
new TextRun({
|
||||
text: "(인)",
|
||||
@@ -2886,7 +2722,7 @@ export class ReportController {
|
||||
})
|
||||
);
|
||||
} catch (imgError) {
|
||||
console.error("바코드 이미지 오류:", imgError);
|
||||
logger.error("바코드 이미지 오류:", imgError);
|
||||
// 바코드 이미지 생성 실패 시 텍스트로 대체
|
||||
const barcodeValue = component.barcodeValue || "BARCODE";
|
||||
children.push(
|
||||
@@ -3164,13 +3000,57 @@ export class ReportController {
|
||||
|
||||
return res.send(docxBuffer);
|
||||
} catch (error: any) {
|
||||
console.error("WORD 변환 오류:", error);
|
||||
logger.error("WORD 변환 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "WORD 변환에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 비주얼 쿼리 빌더 API ─────────────────────────────────────────────────────
|
||||
|
||||
async getSchemaTables(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const tables = await reportService.getSchemaTables();
|
||||
return res.json({ success: true, data: tables });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "테이블 목록 조회에 실패했습니다.";
|
||||
logger.error("스키마 테이블 조회 오류:", { error: message });
|
||||
return res.status(500).json({ success: false, message });
|
||||
}
|
||||
}
|
||||
|
||||
async getSchemaTableColumns(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
if (!tableName) {
|
||||
return res.status(400).json({ success: false, message: "테이블명이 필요합니다." });
|
||||
}
|
||||
const columns = await reportService.getSchemaTableColumns(tableName);
|
||||
return res.json({ success: true, data: columns });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "컬럼 목록 조회에 실패했습니다.";
|
||||
logger.error("테이블 컬럼 조회 오류:", { error: message });
|
||||
return res.status(500).json({ success: false, message });
|
||||
}
|
||||
}
|
||||
|
||||
async previewVisualQuery(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { visualQuery } = req.body;
|
||||
if (!visualQuery || !visualQuery.tableName) {
|
||||
return res.status(400).json({ success: false, message: "visualQuery 정보가 필요합니다." });
|
||||
}
|
||||
const result = await reportService.executeVisualQuery(visualQuery);
|
||||
const generatedSql = reportService.buildVisualQuerySql(visualQuery);
|
||||
return res.json({ success: true, data: { ...result, sql: generatedSql } });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "쿼리 실행에 실패했습니다.";
|
||||
logger.error("비주얼 쿼리 미리보기 오류:", { error: message });
|
||||
return res.status(500).json({ success: false, message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ReportController();
|
||||
|
||||
@@ -7,9 +7,21 @@ import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import { numberingRuleService } from "../services/numberingRuleService";
|
||||
|
||||
// 자동 마이그레이션: work_instruction_detail에 routing_version_id 컬럼 추가
|
||||
let _migrationDone = false;
|
||||
async function ensureDetailRoutingColumn() {
|
||||
if (_migrationDone) return;
|
||||
try {
|
||||
const pool = getPool();
|
||||
await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS routing_version_id VARCHAR(500)");
|
||||
_migrationDone = true;
|
||||
} catch { /* 이미 존재하거나 권한 문제 시 무시 */ }
|
||||
}
|
||||
|
||||
// ─── 작업지시 목록 조회 (detail 기준 행 반환) ───
|
||||
export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
await ensureDetailRoutingColumn();
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { dateFrom, dateTo, status, progressStatus, keyword } = req.query;
|
||||
|
||||
@@ -72,6 +84,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
d.part_code,
|
||||
d.source_table,
|
||||
d.source_id,
|
||||
d.routing_version_id AS detail_routing_version_id,
|
||||
COALESCE(itm.item_name, '') AS item_name,
|
||||
COALESCE(itm.size, '') AS item_spec,
|
||||
COALESCE(e.equipment_name, '') AS equipment_name,
|
||||
@@ -131,6 +144,7 @@ export async function previewNextNo(req: AuthenticatedRequest, res: Response) {
|
||||
// ─── 작업지시 저장 (신규/수정) ───
|
||||
export async function save(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
await ensureDetailRoutingColumn();
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { id: editId, status: wiStatus, progressStatus, reason, startDate, endDate, equipmentId, workTeam, worker, remark, items, routing: routingVersionId } = req.body;
|
||||
@@ -175,8 +189,8 @@ export async function save(req: AuthenticatedRequest, res: Response) {
|
||||
|
||||
for (const item of items) {
|
||||
await client.query(
|
||||
`INSERT INTO work_instruction_detail (id,company_code,work_instruction_no,item_number,qty,remark,source_table,source_id,part_code,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,NOW(),$9)`,
|
||||
[companyCode, wiNo, item.itemNumber||item.itemCode||"", item.qty||"0", item.remark||"", item.sourceTable||"", item.sourceId||"", item.partCode||item.itemNumber||item.itemCode||"", userId]
|
||||
`INSERT INTO work_instruction_detail (id,company_code,work_instruction_no,item_number,qty,remark,source_table,source_id,part_code,routing_version_id,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,NOW(),$10)`,
|
||||
[companyCode, wiNo, item.itemNumber||item.itemCode||"", item.qty||"0", item.remark||"", item.sourceTable||"", item.sourceId||"", item.partCode||item.itemNumber||item.itemCode||"", item.routing||null, userId]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,11 @@ router.get("/templates", (req, res, next) =>
|
||||
router.post("/templates", (req, res, next) =>
|
||||
reportController.createTemplate(req, res, next)
|
||||
);
|
||||
|
||||
// 카테고리(report_type) 목록 조회
|
||||
router.get("/categories", (req, res, next) =>
|
||||
reportController.getCategories(req, res, next)
|
||||
);
|
||||
// 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요)
|
||||
router.post("/templates/create-from-layout", (req, res, next) =>
|
||||
reportController.createTemplateFromLayout(req, res, next)
|
||||
@@ -61,6 +66,17 @@ router.post("/export-word", (req, res, next) =>
|
||||
reportController.exportToWord(req, res, next)
|
||||
);
|
||||
|
||||
// 비주얼 쿼리 빌더 — 스키마 조회 (/:reportId 패턴보다 반드시 먼저 등록)
|
||||
router.get("/schema/tables", (req, res, next) =>
|
||||
reportController.getSchemaTables(req, res, next)
|
||||
);
|
||||
router.get("/schema/tables/:tableName/columns", (req, res, next) =>
|
||||
reportController.getSchemaTableColumns(req, res, next)
|
||||
);
|
||||
router.post("/schema/preview", (req, res, next) =>
|
||||
reportController.previewVisualQuery(req, res, next)
|
||||
);
|
||||
|
||||
// 리포트 목록
|
||||
router.get("/", (req, res, next) =>
|
||||
reportController.getReports(req, res, next)
|
||||
@@ -71,6 +87,11 @@ router.post("/", (req, res, next) =>
|
||||
reportController.createReport(req, res, next)
|
||||
);
|
||||
|
||||
// 메뉴별 리포트 목록 (/:reportId 보다 반드시 먼저 등록)
|
||||
router.get("/by-menu/:menuObjid", (req, res, next) =>
|
||||
reportController.getReportsByMenuObjid(req, res, next)
|
||||
);
|
||||
|
||||
// 리포트 복사 (구체적인 경로를 먼저 배치)
|
||||
router.post("/:reportId/copy", (req, res, next) =>
|
||||
reportController.copyReport(req, res, next)
|
||||
|
||||
@@ -1195,6 +1195,10 @@ export class DynamicFormService {
|
||||
|
||||
const updatedRecord = Array.isArray(result) ? result[0] : result;
|
||||
|
||||
if (!updatedRecord) {
|
||||
throw new Error(`업데이트 대상 레코드를 찾을 수 없습니다. (id: ${id}, 테이블: ${tableName})`);
|
||||
}
|
||||
|
||||
// 🔥 조건부 연결 실행 (UPDATE 트리거)
|
||||
try {
|
||||
if (company_code) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -202,7 +202,7 @@ export class RiskAlertService {
|
||||
}
|
||||
|
||||
// 2순위: 한국도로공사 API (현재 차단됨)
|
||||
const exwayApiKey = process.env.EXWAY_API_KEY || '7820214492';
|
||||
const exwayApiKey = process.env.EXWAY_API_KEY || '';
|
||||
try {
|
||||
const url = 'https://data.ex.co.kr/openapi/business/trafficFcst';
|
||||
|
||||
@@ -321,7 +321,7 @@ export class RiskAlertService {
|
||||
}
|
||||
|
||||
// 2순위: 한국도로공사 API
|
||||
const exwayApiKey = process.env.EXWAY_API_KEY || '7820214492';
|
||||
const exwayApiKey = process.env.EXWAY_API_KEY || '';
|
||||
try {
|
||||
const url = 'https://data.ex.co.kr/openapi/business/trafficFcst';
|
||||
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
process.env.NODE_ENV = "test";
|
||||
// 실제 DB 연결을 위해 운영 데이터베이스 사용 (읽기 전용 테스트만 수행)
|
||||
process.env.DATABASE_URL =
|
||||
process.env.TEST_DATABASE_URL ||
|
||||
"postgresql://postgres:ph0909!!@39.117.244.52:11132/plm";
|
||||
process.env.TEST_DATABASE_URL || "";
|
||||
process.env.JWT_SECRET = "test-jwt-secret-key-for-testing-only";
|
||||
process.env.PORT = "3001";
|
||||
process.env.DEBUG = "true"; // 테스트 시 디버그 로그 활성화
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
/**
|
||||
* 리포트 관리 시스템 타입 정의
|
||||
*/
|
||||
|
||||
// 리포트 템플릿
|
||||
export interface ReportTemplate {
|
||||
template_id: string;
|
||||
template_name_kor: string;
|
||||
@@ -21,12 +16,12 @@ export interface ReportTemplate {
|
||||
updated_by: string | null;
|
||||
}
|
||||
|
||||
// 리포트 마스터
|
||||
export interface ReportMaster {
|
||||
report_id: string;
|
||||
report_name_kor: string;
|
||||
report_name_eng: string | null;
|
||||
template_id: string | null;
|
||||
template_name: string | null;
|
||||
report_type: string;
|
||||
company_code: string | null;
|
||||
description: string | null;
|
||||
@@ -37,7 +32,6 @@ export interface ReportMaster {
|
||||
updated_by: string | null;
|
||||
}
|
||||
|
||||
// 리포트 레이아웃
|
||||
export interface ReportLayout {
|
||||
layout_id: string;
|
||||
report_id: string;
|
||||
@@ -55,7 +49,6 @@ export interface ReportLayout {
|
||||
updated_by: string | null;
|
||||
}
|
||||
|
||||
// 리포트 쿼리
|
||||
export interface ReportQuery {
|
||||
query_id: string;
|
||||
report_id: string;
|
||||
@@ -63,7 +56,7 @@ export interface ReportQuery {
|
||||
query_type: "MASTER" | "DETAIL";
|
||||
sql_query: string;
|
||||
parameters: string[] | null;
|
||||
external_connection_id: number | null; // 외부 DB 연결 ID (NULL이면 내부 DB)
|
||||
external_connection_id: number | null;
|
||||
display_order: number;
|
||||
created_at: Date;
|
||||
created_by: string | null;
|
||||
@@ -71,34 +64,37 @@ export interface ReportQuery {
|
||||
updated_by: string | null;
|
||||
}
|
||||
|
||||
// 리포트 상세 (마스터 + 레이아웃 + 쿼리 + 연결된 메뉴)
|
||||
export interface ReportDetail {
|
||||
report: ReportMaster;
|
||||
layout: ReportLayout | null;
|
||||
queries: ReportQuery[];
|
||||
menuObjids?: number[]; // 연결된 메뉴 ID 목록
|
||||
menuObjids?: number[];
|
||||
}
|
||||
|
||||
// 리포트 목록 조회 파라미터
|
||||
export interface GetReportsParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
searchText?: string;
|
||||
searchField?: "report_name" | "created_by" | "report_type" | "updated_at" | "created_at";
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
reportType?: string;
|
||||
useYn?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
}
|
||||
|
||||
// 리포트 목록 응답
|
||||
export interface GetReportsResponse {
|
||||
items: ReportMaster[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
typeSummary: Array<{ type: string; count: number }>;
|
||||
allTypes: string[];
|
||||
recentActivity: Array<{ date: string; count: number }>;
|
||||
recentTotal: number;
|
||||
}
|
||||
|
||||
// 리포트 생성 요청
|
||||
export interface CreateReportRequest {
|
||||
reportNameKor: string;
|
||||
reportNameEng?: string;
|
||||
@@ -108,7 +104,6 @@ export interface CreateReportRequest {
|
||||
companyCode?: string;
|
||||
}
|
||||
|
||||
// 리포트 수정 요청
|
||||
export interface UpdateReportRequest {
|
||||
reportNameKor?: string;
|
||||
reportNameEng?: string;
|
||||
@@ -117,23 +112,18 @@ export interface UpdateReportRequest {
|
||||
useYn?: string;
|
||||
}
|
||||
|
||||
// 워터마크 설정
|
||||
export interface WatermarkConfig {
|
||||
enabled: boolean;
|
||||
type: "text" | "image";
|
||||
// 텍스트 워터마크
|
||||
text?: string;
|
||||
fontSize?: number;
|
||||
fontColor?: string;
|
||||
// 이미지 워터마크
|
||||
imageUrl?: string;
|
||||
// 공통 설정
|
||||
opacity: number; // 0~1
|
||||
opacity: number;
|
||||
style: "diagonal" | "center" | "tile";
|
||||
rotation?: number; // 대각선일 때 각도 (기본 -45)
|
||||
rotation?: number;
|
||||
}
|
||||
|
||||
// 페이지 설정
|
||||
export interface PageConfig {
|
||||
page_id: string;
|
||||
page_name: string;
|
||||
@@ -147,30 +137,29 @@ export interface PageConfig {
|
||||
left: number;
|
||||
right: number;
|
||||
};
|
||||
components: any[];
|
||||
components: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
// 레이아웃 설정
|
||||
export interface ReportLayoutConfig {
|
||||
pages: PageConfig[];
|
||||
watermark?: WatermarkConfig; // 전체 페이지 공유 워터마크
|
||||
watermark?: WatermarkConfig;
|
||||
}
|
||||
|
||||
export interface SaveLayoutQueryItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "MASTER" | "DETAIL";
|
||||
sqlQuery: string;
|
||||
parameters: string[];
|
||||
externalConnectionId?: number | null;
|
||||
}
|
||||
|
||||
// 레이아웃 저장 요청
|
||||
export interface SaveLayoutRequest {
|
||||
layoutConfig: ReportLayoutConfig;
|
||||
queries?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: "MASTER" | "DETAIL";
|
||||
sqlQuery: string;
|
||||
parameters: string[];
|
||||
externalConnectionId?: number;
|
||||
}>;
|
||||
menuObjids?: number[]; // 연결할 메뉴 ID 목록
|
||||
queries?: SaveLayoutQueryItem[];
|
||||
menuObjids?: number[];
|
||||
}
|
||||
|
||||
// 리포트-메뉴 매핑
|
||||
export interface ReportMenuMapping {
|
||||
mapping_id: number;
|
||||
report_id: string;
|
||||
@@ -180,23 +169,20 @@ export interface ReportMenuMapping {
|
||||
created_by: string | null;
|
||||
}
|
||||
|
||||
// 템플릿 목록 응답
|
||||
export interface GetTemplatesResponse {
|
||||
system: ReportTemplate[];
|
||||
custom: ReportTemplate[];
|
||||
}
|
||||
|
||||
// 템플릿 생성 요청
|
||||
export interface CreateTemplateRequest {
|
||||
templateNameKor: string;
|
||||
templateNameEng?: string;
|
||||
templateType: string;
|
||||
description?: string;
|
||||
layoutConfig?: any;
|
||||
defaultQueries?: any;
|
||||
layoutConfig?: Record<string, unknown>;
|
||||
defaultQueries?: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
// 컴포넌트 설정 (프론트엔드와 동기화)
|
||||
export interface ComponentConfig {
|
||||
id: string;
|
||||
type: string;
|
||||
@@ -224,21 +210,16 @@ export interface ComponentConfig {
|
||||
conditional?: string;
|
||||
locked?: boolean;
|
||||
groupId?: string;
|
||||
// 이미지 전용
|
||||
imageUrl?: string;
|
||||
objectFit?: "contain" | "cover" | "fill" | "none";
|
||||
// 구분선 전용
|
||||
orientation?: "horizontal" | "vertical";
|
||||
lineStyle?: "solid" | "dashed" | "dotted" | "double";
|
||||
lineWidth?: number;
|
||||
lineColor?: string;
|
||||
// 서명/도장 전용
|
||||
showLabel?: boolean;
|
||||
labelText?: string;
|
||||
labelPosition?: "top" | "left" | "bottom" | "right";
|
||||
showUnderline?: boolean;
|
||||
personName?: string;
|
||||
// 테이블 전용
|
||||
tableColumns?: Array<{
|
||||
field: string;
|
||||
header: string;
|
||||
@@ -249,9 +230,7 @@ export interface ComponentConfig {
|
||||
headerTextColor?: string;
|
||||
showBorder?: boolean;
|
||||
rowHeight?: number;
|
||||
// 페이지 번호 전용
|
||||
pageNumberFormat?: "number" | "numberTotal" | "koreanNumber";
|
||||
// 카드 컴포넌트 전용
|
||||
cardTitle?: string;
|
||||
cardItems?: Array<{
|
||||
label: string;
|
||||
@@ -267,7 +246,6 @@ export interface ComponentConfig {
|
||||
titleColor?: string;
|
||||
labelColor?: string;
|
||||
valueColor?: string;
|
||||
// 계산 컴포넌트 전용
|
||||
calcItems?: Array<{
|
||||
label: string;
|
||||
value: number | string;
|
||||
@@ -280,7 +258,6 @@ export interface ComponentConfig {
|
||||
showCalcBorder?: boolean;
|
||||
numberFormat?: "none" | "comma" | "currency";
|
||||
currencySuffix?: string;
|
||||
// 바코드 컴포넌트 전용
|
||||
barcodeType?: "CODE128" | "CODE39" | "EAN13" | "EAN8" | "UPC" | "QR";
|
||||
barcodeValue?: string;
|
||||
barcodeFieldName?: string;
|
||||
@@ -289,19 +266,118 @@ export interface ComponentConfig {
|
||||
barcodeBackground?: string;
|
||||
barcodeMargin?: number;
|
||||
qrErrorCorrectionLevel?: "L" | "M" | "Q" | "H";
|
||||
// QR코드 다중 필드 (JSON 형식)
|
||||
qrDataFields?: Array<{
|
||||
fieldName: string;
|
||||
label: string;
|
||||
}>;
|
||||
qrUseMultiField?: boolean;
|
||||
qrIncludeAllRows?: boolean;
|
||||
// 체크박스 컴포넌트 전용
|
||||
checkboxChecked?: boolean; // 체크 상태 (고정값)
|
||||
checkboxFieldName?: string; // 쿼리 필드 바인딩 (truthy/falsy 값)
|
||||
checkboxLabel?: string; // 체크박스 옆 레이블 텍스트
|
||||
checkboxSize?: number; // 체크박스 크기 (px)
|
||||
checkboxColor?: string; // 체크 색상
|
||||
checkboxBorderColor?: string; // 테두리 색상
|
||||
checkboxLabelPosition?: "left" | "right"; // 레이블 위치
|
||||
checkboxChecked?: boolean;
|
||||
checkboxFieldName?: string;
|
||||
checkboxLabel?: string;
|
||||
checkboxSize?: number;
|
||||
checkboxColor?: string;
|
||||
checkboxBorderColor?: string;
|
||||
checkboxLabelPosition?: "left" | "right";
|
||||
visualQuery?: VisualQuery;
|
||||
// 카드 레이아웃 설정 (card 컴포넌트 전용 - v3)
|
||||
cardLayoutConfig?: CardLayoutConfig;
|
||||
}
|
||||
|
||||
export interface VisualQueryFormulaColumn {
|
||||
alias: string;
|
||||
header: string;
|
||||
expression: string;
|
||||
}
|
||||
|
||||
export interface VisualQuery {
|
||||
tableName: string;
|
||||
limit?: number;
|
||||
columns: string[];
|
||||
formulaColumns: VisualQueryFormulaColumn[];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 카드 레이아웃 v3 타입 정의
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type CardElementType = "header" | "dataCell" | "divider" | "badge";
|
||||
export type CellDirection = "vertical" | "horizontal";
|
||||
|
||||
export interface CardElementBase {
|
||||
id: string;
|
||||
type: CardElementType;
|
||||
colspan?: number;
|
||||
rowspan?: number;
|
||||
}
|
||||
|
||||
export interface CardHeaderElement extends CardElementBase {
|
||||
type: "header";
|
||||
icon?: string;
|
||||
iconColor?: string;
|
||||
title: string;
|
||||
titleColor?: string;
|
||||
titleFontSize?: number;
|
||||
}
|
||||
|
||||
export interface CardDataCellElement extends CardElementBase {
|
||||
type: "dataCell";
|
||||
direction: CellDirection;
|
||||
label: string;
|
||||
columnName?: string;
|
||||
inputType?: "text" | "date" | "number" | "select" | "readonly";
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
selectOptions?: string[];
|
||||
labelWidth?: number;
|
||||
labelFontSize?: number;
|
||||
labelColor?: string;
|
||||
valueFontSize?: number;
|
||||
valueColor?: string;
|
||||
}
|
||||
|
||||
export interface CardDividerElement extends CardElementBase {
|
||||
type: "divider";
|
||||
style?: "solid" | "dashed" | "dotted";
|
||||
color?: string;
|
||||
thickness?: number;
|
||||
}
|
||||
|
||||
export interface CardBadgeElement extends CardElementBase {
|
||||
type: "badge";
|
||||
label?: string;
|
||||
columnName?: string;
|
||||
colorMap?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type CardElement =
|
||||
| CardHeaderElement
|
||||
| CardDataCellElement
|
||||
| CardDividerElement
|
||||
| CardBadgeElement;
|
||||
|
||||
export interface CardLayoutRow {
|
||||
id: string;
|
||||
gridColumns: number;
|
||||
elements: CardElement[];
|
||||
height?: string;
|
||||
}
|
||||
|
||||
export interface CardLayoutConfig {
|
||||
tableName?: string;
|
||||
primaryKey?: string;
|
||||
rows: CardLayoutRow[];
|
||||
padding?: string;
|
||||
gap?: string;
|
||||
borderStyle?: string;
|
||||
borderColor?: string;
|
||||
backgroundColor?: string;
|
||||
headerTitleFontSize?: number;
|
||||
headerTitleColor?: string;
|
||||
labelFontSize?: number;
|
||||
labelColor?: string;
|
||||
valueFontSize?: number;
|
||||
valueColor?: string;
|
||||
dividerThickness?: number;
|
||||
dividerColor?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user