diff --git a/.cursor/rules/multi-tenancy-guide.mdc b/.cursor/rules/multi-tenancy-guide.mdc new file mode 100644 index 00000000..3b79dd63 --- /dev/null +++ b/.cursor/rules/multi-tenancy-guide.mdc @@ -0,0 +1,844 @@ +--- +priority: critical +applies_to: all +check_frequency: always +enforcement: mandatory +--- + +# 멀티테넌시(Multi-Tenancy) 필수 구현 가이드 + +**🚨 최우선 보안 규칙: 이 문서의 모든 규칙은 예외 없이 반드시 준수해야 합니다.** + +**⚠️ AI 에이전트는 모든 코드 작성/수정 후 반드시 이 체크리스트를 확인해야 합니다.** + +## 핵심 원칙 + +**모든 비즈니스 데이터는 회사별(company_code)로 완벽하게 격리되어야 합니다.** + +이 시스템은 멀티테넌트 아키텍처를 사용하며, 각 회사(tenant)는 자신의 데이터만 접근할 수 있어야 합니다. +다른 회사의 데이터에 접근하는 것은 **치명적인 보안 취약점**입니다. + +--- + +## 1. 데이터베이스 스키마 요구사항 + +### 1.1 company_code 컬럼 필수 + +**모든 비즈니스 테이블은 `company_code` 컬럼을 반드시 포함해야 합니다.** + +```sql +CREATE TABLE example_table ( + id SERIAL PRIMARY KEY, + company_code VARCHAR(20) NOT NULL, -- ✅ 필수! + name VARCHAR(100), + created_at TIMESTAMPTZ DEFAULT NOW(), + + -- 외래키 제약조건 (필수) + CONSTRAINT fk_company FOREIGN KEY (company_code) + REFERENCES company_mng(company_code) + ON DELETE CASCADE ON UPDATE CASCADE +); + +-- 성능을 위한 인덱스 (필수) +CREATE INDEX idx_example_company_code ON example_table(company_code); + +-- 복합 유니크 제약조건 (중복 방지) +CREATE UNIQUE INDEX idx_example_unique +ON example_table(name, company_code); -- 회사별로 고유해야 하는 경우 +``` + +### 1.2 예외 테이블 (company_code 불필요) + +**⚠️ 유일한 예외: `company_mng` 테이블만 `company_code`가 없습니다.** + +이 테이블은 회사 정보를 저장하는 마스터 테이블이므로 예외입니다. + +**모든 다른 테이블은 예외 없이 `company_code`가 필수입니다:** + +- ✅ `user_info` → `company_code` 필수 (사용자는 특정 회사 소속) +- ✅ `menu_info` → `company_code` 필수 (회사별 메뉴 설정 가능) +- ✅ `system_config` → `company_code` 필수 (회사별 시스템 설정) +- ✅ `audit_log` → `company_code` 필수 (회사별 감사 로그) +- ✅ 모든 비즈니스 테이블 → `company_code` 필수 + +**새로운 테이블 생성 시 체크리스트:** + +- [ ] `company_mng` 테이블인가? → `company_code` 불필요 (유일한 예외) +- [ ] 그 외 모든 테이블 → `company_code` 필수 (예외 없음) +- [ ] `company_code` 없이 테이블을 만들려고 하는가? → 다시 생각하세요! + +--- + +## 2. 백엔드 API 구현 필수 사항 + +### 2.1 모든 데이터 조회 시 필터링 + +**절대 원칙: 모든 SELECT 쿼리는 company_code 필터링을 반드시 포함해야 합니다.** + +#### ✅ 올바른 방법 + +```typescript +async function getDataList(req: Request, res: Response) { + const companyCode = req.user!.companyCode; // 인증된 사용자의 회사 코드 + + let query: string; + let params: any[]; + + if (companyCode === "*") { + // 최고 관리자: 모든 회사 데이터 조회 가능 + query = ` + SELECT * FROM example_table + ORDER BY created_at DESC + `; + params = []; + logger.info("최고 관리자 전체 데이터 조회"); + } else { + // 일반 회사: 자신의 회사 데이터만 조회 + query = ` + SELECT * FROM example_table + WHERE company_code = $1 + ORDER BY created_at DESC + `; + params = [companyCode]; + logger.info("회사별 데이터 조회", { companyCode }); + } + + const result = await pool.query(query, params); + + return res.json({ success: true, data: result.rows }); +} +``` + +#### ❌ 잘못된 방법 - 절대 사용 금지 + +```typescript +// 🚨 치명적 보안 취약점: company_code 필터링 없음 +async function getDataList(req: Request, res: Response) { + const query = `SELECT * FROM example_table`; // 모든 회사 데이터 노출! + const result = await pool.query(query); + return res.json({ success: true, data: result.rows }); +} +``` + +### 2.2 데이터 생성 (INSERT) + +**모든 INSERT 쿼리는 company_code를 반드시 포함해야 합니다.** + +#### ✅ 올바른 방법 + +```typescript +async function createData(req: Request, res: Response) { + const companyCode = req.user!.companyCode; // 서버에서 확정 + const { name, description } = req.body; + + const query = ` + INSERT INTO example_table (company_code, name, description) + VALUES ($1, $2, $3) + RETURNING * + `; + + const result = await pool.query(query, [companyCode, name, description]); + + logger.info("데이터 생성", { + companyCode, + id: result.rows[0].id, + }); + + return res.json({ success: true, data: result.rows[0] }); +} +``` + +#### ❌ 클라이언트 입력 사용 금지 + +```typescript +// 🚨 보안 취약점: 클라이언트가 임의의 회사 코드 지정 가능 +async function createData(req: Request, res: Response) { + const { companyCode, name } = req.body; // 사용자가 다른 회사 코드 전달 가능! + const query = `INSERT INTO example_table (company_code, name) VALUES ($1, $2)`; + await pool.query(query, [companyCode, name]); +} +``` + +### 2.3 데이터 수정 (UPDATE) + +**WHERE 절에 company_code를 반드시 포함해야 합니다.** + +#### ✅ 올바른 방법 + +```typescript +async function updateData(req: Request, res: Response) { + const companyCode = req.user!.companyCode; + const { id } = req.params; + const { name, description } = req.body; + + let query: string; + let params: any[]; + + if (companyCode === "*") { + // 최고 관리자: 모든 데이터 수정 가능 + query = ` + UPDATE example_table + SET name = $1, description = $2, updated_at = NOW() + WHERE id = $3 + RETURNING * + `; + params = [name, description, id]; + } else { + // 일반 회사: 자신의 데이터만 수정 가능 + query = ` + UPDATE example_table + SET name = $1, description = $2, updated_at = NOW() + WHERE id = $3 AND company_code = $4 + RETURNING * + `; + params = [name, description, id, companyCode]; + } + + const result = await pool.query(query, params); + + if (result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "데이터를 찾을 수 없거나 권한이 없습니다", + }); + } + + logger.info("데이터 수정", { companyCode, id }); + + return res.json({ success: true, data: result.rows[0] }); +} +``` + +#### ❌ 잘못된 방법 + +```typescript +// 🚨 보안 취약점: 다른 회사의 같은 ID 데이터도 수정됨 +const query = ` + UPDATE example_table + SET name = $1, description = $2 + WHERE id = $3 +`; +``` + +### 2.4 데이터 삭제 (DELETE) + +**WHERE 절에 company_code를 반드시 포함해야 합니다.** + +#### ✅ 올바른 방법 + +```typescript +async function deleteData(req: Request, res: Response) { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + let query: string; + let params: any[]; + + if (companyCode === "*") { + // 최고 관리자: 모든 데이터 삭제 가능 + query = `DELETE FROM example_table WHERE id = $1 RETURNING id`; + params = [id]; + } else { + // 일반 회사: 자신의 데이터만 삭제 가능 + query = ` + DELETE FROM example_table + WHERE id = $1 AND company_code = $2 + RETURNING id + `; + params = [id, companyCode]; + } + + const result = await pool.query(query, params); + + if (result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "데이터를 찾을 수 없거나 권한이 없습니다", + }); + } + + logger.info("데이터 삭제", { companyCode, id }); + + return res.json({ success: true }); +} +``` + +--- + +## 3. company_code = "\*" 의 의미 + +### 3.1 최고 관리자 전용 데이터 + +**중요**: `company_code = "*"`는 **최고 관리자 전용 데이터**를 의미합니다. + +- ❌ 잘못된 이해: `company_code = "*"` = 모든 회사가 공유하는 공통 데이터 +- ✅ 올바른 이해: `company_code = "*"` = 최고 관리자만 관리하는 전용 데이터 + +### 3.2 데이터 격리 원칙 + +**회사별 데이터 접근 규칙:** + +| 사용자 유형 | company_code | 접근 가능한 데이터 | +| ----------- | ------------ | ---------------------------------------------- | +| 회사 A | `COMPANY_A` | `company_code = 'COMPANY_A'` 데이터만 | +| 회사 B | `COMPANY_B` | `company_code = 'COMPANY_B'` 데이터만 | +| 최고 관리자 | `*` | 모든 회사 데이터 + `company_code = '*'` 데이터 | + +**핵심**: + +- 일반 회사는 `company_code = "*"` 데이터를 **절대 볼 수 없음** +- 일반 회사는 다른 회사의 데이터를 **절대 볼 수 없음** +- 최고 관리자만 모든 데이터에 접근 가능 + +--- + +## 4. 복잡한 쿼리에서의 멀티테넌시 + +### 4.1 JOIN 쿼리 + +**모든 JOIN된 테이블에도 company_code 필터링을 적용해야 합니다.** + +#### ✅ 올바른 방법 + +```typescript +const query = ` + SELECT + a.*, + b.name as category_name, + c.name as user_name + FROM example_table a + LEFT JOIN category_table b + ON a.category_id = b.id + AND a.company_code = b.company_code -- ✅ JOIN 조건에도 company_code 필수 + LEFT JOIN user_info c + ON a.user_id = c.user_id + AND a.company_code = c.company_code + WHERE a.company_code = $1 +`; +``` + +#### ❌ 잘못된 방법 + +```typescript +// 🚨 보안 취약점: JOIN에서 다른 회사 데이터와 섞임 +const query = ` + SELECT + a.*, + b.name as category_name + FROM example_table a + LEFT JOIN category_table b ON a.category_id = b.id -- company_code 없음! + WHERE a.company_code = $1 +`; +``` + +### 4.2 서브쿼리 + +**모든 서브쿼리에도 company_code 필터링을 적용해야 합니다.** + +#### ✅ 올바른 방법 + +```typescript +const query = ` + SELECT * FROM example_table + WHERE category_id IN ( + SELECT id FROM category_table + WHERE active = true AND company_code = $1 -- ✅ + ) + AND company_code = $1 +`; +``` + +#### ❌ 잘못된 방법 + +```typescript +// 🚨 보안 취약점: 서브쿼리에서 company_code 누락 +const query = ` + SELECT * FROM example_table + WHERE category_id IN ( + SELECT id FROM category_table WHERE active = true -- company_code 없음! + ) + AND company_code = $1 +`; +``` + +### 4.3 집계 함수 (COUNT, SUM 등) + +**집계 함수도 company_code로 필터링해야 합니다.** + +#### ✅ 올바른 방법 + +```typescript +const query = ` + SELECT COUNT(*) as total + FROM example_table + WHERE company_code = $1 +`; +``` + +#### ❌ 잘못된 방법 + +```typescript +// 🚨 보안 취약점: 모든 회사의 총합 반환 +const query = `SELECT COUNT(*) as total FROM example_table`; +``` + +### 4.4 EXISTS 서브쿼리 + +```typescript +// ✅ 올바른 방법 +const query = ` + SELECT * FROM example_table a + WHERE EXISTS ( + SELECT 1 FROM related_table b + WHERE b.example_id = a.id + AND b.company_code = a.company_code -- ✅ 필수 + ) + AND a.company_code = $1 +`; +``` + +--- + +## 5. 자동 필터 시스템 (autoFilter) + +### 5.1 백엔드 구현 (이미 완료) + +백엔드에는 `autoFilter` 기능이 구현되어 있습니다: + +```typescript +// tableManagementController.ts +let enhancedSearch = { ...search }; +if (autoFilter?.enabled && req.user) { + const filterColumn = autoFilter.filterColumn || "company_code"; + const userField = autoFilter.userField || "companyCode"; + const userValue = (req.user as any)[userField]; + + if (userValue) { + enhancedSearch[filterColumn] = userValue; + logger.info("🔍 현재 사용자 필터 적용:", { + filterColumn, + userValue, + tableName, + }); + } +} +``` + +### 5.2 프론트엔드 사용 (필수) + +**모든 테이블 데이터 API 호출 시 `autoFilter`를 반드시 전달해야 합니다.** + +#### ✅ 올바른 방법 + +```typescript +// frontend/lib/api/screen.ts +const requestBody = { + ...params, + autoFilter: { + enabled: true, + filterColumn: "company_code", + userField: "companyCode", + }, +}; + +const response = await apiClient.post( + `/table-management/tables/${tableName}/data`, + requestBody +); +``` + +#### Entity 조인 API + +```typescript +// frontend/lib/api/entityJoin.ts +const autoFilter = { + enabled: true, + filterColumn: "company_code", + userField: "companyCode", +}; + +const response = await apiClient.get( + `/table-management/tables/${tableName}/data-with-joins`, + { + params: { + ...params, + autoFilter: JSON.stringify(autoFilter), + }, + } +); +``` + +--- + +## 6. 서비스 계층 패턴 + +### 6.1 표준 서비스 함수 패턴 + +**서비스 함수는 항상 companyCode를 첫 번째 파라미터로 받아야 합니다.** + +```typescript +class ExampleService { + async findAll(companyCode: string, filters?: any) { + let query: string; + let params: any[]; + + if (companyCode === "*") { + query = `SELECT * FROM example_table`; + params = []; + } else { + query = `SELECT * FROM example_table WHERE company_code = $1`; + params = [companyCode]; + } + + return await pool.query(query, params); + } + + async findById(companyCode: string, id: number) { + let query: string; + let params: any[]; + + if (companyCode === "*") { + query = `SELECT * FROM example_table WHERE id = $1`; + params = [id]; + } else { + query = `SELECT * FROM example_table WHERE id = $1 AND company_code = $2`; + params = [id, companyCode]; + } + + const result = await pool.query(query, params); + return result.rows[0]; + } + + async create(companyCode: string, data: any) { + const query = ` + INSERT INTO example_table (company_code, name, description) + VALUES ($1, $2, $3) + RETURNING * + `; + const result = await pool.query(query, [ + companyCode, + data.name, + data.description, + ]); + return result.rows[0]; + } +} + +// 컨트롤러에서 사용 +const exampleService = new ExampleService(); + +async function getDataList(req: Request, res: Response) { + const companyCode = req.user!.companyCode; + const data = await exampleService.findAll(companyCode, req.query); + return res.json({ success: true, data }); +} +``` + +--- + +## 7. 마이그레이션 체크리스트 + +### 7.1 새로운 테이블 생성 시 + +- [ ] `company_code VARCHAR(20) NOT NULL` 컬럼 추가 +- [ ] `company_mng` 테이블에 대한 외래키 제약조건 추가 +- [ ] `company_code`에 인덱스 생성 +- [ ] 복합 유니크 제약조건에 `company_code` 포함 +- [ ] 샘플 데이터에 올바른 `company_code` 값 포함 + +### 7.2 기존 테이블 마이그레이션 시 + +```sql +-- 1. company_code 컬럼 추가 +ALTER TABLE example_table ADD COLUMN company_code VARCHAR(20); + +-- 2. 기존 데이터를 모든 회사별로 복제 +INSERT INTO example_table (company_code, name, description, created_at) +SELECT ci.company_code, et.name, et.description, et.created_at +FROM (SELECT * FROM example_table WHERE company_code IS NULL) et +CROSS JOIN company_mng ci +WHERE NOT EXISTS ( + SELECT 1 FROM example_table et2 + WHERE et2.name = et.name + AND et2.company_code = ci.company_code +); + +-- 3. NULL 데이터 삭제 +DELETE FROM example_table WHERE company_code IS NULL; + +-- 4. NOT NULL 제약조건 +ALTER TABLE example_table ALTER COLUMN company_code SET NOT NULL; + +-- 5. 인덱스 및 외래키 +CREATE INDEX idx_example_company ON example_table(company_code); +ALTER TABLE example_table +ADD CONSTRAINT fk_example_company +FOREIGN KEY (company_code) REFERENCES company_mng(company_code) +ON DELETE CASCADE ON UPDATE CASCADE; +``` + +--- + +## 8. 테스트 체크리스트 + +### 8.1 필수 테스트 시나리오 + +**모든 새로운 API는 다음 테스트를 통과해야 합니다:** + +- [ ] **회사 A 테스트**: 회사 A로 로그인하여 회사 A 데이터만 보이는지 확인 +- [ ] **회사 B 테스트**: 회사 B로 로그인하여 회사 B 데이터만 보이는지 확인 +- [ ] **격리 테스트**: 회사 A로 로그인하여 회사 B 데이터에 접근 불가능한지 확인 +- [ ] **최고 관리자 테스트**: 최고 관리자로 로그인하여 모든 데이터가 보이는지 확인 +- [ ] **수정 권한 테스트**: 회사 A가 회사 B의 데이터를 수정할 수 없는지 확인 +- [ ] **삭제 권한 테스트**: 회사 A가 회사 B의 데이터를 삭제할 수 없는지 확인 + +### 8.2 SQL 인젝션 테스트 + +```typescript +// company_code를 URL 파라미터로 전달하려는 시도 차단 +// ❌ 이런 요청을 받아서는 안 됨 +GET /api/data?company_code=COMPANY_B + +// ✅ company_code는 항상 req.user에서 가져와야 함 +const companyCode = req.user!.companyCode; +``` + +--- + +## 9. 감사 로그 (Audit Log) + +### 9.1 모든 중요 작업에 로깅 + +```typescript +logger.info("데이터 생성", { + companyCode: req.user!.companyCode, + userId: req.user!.userId, + tableName: "example_table", + action: "INSERT", + recordId: result.rows[0].id, +}); + +logger.warn("권한 없는 접근 시도", { + companyCode: req.user!.companyCode, + userId: req.user!.userId, + attemptedRecordId: req.params.id, + message: "다른 회사의 데이터 접근 시도", +}); +``` + +### 9.2 감사 로그 테이블 구조 + +```sql +CREATE TABLE audit_log ( + id SERIAL PRIMARY KEY, + company_code VARCHAR(20) NOT NULL, + user_id VARCHAR(100) NOT NULL, + action VARCHAR(50) NOT NULL, + table_name VARCHAR(100), + record_id VARCHAR(100), + old_value JSONB, + new_value JSONB, + ip_address INET, + user_agent TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX idx_audit_company ON audit_log(company_code); +CREATE INDEX idx_audit_action ON audit_log(action, created_at); +``` + +--- + +## 10. 보안 체크리스트 (코드 리뷰 시 필수) + +### 10.1 백엔드 API 체크리스트 + +- [ ] 모든 SELECT 쿼리에 `WHERE company_code = $1` 포함 (최고 관리자 예외) +- [ ] 모든 INSERT 쿼리에 `company_code` 컬럼 포함 +- [ ] 모든 UPDATE/DELETE 쿼리의 WHERE 절에 `company_code` 조건 포함 +- [ ] JOIN 쿼리의 ON 절에 `company_code` 매칭 조건 포함 +- [ ] 서브쿼리에 `company_code` 필터링 포함 +- [ ] 집계 함수에 `company_code` 필터링 포함 +- [ ] `req.user.companyCode` 사용 (클라이언트 입력 사용 금지) +- [ ] 최고 관리자(`company_code = "*"`) 예외 처리 +- [ ] 로그에 `companyCode` 정보 포함 +- [ ] 권한 없음 시 404 또는 403 반환 + +### 10.2 프론트엔드 체크리스트 + +- [ ] 모든 테이블 데이터 API 호출 시 `autoFilter` 전달 +- [ ] `company_code`를 직접 전달하지 않음 (백엔드에서 자동 처리) +- [ ] 에러 발생 시 적절한 메시지 표시 + +### 10.3 데이터베이스 체크리스트 + +- [ ] 모든 비즈니스 테이블에 `company_code` 컬럼 존재 +- [ ] `company_code`에 NOT NULL 제약조건 적용 +- [ ] `company_code`에 인덱스 생성 +- [ ] 외래키 제약조건으로 `company_mng` 참조 +- [ ] 복합 유니크 제약조건에 `company_code` 포함 + +--- + +## 11. 일반적인 실수와 해결방법 + +### 실수 1: 서브쿼리에서 company_code 누락 + +```typescript +// ❌ 잘못된 방법 +const query = ` + SELECT * FROM example_table + WHERE category_id IN ( + SELECT id FROM category_table WHERE active = true + ) + AND company_code = $1 +`; + +// ✅ 올바른 방법 +const query = ` + SELECT * FROM example_table + WHERE category_id IN ( + SELECT id FROM category_table + WHERE active = true AND company_code = $1 + ) + AND company_code = $1 +`; +``` + +### 실수 2: COUNT/SUM 집계 함수 + +```typescript +// ❌ 잘못된 방법 - 모든 회사의 총합 +const query = `SELECT COUNT(*) as total FROM example_table`; + +// ✅ 올바른 방법 +const query = ` + SELECT COUNT(*) as total + FROM example_table + WHERE company_code = $1 +`; +``` + +### 실수 3: autoFilter 누락 + +```typescript +// ❌ 잘못된 방법 +const response = await apiClient.post( + `/table-management/tables/${tableName}/data`, + { + page, + size, + search, + } +); + +// ✅ 올바른 방법 +const response = await apiClient.post( + `/table-management/tables/${tableName}/data`, + { + page, + size, + search, + autoFilter: { + enabled: true, + filterColumn: "company_code", + userField: "companyCode", + }, + } +); +``` + +--- + +## 12. 참고 자료 + +### 완료된 구현 예시 + +- **테이블 데이터 API**: `backend-node/src/controllers/tableManagementController.ts` (getTableData) +- **Entity 조인 API**: `backend-node/src/controllers/entityJoinController.ts` (getTableDataWithJoins) +- **카테고리 값 API**: `backend-node/src/services/tableCategoryValueService.ts` (getCategoryValues) +- **프론트엔드 API**: `frontend/lib/api/screen.ts` (getTableData) +- **프론트엔드 Entity 조인**: `frontend/lib/api/entityJoin.ts` (getTableDataWithJoins) + +### 마이그레이션 스크립트 + +- `db/migrations/044_simple_version.sql` - table_type_columns에 company_code 추가 +- `db/migrations/045_add_company_code_to_category_values.sql` - 카테고리 값 테이블 마이그레이션 + +--- + +## 요약: 절대 잊지 말아야 할 핵심 규칙 + +### 데이터베이스 + +1. **모든 테이블에 `company_code` 필수** (`company_mng` 제외) +2. **인덱스와 외래키 필수** +3. **복합 유니크 제약조건에 `company_code` 포함** + +### 백엔드 API + +1. **모든 SELECT 쿼리**: `WHERE company_code = $1` (최고 관리자 제외) +2. **모든 INSERT 쿼리**: `company_code` 컬럼 포함 +3. **모든 UPDATE/DELETE 쿼리**: WHERE 절에 `company_code` 조건 포함 +4. **JOIN/서브쿼리/집계**: 모두 `company_code` 필터링 필수 + +### 프론트엔드 + +1. **모든 테이블 데이터 API 호출**: `autoFilter` 전달 필수 +2. **`company_code`를 직접 전달 금지**: 백엔드에서 자동 처리 + +--- + +**🚨 멀티테넌시는 보안의 핵심입니다. 예외 없이 모든 규칙을 준수하세요!** + +**⚠️ company_code = "\*"는 공통 데이터가 아닌 최고 관리자 전용 데이터입니다!** + +**✅ 모든 테이블에 company_code 필수! (company_mng 제외)** + +--- + +## 🤖 AI 에이전트 필수 체크리스트 + +**모든 코드 작성/수정 완료 후 반드시 다음을 확인하세요:** + +### 데이터베이스 마이그레이션을 작성했다면: + +- [ ] `company_code VARCHAR(20) NOT NULL` 컬럼 추가했는가? +- [ ] `company_code`에 인덱스를 생성했는가? +- [ ] `company_mng` 테이블에 대한 외래키를 추가했는가? +- [ ] 복합 유니크 제약조건에 `company_code`를 포함했는가? +- [ ] 기존 데이터를 모든 회사별로 복제했는가? + +### 백엔드 API를 작성/수정했다면: + +- [ ] SELECT 쿼리에 `WHERE company_code = $1` 조건이 있는가? (최고 관리자 제외) +- [ ] INSERT 쿼리에 `company_code` 컬럼이 포함되어 있는가? +- [ ] UPDATE/DELETE 쿼리의 WHERE 절에 `company_code` 조건이 있는가? +- [ ] JOIN 쿼리의 ON 절에 `company_code` 매칭 조건이 있는가? +- [ ] 서브쿼리에 `company_code` 필터링이 있는가? +- [ ] 집계 함수(COUNT, SUM 등)에 `company_code` 필터링이 있는가? +- [ ] `req.user.companyCode`를 사용하고 있는가? (클라이언트 입력 사용 금지) +- [ ] 최고 관리자(`company_code = "*"`) 예외 처리를 했는가? +- [ ] 로그에 `companyCode` 정보를 포함했는가? +- [ ] 권한 없음 시 적절한 HTTP 상태 코드(404/403)를 반환하는가? + +### 프론트엔드 API 호출을 작성/수정했다면: + +- [ ] `autoFilter` 옵션을 전달하고 있는가? +- [ ] `autoFilter.enabled = true`로 설정했는가? +- [ ] `autoFilter.filterColumn = "company_code"`로 설정했는가? +- [ ] `autoFilter.userField = "companyCode"`로 설정했는가? +- [ ] `company_code`를 직접 전달하지 않았는가? (백엔드 자동 처리) + +### 테스트를 수행했다면: + +- [ ] 회사 A로 로그인하여 회사 A 데이터만 보이는지 확인했는가? +- [ ] 회사 B로 로그인하여 회사 B 데이터만 보이는지 확인했는가? +- [ ] 회사 A가 회사 B 데이터에 접근할 수 없는지 확인했는가? +- [ ] 최고 관리자로 로그인하여 모든 데이터가 보이는지 확인했는가? + +--- + +**⚠️ 위 체크리스트 중 하나라도 "아니오"가 있다면, 코드를 다시 검토하세요!** + +**🚨 멀티테넌시 위반은 치명적인 보안 취약점입니다!** diff --git a/.cursorrules b/.cursorrules index abbc2994..3b0c3833 100644 --- a/.cursorrules +++ b/.cursorrules @@ -1,5 +1,23 @@ # Cursor Rules for ERP-node Project +## 🚨 최우선 보안 규칙: 멀티테넌시 + +**모든 코드 작성/수정 완료 후 반드시 다음 파일을 확인하세요:** +- [멀티테넌시 필수 구현 가이드](.cursor/rules/multi-tenancy-guide.mdc) + +**AI 에이전트는 다음 상황에서 반드시 멀티테넌시 체크리스트를 확인해야 합니다:** +1. 데이터베이스 마이그레이션 작성 시 +2. 백엔드 API (SELECT/INSERT/UPDATE/DELETE) 작성/수정 시 +3. 프론트엔드 데이터 API 호출 작성/수정 시 +4. 테스트 완료 시 + +**핵심 원칙:** +- ✅ 모든 테이블에 `company_code` 필수 (company_mng 제외) +- ✅ 모든 쿼리에 `company_code` 필터링 필수 +- ✅ 프론트엔드 API 호출 시 `autoFilter` 전달 필수 + +--- + ## shadcn/ui 웹 스타일 가이드라인 모든 프론트엔드 개발 시 다음 shadcn/ui 기반 스타일 가이드라인을 준수해야 합니다. diff --git a/backend-node/src/controllers/entityJoinController.ts b/backend-node/src/controllers/entityJoinController.ts index 77fdb0dd..8649cea5 100644 --- a/backend-node/src/controllers/entityJoinController.ts +++ b/backend-node/src/controllers/entityJoinController.ts @@ -27,6 +27,7 @@ export class EntityJoinController { enableEntityJoin = true, additionalJoinColumns, // 추가 조인 컬럼 정보 (JSON 문자열) screenEntityConfigs, // 화면별 엔티티 설정 (JSON 문자열) + autoFilter, // 🔒 멀티테넌시 자동 필터 userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함 ...otherParams } = req.query; @@ -36,6 +37,7 @@ export class EntityJoinController { size, enableEntityJoin, search, + autoFilter, }); // 검색 조건 처리 @@ -51,6 +53,31 @@ export class EntityJoinController { } } + // 🔒 멀티테넌시: 자동 필터 처리 + if (autoFilter) { + try { + const parsedAutoFilter = + typeof autoFilter === "string" ? JSON.parse(autoFilter) : autoFilter; + + if (parsedAutoFilter.enabled && (req as any).user) { + const filterColumn = parsedAutoFilter.filterColumn || "company_code"; + const userField = parsedAutoFilter.userField || "companyCode"; + const userValue = ((req as any).user as any)[userField]; + + if (userValue) { + searchConditions[filterColumn] = userValue; + logger.info("🔒 Entity 조인에 멀티테넌시 필터 적용:", { + filterColumn, + userValue, + tableName, + }); + } + } + } catch (error) { + logger.warn("자동 필터 파싱 오류:", error); + } + } + // 추가 조인 컬럼 정보 처리 let parsedAdditionalJoinColumns: any[] = []; if (additionalJoinColumns) { diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 9661ab0a..c4c29503 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -62,9 +62,23 @@ export async function getColumnList( try { const { tableName } = req.params; const { page = 1, size = 50 } = req.query; + + // 🔥 회사 코드 추출 (JWT에서 또는 DB에서 조회) + let companyCode = req.user?.companyCode; + + if (!companyCode && req.user?.userId) { + // JWT에 없으면 DB에서 조회 + const { query } = require("../database/db"); + const userResult = await query( + `SELECT company_code FROM user_info WHERE user_id = $1`, + [req.user.userId] + ); + companyCode = userResult[0]?.company_code; + logger.info(`DB에서 회사 코드 조회 (컬럼 목록): ${req.user.userId} → ${companyCode}`); + } logger.info( - `=== 컬럼 정보 조회 시작: ${tableName} (page: ${page}, size: ${size}) ===` + `=== 컬럼 정보 조회 시작: ${tableName} (page: ${page}, size: ${size}), company: ${companyCode} ===` ); if (!tableName) { @@ -84,7 +98,8 @@ export async function getColumnList( const result = await tableManagementService.getColumnList( tableName, parseInt(page as string), - parseInt(size as string) + parseInt(size as string), + companyCode // 🔥 회사 코드 전달 ); logger.info( @@ -124,8 +139,22 @@ export async function updateColumnSettings( try { const { tableName, columnName } = req.params; const settings: ColumnSettings = req.body; + + // 🔥 회사 코드 추출 (JWT에서 또는 DB에서 조회) + let companyCode = req.user?.companyCode; + + if (!companyCode && req.user?.userId) { + // JWT에 없으면 DB에서 조회 + const { query } = require("../database/db"); + const userResult = await query( + `SELECT company_code FROM user_info WHERE user_id = $1`, + [req.user.userId] + ); + companyCode = userResult[0]?.company_code; + logger.info(`DB에서 회사 코드 조회: ${req.user.userId} → ${companyCode}`); + } - logger.info(`=== 컬럼 설정 업데이트 시작: ${tableName}.${columnName} ===`); + logger.info(`=== 컬럼 설정 업데이트 시작: ${tableName}.${columnName}, company: ${companyCode} ===`); if (!tableName || !columnName) { const response: ApiResponse = { @@ -153,14 +182,34 @@ export async function updateColumnSettings( return; } + if (!companyCode) { + logger.error(`회사 코드 누락: ${tableName}.${columnName}`, { + user: req.user, + hasUser: !!req.user, + userId: req.user?.userId, + companyCodeFromJWT: req.user?.companyCode, + }); + const response: ApiResponse = { + success: false, + message: "회사 코드를 찾을 수 없습니다.", + error: { + code: "MISSING_COMPANY_CODE", + details: "사용자 정보에서 회사 코드를 찾을 수 없습니다. 관리자에게 문의하세요.", + }, + }; + res.status(400).json(response); + return; + } + const tableManagementService = new TableManagementService(); await tableManagementService.updateColumnSettings( tableName, columnName, - settings + settings, + companyCode // 🔥 회사 코드 전달 ); - logger.info(`컬럼 설정 업데이트 완료: ${tableName}.${columnName}`); + logger.info(`컬럼 설정 업데이트 완료: ${tableName}.${columnName}, company: ${companyCode}`); const response: ApiResponse = { success: true, @@ -194,8 +243,28 @@ export async function updateAllColumnSettings( try { const { tableName } = req.params; const columnSettings: ColumnSettings[] = req.body; + + // 🔥 회사 코드 추출 (JWT에서 또는 DB에서 조회) + let companyCode = req.user?.companyCode; + + if (!companyCode && req.user?.userId) { + // JWT에 없으면 DB에서 조회 + const { query } = require("../database/db"); + const userResult = await query( + `SELECT company_code FROM user_info WHERE user_id = $1`, + [req.user.userId] + ); + companyCode = userResult[0]?.company_code; + logger.info(`DB에서 회사 코드 조회: ${req.user.userId} → ${companyCode}`); + } - logger.info(`=== 전체 컬럼 설정 일괄 업데이트 시작: ${tableName} ===`); + // 🔍 디버깅: 사용자 정보 출력 + logger.info(`[DEBUG] req.user:`, JSON.stringify(req.user, null, 2)); + logger.info(`[DEBUG] req.user?.companyCode: ${req.user?.companyCode}`); + logger.info(`[DEBUG] req.user?.userId: ${req.user?.userId}`); + logger.info(`[DEBUG] companyCode 최종값: ${companyCode}`); + + logger.info(`=== 전체 컬럼 설정 일괄 업데이트 시작: ${tableName}, company: ${companyCode} ===`); if (!tableName) { const response: ApiResponse = { @@ -223,14 +292,35 @@ export async function updateAllColumnSettings( return; } + if (!companyCode) { + logger.error(`회사 코드 누락 (일괄 업데이트): ${tableName}`, { + user: req.user, + hasUser: !!req.user, + userId: req.user?.userId, + companyCodeFromJWT: req.user?.companyCode, + settingsCount: columnSettings.length, + }); + const response: ApiResponse = { + success: false, + message: "회사 코드를 찾을 수 없습니다.", + error: { + code: "MISSING_COMPANY_CODE", + details: "사용자 정보에서 회사 코드를 찾을 수 없습니다. 관리자에게 문의하세요.", + }, + }; + res.status(400).json(response); + return; + } + const tableManagementService = new TableManagementService(); await tableManagementService.updateAllColumnSettings( tableName, - columnSettings + columnSettings, + companyCode // 🔥 회사 코드 전달 ); logger.info( - `전체 컬럼 설정 일괄 업데이트 완료: ${tableName}, ${columnSettings.length}개` + `전체 컬럼 설정 일괄 업데이트 완료: ${tableName}, ${columnSettings.length}개, company: ${companyCode}` ); const response: ApiResponse = { @@ -453,9 +543,23 @@ export async function updateColumnInputType( try { const { tableName, columnName } = req.params; const { inputType, detailSettings } = req.body; + + // 🔥 회사 코드 추출 (JWT에서 또는 DB에서 조회) + let companyCode = req.user?.companyCode; + + if (!companyCode && req.user?.userId) { + // JWT에 없으면 DB에서 조회 + const { query } = require("../database/db"); + const userResult = await query( + `SELECT company_code FROM user_info WHERE user_id = $1`, + [req.user.userId] + ); + companyCode = userResult[0]?.company_code; + logger.info(`DB에서 회사 코드 조회: ${req.user.userId} → ${companyCode}`); + } logger.info( - `=== 컬럼 입력 타입 설정 시작: ${tableName}.${columnName} = ${inputType} ===` + `=== 컬럼 입력 타입 설정 시작: ${tableName}.${columnName} = ${inputType}, company: ${companyCode} ===` ); if (!tableName || !columnName || !inputType) { @@ -471,16 +575,37 @@ export async function updateColumnInputType( return; } + if (!companyCode) { + logger.error(`회사 코드 누락 (입력 타입): ${tableName}.${columnName}`, { + user: req.user, + hasUser: !!req.user, + userId: req.user?.userId, + companyCodeFromJWT: req.user?.companyCode, + inputType, + }); + const response: ApiResponse = { + success: false, + message: "회사 코드를 찾을 수 없습니다.", + error: { + code: "MISSING_COMPANY_CODE", + details: "사용자 정보에서 회사 코드를 찾을 수 없습니다. 관리자에게 문의하세요.", + }, + }; + res.status(400).json(response); + return; + } + const tableManagementService = new TableManagementService(); await tableManagementService.updateColumnInputType( tableName, columnName, inputType, + companyCode, detailSettings ); logger.info( - `컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${inputType}` + `컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${inputType}, company: ${companyCode}` ); const response: ApiResponse = { @@ -960,7 +1085,24 @@ export async function getColumnWebTypes( ): Promise { try { const { tableName } = req.params; - logger.info(`=== 컬럼 웹타입 정보 조회 시작: ${tableName} ===`); + + // 🔥 회사 코드 추출 (JWT에서 또는 DB에서 조회) + let companyCode = req.user?.companyCode; + + if (!companyCode && req.user?.userId) { + // JWT에 없으면 DB에서 조회 + const { query } = require("../database/db"); + const userResult = await query( + `SELECT company_code FROM user_info WHERE user_id = $1`, + [req.user.userId] + ); + companyCode = userResult[0]?.company_code; + logger.info(`DB에서 회사 코드 조회 (조회): ${req.user.userId} → ${companyCode}`); + } + + logger.info( + `=== 컬럼 웹타입 정보 조회 시작: ${tableName}, company: ${companyCode} ===` + ); if (!tableName) { const response: ApiResponse = { @@ -975,12 +1117,33 @@ export async function getColumnWebTypes( return; } + if (!companyCode) { + logger.error(`회사 코드 누락 (조회): ${tableName}`, { + user: req.user, + hasUser: !!req.user, + userId: req.user?.userId, + companyCodeFromJWT: req.user?.companyCode, + }); + const response: ApiResponse = { + success: false, + message: "회사 코드를 찾을 수 없습니다.", + error: { + code: "MISSING_COMPANY_CODE", + details: "사용자 정보에서 회사 코드를 찾을 수 없습니다. 관리자에게 문의하세요.", + }, + }; + res.status(400).json(response); + return; + } + const tableManagementService = new TableManagementService(); - const inputTypes = - await tableManagementService.getColumnInputTypes(tableName); + const inputTypes = await tableManagementService.getColumnInputTypes( + tableName, + companyCode + ); logger.info( - `컬럼 입력타입 정보 조회 완료: ${tableName}, ${inputTypes.length}개 컬럼` + `컬럼 입력타입 정보 조회 완료: ${tableName}, company: ${companyCode}, ${inputTypes.length}개 컬럼` ); const response: ApiResponse = { diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index 9840c9c4..0ec8c162 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -27,8 +27,8 @@ import { const router = express.Router(); -// 모든 라우트에 인증 미들웨어 적용 (테스트 시에는 주석 처리) -// router.use(authenticateToken); +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); /** * 테이블 목록 조회 diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index cad0727e..0c612b51 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -42,48 +42,100 @@ class NumberingRuleService { logger.info("채번 규칙 목록 조회 시작", { companyCode }); const pool = getPool(); - const query = ` - SELECT - rule_id AS "ruleId", - rule_name AS "ruleName", - description, - separator, - reset_period AS "resetPeriod", - current_sequence AS "currentSequence", - table_name AS "tableName", - column_name AS "columnName", - company_code AS "companyCode", - menu_objid AS "menuObjid", - scope_type AS "scopeType", - created_at AS "createdAt", - updated_at AS "updatedAt", - created_by AS "createdBy" - FROM numbering_rules - WHERE company_code = $1 OR company_code = '*' - ORDER BY created_at DESC - `; + + // 멀티테넌시: 최고 관리자만 company_code="*" 데이터를 볼 수 있음 + let query: string; + let params: any[]; + + if (companyCode === "*") { + // 최고 관리자: 모든 회사 데이터 조회 가능 + query = ` + SELECT + rule_id AS "ruleId", + rule_name AS "ruleName", + description, + separator, + reset_period AS "resetPeriod", + current_sequence AS "currentSequence", + table_name AS "tableName", + column_name AS "columnName", + company_code AS "companyCode", + menu_objid AS "menuObjid", + scope_type AS "scopeType", + created_at AS "createdAt", + updated_at AS "updatedAt", + created_by AS "createdBy" + FROM numbering_rules + ORDER BY created_at DESC + `; + params = []; + logger.info("최고 관리자 전체 채번 규칙 조회"); + } else { + // 일반 회사: 자신의 회사 데이터만 조회 (company_code="*" 제외) + query = ` + SELECT + rule_id AS "ruleId", + rule_name AS "ruleName", + description, + separator, + reset_period AS "resetPeriod", + current_sequence AS "currentSequence", + table_name AS "tableName", + column_name AS "columnName", + company_code AS "companyCode", + menu_objid AS "menuObjid", + scope_type AS "scopeType", + created_at AS "createdAt", + updated_at AS "updatedAt", + created_by AS "createdBy" + FROM numbering_rules + WHERE company_code = $1 + ORDER BY created_at DESC + `; + params = [companyCode]; + logger.info("회사별 채번 규칙 조회", { companyCode }); + } - const result = await pool.query(query, [companyCode]); + const result = await pool.query(query, params); // 각 규칙의 파트 정보 조회 for (const rule of result.rows) { - const partsQuery = ` - SELECT - id, - part_order AS "order", - part_type AS "partType", - generation_method AS "generationMethod", - auto_config AS "autoConfig", - manual_config AS "manualConfig" - FROM numbering_rule_parts - WHERE rule_id = $1 AND (company_code = $2 OR company_code = '*') - ORDER BY part_order - `; + let partsQuery: string; + let partsParams: any[]; + + if (companyCode === "*") { + // 최고 관리자: 모든 파트 조회 + partsQuery = ` + SELECT + id, + part_order AS "order", + part_type AS "partType", + generation_method AS "generationMethod", + auto_config AS "autoConfig", + manual_config AS "manualConfig" + FROM numbering_rule_parts + WHERE rule_id = $1 + ORDER BY part_order + `; + partsParams = [rule.ruleId]; + } else { + // 일반 회사: 자신의 파트만 조회 + partsQuery = ` + SELECT + id, + part_order AS "order", + part_type AS "partType", + generation_method AS "generationMethod", + auto_config AS "autoConfig", + manual_config AS "manualConfig" + FROM numbering_rule_parts + WHERE rule_id = $1 AND company_code = $2 + ORDER BY part_order + `; + partsParams = [rule.ruleId, companyCode]; + } - const partsResult = await pool.query(partsQuery, [ - rule.ruleId, - companyCode, - ]); + const partsResult = await pool.query(partsQuery, partsParams); rule.parts = partsResult.rows; } @@ -114,49 +166,95 @@ class NumberingRuleService { // menuObjid가 없으면 global 규칙만 반환 if (!menuObjid) { - const query = ` - SELECT - rule_id AS "ruleId", - rule_name AS "ruleName", - description, - separator, - reset_period AS "resetPeriod", - current_sequence AS "currentSequence", - table_name AS "tableName", - column_name AS "columnName", - company_code AS "companyCode", - menu_objid AS "menuObjid", - scope_type AS "scopeType", - created_at AS "createdAt", - updated_at AS "updatedAt", - created_by AS "createdBy" - FROM numbering_rules - WHERE (company_code = $1 OR company_code = '*') - AND scope_type = 'global' - ORDER BY created_at DESC - `; + let query: string; + let params: any[]; + + if (companyCode === "*") { + // 최고 관리자: 모든 global 규칙 조회 + query = ` + SELECT + rule_id AS "ruleId", + rule_name AS "ruleName", + description, + separator, + reset_period AS "resetPeriod", + current_sequence AS "currentSequence", + table_name AS "tableName", + column_name AS "columnName", + company_code AS "companyCode", + menu_objid AS "menuObjid", + scope_type AS "scopeType", + created_at AS "createdAt", + updated_at AS "updatedAt", + created_by AS "createdBy" + FROM numbering_rules + WHERE scope_type = 'global' + ORDER BY created_at DESC + `; + params = []; + } else { + // 일반 회사: 자신의 global 규칙만 조회 (company_code="*" 제외) + query = ` + SELECT + rule_id AS "ruleId", + rule_name AS "ruleName", + description, + separator, + reset_period AS "resetPeriod", + current_sequence AS "currentSequence", + table_name AS "tableName", + column_name AS "columnName", + company_code AS "companyCode", + menu_objid AS "menuObjid", + scope_type AS "scopeType", + created_at AS "createdAt", + updated_at AS "updatedAt", + created_by AS "createdBy" + FROM numbering_rules + WHERE company_code = $1 AND scope_type = 'global' + ORDER BY created_at DESC + `; + params = [companyCode]; + } - const result = await pool.query(query, [companyCode]); + const result = await pool.query(query, params); // 파트 정보 추가 for (const rule of result.rows) { - const partsQuery = ` - SELECT - id, - part_order AS "order", - part_type AS "partType", - generation_method AS "generationMethod", - auto_config AS "autoConfig", - manual_config AS "manualConfig" - FROM numbering_rule_parts - WHERE rule_id = $1 AND (company_code = $2 OR company_code = '*') - ORDER BY part_order - `; + let partsQuery: string; + let partsParams: any[]; + + if (companyCode === "*") { + partsQuery = ` + SELECT + id, + part_order AS "order", + part_type AS "partType", + generation_method AS "generationMethod", + auto_config AS "autoConfig", + manual_config AS "manualConfig" + FROM numbering_rule_parts + WHERE rule_id = $1 + ORDER BY part_order + `; + partsParams = [rule.ruleId]; + } else { + partsQuery = ` + SELECT + id, + part_order AS "order", + part_type AS "partType", + generation_method AS "generationMethod", + auto_config AS "autoConfig", + manual_config AS "manualConfig" + FROM numbering_rule_parts + WHERE rule_id = $1 AND company_code = $2 + ORDER BY part_order + `; + partsParams = [rule.ruleId, companyCode]; + } - const partsResult = await pool.query(partsQuery, [ - rule.ruleId, - companyCode, - ]); + const partsResult = await pool.query(partsQuery, partsParams); rule.parts = partsResult.rows; } @@ -186,53 +284,102 @@ class NumberingRuleService { const level2MenuObjid = hierarchyResult.rowCount > 0 ? hierarchyResult.rows[0].objid : null; - // 사용 가능한 규칙 조회 - const query = ` - SELECT - rule_id AS "ruleId", - rule_name AS "ruleName", - description, - separator, - reset_period AS "resetPeriod", - current_sequence AS "currentSequence", - table_name AS "tableName", - column_name AS "columnName", - company_code AS "companyCode", - menu_objid AS "menuObjid", - scope_type AS "scopeType", - created_at AS "createdAt", - updated_at AS "updatedAt", - created_by AS "createdBy" - FROM numbering_rules - WHERE (company_code = $1 OR company_code = '*') - AND ( + // 사용 가능한 규칙 조회 (멀티테넌시 적용) + let query: string; + let params: any[]; + + if (companyCode === "*") { + // 최고 관리자: 모든 규칙 조회 + query = ` + SELECT + rule_id AS "ruleId", + rule_name AS "ruleName", + description, + separator, + reset_period AS "resetPeriod", + current_sequence AS "currentSequence", + table_name AS "tableName", + column_name AS "columnName", + company_code AS "companyCode", + menu_objid AS "menuObjid", + scope_type AS "scopeType", + created_at AS "createdAt", + updated_at AS "updatedAt", + created_by AS "createdBy" + FROM numbering_rules + WHERE scope_type = 'global' - OR (scope_type = 'menu' AND menu_objid = $2) - ) - ORDER BY scope_type DESC, created_at DESC - `; + OR (scope_type = 'menu' AND menu_objid = $1) + ORDER BY scope_type DESC, created_at DESC + `; + params = [level2MenuObjid]; + } else { + // 일반 회사: 자신의 규칙만 조회 + query = ` + SELECT + rule_id AS "ruleId", + rule_name AS "ruleName", + description, + separator, + reset_period AS "resetPeriod", + current_sequence AS "currentSequence", + table_name AS "tableName", + column_name AS "columnName", + company_code AS "companyCode", + menu_objid AS "menuObjid", + scope_type AS "scopeType", + created_at AS "createdAt", + updated_at AS "updatedAt", + created_by AS "createdBy" + FROM numbering_rules + WHERE company_code = $1 + AND ( + scope_type = 'global' + OR (scope_type = 'menu' AND menu_objid = $2) + ) + ORDER BY scope_type DESC, created_at DESC + `; + params = [companyCode, level2MenuObjid]; + } - const result = await pool.query(query, [companyCode, level2MenuObjid]); + const result = await pool.query(query, params); // 파트 정보 추가 for (const rule of result.rows) { - const partsQuery = ` - SELECT - id, - part_order AS "order", - part_type AS "partType", - generation_method AS "generationMethod", - auto_config AS "autoConfig", - manual_config AS "manualConfig" - FROM numbering_rule_parts - WHERE rule_id = $1 AND (company_code = $2 OR company_code = '*') - ORDER BY part_order - `; + let partsQuery: string; + let partsParams: any[]; + + if (companyCode === "*") { + partsQuery = ` + SELECT + id, + part_order AS "order", + part_type AS "partType", + generation_method AS "generationMethod", + auto_config AS "autoConfig", + manual_config AS "manualConfig" + FROM numbering_rule_parts + WHERE rule_id = $1 + ORDER BY part_order + `; + partsParams = [rule.ruleId]; + } else { + partsQuery = ` + SELECT + id, + part_order AS "order", + part_type AS "partType", + generation_method AS "generationMethod", + auto_config AS "autoConfig", + manual_config AS "manualConfig" + FROM numbering_rule_parts + WHERE rule_id = $1 AND company_code = $2 + ORDER BY part_order + `; + partsParams = [rule.ruleId, companyCode]; + } - const partsResult = await pool.query(partsQuery, [ - rule.ruleId, - companyCode, - ]); + const partsResult = await pool.query(partsQuery, partsParams); rule.parts = partsResult.rows; } @@ -262,45 +409,97 @@ class NumberingRuleService { companyCode: string ): Promise { const pool = getPool(); - const query = ` - SELECT - rule_id AS "ruleId", - rule_name AS "ruleName", - description, - separator, - reset_period AS "resetPeriod", - current_sequence AS "currentSequence", - table_name AS "tableName", - column_name AS "columnName", - company_code AS "companyCode", - menu_objid AS "menuObjid", - scope_type AS "scopeType", - created_at AS "createdAt", - updated_at AS "updatedAt", - created_by AS "createdBy" - FROM numbering_rules - WHERE rule_id = $1 AND (company_code = $2 OR company_code = '*') - `; + + // 멀티테넌시: 최고 관리자만 company_code="*" 데이터를 볼 수 있음 + let query: string; + let params: any[]; + + if (companyCode === "*") { + // 최고 관리자: 모든 규칙 조회 가능 + query = ` + SELECT + rule_id AS "ruleId", + rule_name AS "ruleName", + description, + separator, + reset_period AS "resetPeriod", + current_sequence AS "currentSequence", + table_name AS "tableName", + column_name AS "columnName", + company_code AS "companyCode", + menu_objid AS "menuObjid", + scope_type AS "scopeType", + created_at AS "createdAt", + updated_at AS "updatedAt", + created_by AS "createdBy" + FROM numbering_rules + WHERE rule_id = $1 + `; + params = [ruleId]; + } else { + // 일반 회사: 자신의 규칙만 조회 + query = ` + SELECT + rule_id AS "ruleId", + rule_name AS "ruleName", + description, + separator, + reset_period AS "resetPeriod", + current_sequence AS "currentSequence", + table_name AS "tableName", + column_name AS "columnName", + company_code AS "companyCode", + menu_objid AS "menuObjid", + scope_type AS "scopeType", + created_at AS "createdAt", + updated_at AS "updatedAt", + created_by AS "createdBy" + FROM numbering_rules + WHERE rule_id = $1 AND company_code = $2 + `; + params = [ruleId, companyCode]; + } - const result = await pool.query(query, [ruleId, companyCode]); + const result = await pool.query(query, params); if (result.rowCount === 0) return null; const rule = result.rows[0]; - const partsQuery = ` - SELECT - id, - part_order AS "order", - part_type AS "partType", - generation_method AS "generationMethod", - auto_config AS "autoConfig", - manual_config AS "manualConfig" - FROM numbering_rule_parts - WHERE rule_id = $1 AND (company_code = $2 OR company_code = '*') - ORDER BY part_order - `; + // 파트 정보 조회 + let partsQuery: string; + let partsParams: any[]; + + if (companyCode === "*") { + partsQuery = ` + SELECT + id, + part_order AS "order", + part_type AS "partType", + generation_method AS "generationMethod", + auto_config AS "autoConfig", + manual_config AS "manualConfig" + FROM numbering_rule_parts + WHERE rule_id = $1 + ORDER BY part_order + `; + partsParams = [ruleId]; + } else { + partsQuery = ` + SELECT + id, + part_order AS "order", + part_type AS "partType", + generation_method AS "generationMethod", + auto_config AS "autoConfig", + manual_config AS "manualConfig" + FROM numbering_rule_parts + WHERE rule_id = $1 AND company_code = $2 + ORDER BY part_order + `; + partsParams = [ruleId, companyCode]; + } - const partsResult = await pool.query(partsQuery, [ruleId, companyCode]); + const partsResult = await pool.query(partsQuery, partsParams); rule.parts = partsResult.rows; return rule; diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index 5e91d332..8a20aac1 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -17,23 +17,50 @@ class TableCategoryValueService { logger.info("카테고리 컬럼 목록 조회", { tableName, companyCode }); const pool = getPool(); - const query = ` - SELECT - tc.table_name AS "tableName", - tc.column_name AS "columnName", - tc.column_name AS "columnLabel", - COUNT(cv.value_id) AS "valueCount" - FROM table_type_columns tc - LEFT JOIN table_column_category_values cv - ON tc.table_name = cv.table_name - AND tc.column_name = cv.column_name - AND cv.is_active = true - AND (cv.company_code = $2 OR cv.company_code = '*') - WHERE tc.table_name = $1 - AND tc.input_type = 'category' - GROUP BY tc.table_name, tc.column_name, tc.display_order - ORDER BY tc.display_order, tc.column_name - `; + + // 멀티테넌시: 최고 관리자만 company_code="*" 데이터를 볼 수 있음 + let query: string; + + if (companyCode === "*") { + // 최고 관리자: 모든 카테고리 값 조회 + query = ` + SELECT + tc.table_name AS "tableName", + tc.column_name AS "columnName", + tc.column_name AS "columnLabel", + COUNT(cv.value_id) AS "valueCount" + FROM table_type_columns tc + LEFT JOIN table_column_category_values cv + ON tc.table_name = cv.table_name + AND tc.column_name = cv.column_name + AND cv.is_active = true + WHERE tc.table_name = $1 + AND tc.input_type = 'category' + GROUP BY tc.table_name, tc.column_name, tc.display_order + ORDER BY tc.display_order, tc.column_name + `; + logger.info("최고 관리자 카테고리 컬럼 조회"); + } else { + // 일반 회사: 자신의 카테고리 값만 조회 + query = ` + SELECT + tc.table_name AS "tableName", + tc.column_name AS "columnName", + tc.column_name AS "columnLabel", + COUNT(cv.value_id) AS "valueCount" + FROM table_type_columns tc + LEFT JOIN table_column_category_values cv + ON tc.table_name = cv.table_name + AND tc.column_name = cv.column_name + AND cv.is_active = true + AND cv.company_code = $2 + WHERE tc.table_name = $1 + AND tc.input_type = 'category' + GROUP BY tc.table_name, tc.column_name, tc.display_order + ORDER BY tc.display_order, tc.column_name + `; + logger.info("회사별 카테고리 컬럼 조회", { companyCode }); + } const result = await pool.query(query, [tableName, companyCode]); @@ -67,33 +94,69 @@ class TableCategoryValueService { }); const pool = getPool(); - let query = ` - SELECT - value_id AS "valueId", - table_name AS "tableName", - column_name AS "columnName", - value_code AS "valueCode", - value_label AS "valueLabel", - value_order AS "valueOrder", - parent_value_id AS "parentValueId", - depth, - description, - color, - icon, - is_active AS "isActive", - is_default AS "isDefault", - company_code AS "companyCode", - created_at AS "createdAt", - updated_at AS "updatedAt", - created_by AS "createdBy", - updated_by AS "updatedBy" - FROM table_column_category_values - WHERE table_name = $1 - AND column_name = $2 - AND (company_code = $3 OR company_code = '*') - `; - - const params: any[] = [tableName, columnName, companyCode]; + + // 멀티테넌시: 최고 관리자만 company_code="*" 데이터를 볼 수 있음 + let query: string; + let params: any[]; + + if (companyCode === "*") { + // 최고 관리자: 모든 카테고리 값 조회 + query = ` + SELECT + value_id AS "valueId", + table_name AS "tableName", + column_name AS "columnName", + value_code AS "valueCode", + value_label AS "valueLabel", + value_order AS "valueOrder", + parent_value_id AS "parentValueId", + depth, + description, + color, + icon, + is_active AS "isActive", + is_default AS "isDefault", + company_code AS "companyCode", + created_at AS "createdAt", + updated_at AS "updatedAt", + created_by AS "createdBy", + updated_by AS "updatedBy" + FROM table_column_category_values + WHERE table_name = $1 + AND column_name = $2 + `; + params = [tableName, columnName]; + logger.info("최고 관리자 카테고리 값 조회"); + } else { + // 일반 회사: 자신의 카테고리 값만 조회 + query = ` + SELECT + value_id AS "valueId", + table_name AS "tableName", + column_name AS "columnName", + value_code AS "valueCode", + value_label AS "valueLabel", + value_order AS "valueOrder", + parent_value_id AS "parentValueId", + depth, + description, + color, + icon, + is_active AS "isActive", + is_default AS "isDefault", + company_code AS "companyCode", + created_at AS "createdAt", + updated_at AS "updatedAt", + created_by AS "createdBy", + updated_by AS "updatedBy" + FROM table_column_category_values + WHERE table_name = $1 + AND column_name = $2 + AND company_code = $3 + `; + params = [tableName, columnName, companyCode]; + logger.info("회사별 카테고리 값 조회", { companyCode }); + } if (!includeInactive) { query += ` AND is_active = true`; @@ -109,6 +172,7 @@ class TableCategoryValueService { logger.info(`카테고리 값 ${result.rows.length}개 조회 완료`, { tableName, columnName, + companyCode, }); return values; @@ -129,22 +193,34 @@ class TableCategoryValueService { const pool = getPool(); try { - // 중복 코드 체크 - const duplicateQuery = ` - SELECT value_id - FROM table_column_category_values - WHERE table_name = $1 - AND column_name = $2 - AND value_code = $3 - AND (company_code = $4 OR company_code = '*') - `; + // 중복 코드 체크 (멀티테넌시 적용) + let duplicateQuery: string; + let duplicateParams: any[]; + + if (companyCode === "*") { + // 최고 관리자: 모든 회사에서 중복 체크 + duplicateQuery = ` + SELECT value_id + FROM table_column_category_values + WHERE table_name = $1 + AND column_name = $2 + AND value_code = $3 + `; + duplicateParams = [value.tableName, value.columnName, value.valueCode]; + } else { + // 일반 회사: 자신의 회사에서만 중복 체크 + duplicateQuery = ` + SELECT value_id + FROM table_column_category_values + WHERE table_name = $1 + AND column_name = $2 + AND value_code = $3 + AND company_code = $4 + `; + duplicateParams = [value.tableName, value.columnName, value.valueCode, companyCode]; + } - const duplicateResult = await pool.query(duplicateQuery, [ - value.tableName, - value.columnName, - value.valueCode, - companyCode, - ]); + const duplicateResult = await pool.query(duplicateQuery, duplicateParams); if (duplicateResult.rows.length > 0) { throw new Error("이미 존재하는 코드입니다"); @@ -260,28 +336,57 @@ class TableCategoryValueService { setClauses.push(`updated_by = $${paramIndex++}`); values.push(userId); - values.push(valueId, companyCode); - - const updateQuery = ` - UPDATE table_column_category_values - SET ${setClauses.join(", ")} - WHERE value_id = $${paramIndex++} - AND (company_code = $${paramIndex++} OR company_code = '*') - RETURNING - value_id AS "valueId", - table_name AS "tableName", - column_name AS "columnName", - value_code AS "valueCode", - value_label AS "valueLabel", - value_order AS "valueOrder", - description, - color, - icon, - is_active AS "isActive", - is_default AS "isDefault", - updated_at AS "updatedAt", - updated_by AS "updatedBy" - `; + // 멀티테넌시: 최고 관리자는 company_code 조건 제외 + let updateQuery: string; + + if (companyCode === "*") { + // 최고 관리자: 모든 카테고리 값 수정 가능 + values.push(valueId); + updateQuery = ` + UPDATE table_column_category_values + SET ${setClauses.join(", ")} + WHERE value_id = $${paramIndex++} + RETURNING + value_id AS "valueId", + table_name AS "tableName", + column_name AS "columnName", + value_code AS "valueCode", + value_label AS "valueLabel", + value_order AS "valueOrder", + description, + color, + icon, + is_active AS "isActive", + is_default AS "isDefault", + company_code AS "companyCode", + updated_at AS "updatedAt", + updated_by AS "updatedBy" + `; + } else { + // 일반 회사: 자신의 카테고리 값만 수정 가능 + values.push(valueId, companyCode); + updateQuery = ` + UPDATE table_column_category_values + SET ${setClauses.join(", ")} + WHERE value_id = $${paramIndex++} + AND company_code = $${paramIndex++} + RETURNING + value_id AS "valueId", + table_name AS "tableName", + column_name AS "columnName", + value_code AS "valueCode", + value_label AS "valueLabel", + value_order AS "valueOrder", + description, + color, + icon, + is_active AS "isActive", + is_default AS "isDefault", + company_code AS "companyCode", + updated_at AS "updatedAt", + updated_by AS "updatedBy" + `; + } const result = await pool.query(updateQuery, values); @@ -309,30 +414,65 @@ class TableCategoryValueService { const pool = getPool(); try { - // 하위 값 체크 - const checkQuery = ` - SELECT COUNT(*) as count - FROM table_column_category_values - WHERE parent_value_id = $1 - AND (company_code = $2 OR company_code = '*') - AND is_active = true - `; + // 하위 값 체크 (멀티테넌시 적용) + let checkQuery: string; + let checkParams: any[]; + + if (companyCode === "*") { + // 최고 관리자: 모든 하위 값 체크 + checkQuery = ` + SELECT COUNT(*) as count + FROM table_column_category_values + WHERE parent_value_id = $1 + AND is_active = true + `; + checkParams = [valueId]; + } else { + // 일반 회사: 자신의 하위 값만 체크 + checkQuery = ` + SELECT COUNT(*) as count + FROM table_column_category_values + WHERE parent_value_id = $1 + AND company_code = $2 + AND is_active = true + `; + checkParams = [valueId, companyCode]; + } - const checkResult = await pool.query(checkQuery, [valueId, companyCode]); + const checkResult = await pool.query(checkQuery, checkParams); if (parseInt(checkResult.rows[0].count) > 0) { throw new Error("하위 카테고리 값이 있어 삭제할 수 없습니다"); } - // 비활성화 - const deleteQuery = ` - UPDATE table_column_category_values - SET is_active = false, updated_at = NOW(), updated_by = $3 - WHERE value_id = $1 - AND (company_code = $2 OR company_code = '*') - `; + // 비활성화 (멀티테넌시 적용) + let deleteQuery: string; + let deleteParams: any[]; + + if (companyCode === "*") { + // 최고 관리자: 모든 카테고리 값 삭제 가능 + deleteQuery = ` + UPDATE table_column_category_values + SET is_active = false, updated_at = NOW(), updated_by = $2 + WHERE value_id = $1 + `; + deleteParams = [valueId, userId]; + } else { + // 일반 회사: 자신의 카테고리 값만 삭제 가능 + deleteQuery = ` + UPDATE table_column_category_values + SET is_active = false, updated_at = NOW(), updated_by = $3 + WHERE value_id = $1 + AND company_code = $2 + `; + deleteParams = [valueId, companyCode, userId]; + } - await pool.query(deleteQuery, [valueId, companyCode, userId]); + const result = await pool.query(deleteQuery, deleteParams); + + if (result.rowCount === 0) { + throw new Error("카테고리 값을 찾을 수 없거나 권한이 없습니다"); + } logger.info("카테고리 값 삭제(비활성화) 완료", { valueId, @@ -355,14 +495,30 @@ class TableCategoryValueService { const pool = getPool(); try { - const deleteQuery = ` - UPDATE table_column_category_values - SET is_active = false, updated_at = NOW(), updated_by = $3 - WHERE value_id = ANY($1::int[]) - AND (company_code = $2 OR company_code = '*') - `; + // 멀티테넌시 적용 + let deleteQuery: string; + let deleteParams: any[]; + + if (companyCode === "*") { + // 최고 관리자: 모든 카테고리 값 일괄 삭제 가능 + deleteQuery = ` + UPDATE table_column_category_values + SET is_active = false, updated_at = NOW(), updated_by = $2 + WHERE value_id = ANY($1::int[]) + `; + deleteParams = [valueIds, userId]; + } else { + // 일반 회사: 자신의 카테고리 값만 일괄 삭제 가능 + deleteQuery = ` + UPDATE table_column_category_values + SET is_active = false, updated_at = NOW(), updated_by = $3 + WHERE value_id = ANY($1::int[]) + AND company_code = $2 + `; + deleteParams = [valueIds, companyCode, userId]; + } - await pool.query(deleteQuery, [valueIds, companyCode, userId]); + await pool.query(deleteQuery, deleteParams); logger.info("카테고리 값 일괄 삭제 완료", { count: valueIds.length, @@ -388,18 +544,30 @@ class TableCategoryValueService { await client.query("BEGIN"); for (let i = 0; i < orderedValueIds.length; i++) { - const updateQuery = ` - UPDATE table_column_category_values - SET value_order = $1, updated_at = NOW() - WHERE value_id = $2 - AND (company_code = $3 OR company_code = '*') - `; + // 멀티테넌시 적용 + let updateQuery: string; + let updateParams: any[]; + + if (companyCode === "*") { + // 최고 관리자: 모든 카테고리 값 순서 변경 가능 + updateQuery = ` + UPDATE table_column_category_values + SET value_order = $1, updated_at = NOW() + WHERE value_id = $2 + `; + updateParams = [i + 1, orderedValueIds[i]]; + } else { + // 일반 회사: 자신의 카테고리 값만 순서 변경 가능 + updateQuery = ` + UPDATE table_column_category_values + SET value_order = $1, updated_at = NOW() + WHERE value_id = $2 + AND company_code = $3 + `; + updateParams = [i + 1, orderedValueIds[i], companyCode]; + } - await client.query(updateQuery, [ - i + 1, - orderedValueIds[i], - companyCode, - ]); + await client.query(updateQuery, updateParams); } await client.query("COMMIT"); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index df67e2fe..9434dc78 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -113,7 +113,8 @@ export class TableManagementService { async getColumnList( tableName: string, page: number = 1, - size: number = 50 + size: number = 50, + companyCode?: string // 🔥 회사 코드 추가 ): Promise<{ columns: ColumnTypeInfo[]; total: number; @@ -123,11 +124,11 @@ export class TableManagementService { }> { try { logger.info( - `컬럼 정보 조회 시작: ${tableName} (page: ${page}, size: ${size})` + `컬럼 정보 조회 시작: ${tableName} (page: ${page}, size: ${size}), company: ${companyCode}` ); - // 캐시 키 생성 - const cacheKey = CacheKeys.TABLE_COLUMNS(tableName, page, size); + // 캐시 키 생성 (companyCode 포함) + const cacheKey = CacheKeys.TABLE_COLUMNS(tableName, page, size) + `_${companyCode}`; const countCacheKey = CacheKeys.TABLE_COLUMN_COUNT(tableName); // 캐시에서 먼저 확인 @@ -161,49 +162,92 @@ export class TableManagementService { // 페이지네이션 적용한 컬럼 조회 const offset = (page - 1) * size; - const rawColumns = await query( - `SELECT - c.column_name as "columnName", - COALESCE(cl.column_label, c.column_name) as "displayName", - c.data_type as "dataType", - c.data_type as "dbType", - COALESCE(cl.input_type, 'text') as "webType", - COALESCE(cl.input_type, 'direct') as "inputType", - COALESCE(cl.detail_settings, '') as "detailSettings", - COALESCE(cl.description, '') as "description", - c.is_nullable as "isNullable", - CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey", - c.column_default as "defaultValue", - c.character_maximum_length as "maxLength", - c.numeric_precision as "numericPrecision", - c.numeric_scale as "numericScale", - cl.code_category as "codeCategory", - cl.code_value as "codeValue", - cl.reference_table as "referenceTable", - cl.reference_column as "referenceColumn", - cl.display_column as "displayColumn", - cl.display_order as "displayOrder", - cl.is_visible as "isVisible", - -- Entity 조인 컬럼의 표시 컬럼 라벨 조회 - dcl.column_label as "displayColumnLabel" - FROM information_schema.columns c - LEFT JOIN column_labels cl ON c.table_name = cl.table_name AND c.column_name = cl.column_name - -- Entity 조인의 display_column에 대한 라벨 정보 조회 - LEFT JOIN column_labels dcl ON cl.reference_table = dcl.table_name AND cl.display_column = dcl.column_name - LEFT JOIN ( - SELECT kcu.column_name, kcu.table_name - FROM information_schema.table_constraints tc - JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name - AND tc.table_schema = kcu.table_schema - WHERE tc.constraint_type = 'PRIMARY KEY' - AND tc.table_name = $1 - ) pk ON c.column_name = pk.column_name AND c.table_name = pk.table_name - WHERE c.table_name = $1 - ORDER BY c.ordinal_position - LIMIT $2 OFFSET $3`, - [tableName, size, offset] - ); + + // 🔥 company_code가 있으면 table_type_columns 조인하여 회사별 inputType 가져오기 + const rawColumns = companyCode + ? await query( + `SELECT + c.column_name as "columnName", + COALESCE(cl.column_label, c.column_name) as "displayName", + c.data_type as "dataType", + c.data_type as "dbType", + COALESCE(cl.input_type, 'text') as "webType", + COALESCE(ttc.input_type, cl.input_type, 'direct') as "inputType", + COALESCE(ttc.detail_settings::text, cl.detail_settings, '') as "detailSettings", + COALESCE(cl.description, '') as "description", + c.is_nullable as "isNullable", + CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey", + c.column_default as "defaultValue", + c.character_maximum_length as "maxLength", + c.numeric_precision as "numericPrecision", + c.numeric_scale as "numericScale", + cl.code_category as "codeCategory", + cl.code_value as "codeValue", + cl.reference_table as "referenceTable", + cl.reference_column as "referenceColumn", + cl.display_column as "displayColumn", + cl.display_order as "displayOrder", + cl.is_visible as "isVisible", + dcl.column_label as "displayColumnLabel" + FROM information_schema.columns c + LEFT JOIN column_labels cl ON c.table_name = cl.table_name AND c.column_name = cl.column_name + LEFT JOIN table_type_columns ttc ON c.table_name = ttc.table_name AND c.column_name = ttc.column_name AND ttc.company_code = $4 + LEFT JOIN column_labels dcl ON cl.reference_table = dcl.table_name AND cl.display_column = dcl.column_name + LEFT JOIN ( + SELECT kcu.column_name, kcu.table_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + WHERE tc.constraint_type = 'PRIMARY KEY' + AND tc.table_name = $1 + ) pk ON c.column_name = pk.column_name AND c.table_name = pk.table_name + WHERE c.table_name = $1 + ORDER BY c.ordinal_position + LIMIT $2 OFFSET $3`, + [tableName, size, offset, companyCode] + ) + : await query( + `SELECT + c.column_name as "columnName", + COALESCE(cl.column_label, c.column_name) as "displayName", + c.data_type as "dataType", + c.data_type as "dbType", + COALESCE(cl.input_type, 'text') as "webType", + COALESCE(cl.input_type, 'direct') as "inputType", + COALESCE(cl.detail_settings, '') as "detailSettings", + COALESCE(cl.description, '') as "description", + c.is_nullable as "isNullable", + CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey", + c.column_default as "defaultValue", + c.character_maximum_length as "maxLength", + c.numeric_precision as "numericPrecision", + c.numeric_scale as "numericScale", + cl.code_category as "codeCategory", + cl.code_value as "codeValue", + cl.reference_table as "referenceTable", + cl.reference_column as "referenceColumn", + cl.display_column as "displayColumn", + cl.display_order as "displayOrder", + cl.is_visible as "isVisible", + dcl.column_label as "displayColumnLabel" + FROM information_schema.columns c + LEFT JOIN column_labels cl ON c.table_name = cl.table_name AND c.column_name = cl.column_name + LEFT JOIN column_labels dcl ON cl.reference_table = dcl.table_name AND cl.display_column = dcl.column_name + LEFT JOIN ( + SELECT kcu.column_name, kcu.table_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + WHERE tc.constraint_type = 'PRIMARY KEY' + AND tc.table_name = $1 + ) pk ON c.column_name = pk.column_name AND c.table_name = pk.table_name + WHERE c.table_name = $1 + ORDER BY c.ordinal_position + LIMIT $2 OFFSET $3`, + [tableName, size, offset] + ); // BigInt를 Number로 변환하여 JSON 직렬화 문제 해결 const columns: ColumnTypeInfo[] = rawColumns.map((column) => ({ @@ -312,10 +356,11 @@ export class TableManagementService { async updateColumnSettings( tableName: string, columnName: string, - settings: ColumnSettings + settings: ColumnSettings, + companyCode: string // 🔥 회사 코드 추가 ): Promise { try { - logger.info(`컬럼 설정 업데이트 시작: ${tableName}.${columnName}`); + logger.info(`컬럼 설정 업데이트 시작: ${tableName}.${columnName}, company: ${companyCode}`); // 테이블이 table_labels에 없으면 자동 추가 await this.insertTableIfNotExists(tableName); @@ -356,6 +401,27 @@ export class TableManagementService { ] ); + // 🔥 table_type_columns도 업데이트 (멀티테넌시 지원) + if (settings.inputType) { + // detailSettings가 문자열이면 파싱, 객체면 그대로 사용 + let parsedDetailSettings = settings.detailSettings; + if (typeof settings.detailSettings === 'string') { + try { + parsedDetailSettings = JSON.parse(settings.detailSettings); + } catch (e) { + logger.warn(`detailSettings 파싱 실패, 그대로 사용: ${settings.detailSettings}`); + } + } + + await this.updateColumnInputType( + tableName, + columnName, + settings.inputType, + companyCode, + parsedDetailSettings + ); + } + // 캐시 무효화 - 해당 테이블의 컬럼 캐시 삭제 cache.deleteByPattern(`table_columns:${tableName}:`); cache.delete(CacheKeys.TABLE_COLUMN_COUNT(tableName)); @@ -378,11 +444,12 @@ export class TableManagementService { */ async updateAllColumnSettings( tableName: string, - columnSettings: ColumnSettings[] + columnSettings: ColumnSettings[], + companyCode: string // 🔥 회사 코드 추가 ): Promise { try { logger.info( - `전체 컬럼 설정 일괄 업데이트 시작: ${tableName}, ${columnSettings.length}개` + `전체 컬럼 설정 일괄 업데이트 시작: ${tableName}, ${columnSettings.length}개, company: ${companyCode}` ); // Raw Query 트랜잭션 사용 @@ -398,7 +465,8 @@ export class TableManagementService { await this.updateColumnSettings( tableName, columnName, - columnSetting + columnSetting, + companyCode // 🔥 회사 코드 전달 ); } else { logger.warn( @@ -412,7 +480,7 @@ export class TableManagementService { cache.deleteByPattern(`table_columns:${tableName}:`); cache.delete(CacheKeys.TABLE_COLUMN_COUNT(tableName)); - logger.info(`전체 컬럼 설정 일괄 업데이트 완료: ${tableName}`); + logger.info(`전체 컬럼 설정 일괄 업데이트 완료: ${tableName}, company: ${companyCode}`); } catch (error) { logger.error( `전체 컬럼 설정 일괄 업데이트 중 오류 발생: ${tableName}`, @@ -590,16 +658,18 @@ export class TableManagementService { /** * 컬럼 입력 타입 설정 (새로운 시스템) + * @param companyCode - 회사 코드 (멀티테넌시) */ async updateColumnInputType( tableName: string, columnName: string, inputType: string, + companyCode: string, detailSettings?: Record ): Promise { try { logger.info( - `컬럼 입력 타입 설정 시작: ${tableName}.${columnName} = ${inputType}` + `컬럼 입력 타입 설정 시작: ${tableName}.${columnName} = ${inputType}, company: ${companyCode}` ); // 입력 타입별 기본 상세 설정 생성 @@ -612,22 +682,28 @@ export class TableManagementService { ...detailSettings, }; - // table_type_columns 테이블에서 업데이트 + // table_type_columns 테이블에서 업데이트 (company_code 추가) await query( `INSERT INTO table_type_columns ( table_name, column_name, input_type, detail_settings, - is_nullable, display_order, created_date, updated_date - ) VALUES ($1, $2, $3, $4, 'Y', 0, now(), now()) - ON CONFLICT (table_name, column_name) + is_nullable, display_order, company_code, created_date, updated_date + ) VALUES ($1, $2, $3, $4, 'Y', 0, $5, now(), now()) + ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = EXCLUDED.input_type, detail_settings = EXCLUDED.detail_settings, updated_date = now()`, - [tableName, columnName, inputType, JSON.stringify(finalDetailSettings)] + [ + tableName, + columnName, + inputType, + JSON.stringify(finalDetailSettings), + companyCode, + ] ); logger.info( - `컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${inputType}` + `컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${inputType}, company: ${companyCode}` ); } catch (error) { logger.error( @@ -2978,26 +3054,36 @@ export class TableManagementService { /** * 컬럼 입력타입 정보 조회 (화면관리 연동용) + * @param companyCode - 회사 코드 (멀티테넌시) */ - async getColumnInputTypes(tableName: string): Promise { + async getColumnInputTypes( + tableName: string, + companyCode: string + ): Promise { try { - logger.info(`컬럼 입력타입 정보 조회: ${tableName}`); + logger.info( + `컬럼 입력타입 정보 조회: ${tableName}, company: ${companyCode}` + ); - // column_labels에서 입력타입 정보 조회 + // table_type_columns에서 입력타입 정보 조회 (company_code 필터링) const rawInputTypes = await query( `SELECT - cl.column_name as "columnName", - cl.column_label as "displayName", - COALESCE(cl.input_type, 'text') as "inputType", - '{}'::jsonb as "detailSettings", - ic.is_nullable as "isNullable", - ic.data_type as "dataType" - FROM column_labels cl + ttc.column_name as "columnName", + COALESCE(cl.column_label, ttc.column_name) as "displayName", + ttc.input_type as "inputType", + COALESCE(ttc.detail_settings::jsonb, '{}'::jsonb) as "detailSettings", + ttc.is_nullable as "isNullable", + ic.data_type as "dataType", + ttc.company_code as "companyCode" + FROM table_type_columns ttc + LEFT JOIN column_labels cl + ON ttc.table_name = cl.table_name AND ttc.column_name = cl.column_name LEFT JOIN information_schema.columns ic - ON cl.table_name = ic.table_name AND cl.column_name = ic.column_name - WHERE cl.table_name = $1 - ORDER BY cl.column_name`, - [tableName] + ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name + WHERE ttc.table_name = $1 + AND ttc.company_code = $2 + ORDER BY ttc.display_order, ttc.column_name`, + [tableName, companyCode] ); const inputTypes: ColumnTypeInfo[] = rawInputTypes.map((col) => ({ @@ -3008,18 +3094,21 @@ export class TableManagementService { inputType: col.inputType, detailSettings: col.detailSettings, description: "", // 필수 필드 추가 - isNullable: col.isNullable, + isNullable: col.isNullable === "Y" ? "Y" : "N", // 🔥 FIX: string 타입으로 변환 isPrimaryKey: false, displayOrder: 0, isVisible: true, })); logger.info( - `컬럼 입력타입 정보 조회 완료: ${tableName}, ${inputTypes.length}개 컬럼` + `컬럼 입력타입 정보 조회 완료: ${tableName}, company: ${companyCode}, ${inputTypes.length}개 컬럼` ); return inputTypes; } catch (error) { - logger.error(`컬럼 입력타입 정보 조회 실패: ${tableName}`, error); + logger.error( + `컬럼 입력타입 정보 조회 실패: ${tableName}, company: ${companyCode}`, + error + ); throw error; } } @@ -3028,11 +3117,11 @@ export class TableManagementService { * 레거시 지원: 컬럼 웹타입 정보 조회 * @deprecated getColumnInputTypes 사용 권장 */ - async getColumnWebTypes(tableName: string): Promise { + async getColumnWebTypes(tableName: string, companyCode: string): Promise { logger.warn( `레거시 메서드 사용: getColumnWebTypes → getColumnInputTypes 사용 권장` ); - return this.getColumnInputTypes(tableName); + return this.getColumnInputTypes(tableName, companyCode); // 🔥 FIX: companyCode 파라미터 추가 } /** diff --git a/db/migrations/RUN_044_MIGRATION.md b/db/migrations/RUN_044_MIGRATION.md new file mode 100644 index 00000000..1ece80e8 --- /dev/null +++ b/db/migrations/RUN_044_MIGRATION.md @@ -0,0 +1,280 @@ +# 마이그레이션 044: table_type_columns에 company_code 추가 + +## 목적 + +회사별로 독립적인 컬럼 타입 정의를 가능하게 합니다. + +### 해결하는 문제 + +**현재 문제**: +- 회사 A: `item_info.material` → `category` (드롭다운) +- 회사 B: `item_info.material` → `text` (자유 입력) +- ❌ 현재는 둘 중 하나만 선택 가능! + +**수정 후**: +- ✅ 각 회사가 독립적으로 컬럼 타입을 설정 가능 + +--- + +## 영향받는 테이블 + +- `table_type_columns` + - `company_code VARCHAR(20)` 컬럼 추가 + - 기존 데이터를 모든 회사에 복제 + - 복합 유니크 인덱스 생성 + +--- + +## 실행 방법 + +### Docker 환경 (권장) + +```bash +docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/migrations/044_add_company_code_to_table_type_columns.sql +``` + +### 로컬 PostgreSQL + +```bash +psql -U postgres -d ilshin -f db/migrations/044_add_company_code_to_table_type_columns.sql +``` + +### pgAdmin / DBeaver + +1. `db/migrations/044_add_company_code_to_table_type_columns.sql` 파일 열기 +2. 전체 내용 복사 +3. SQL 쿼리 창에 붙여넣기 +4. 실행 (F5 또는 Execute) + +--- + +## 마이그레이션 단계 + +1. **company_code 컬럼 추가** (nullable) +2. **기존 데이터 백업** (임시 테이블) +3. **데이터 복제** (기존 데이터를 모든 회사에 복제) +4. **기존 데이터 삭제** (company_code가 NULL인 것) +5. **NOT NULL 제약조건 추가** +6. **복합 유니크 인덱스 생성** (table_name, column_name, company_code) +7. **단순 인덱스 생성** (company_code) +8. **외래키 제약조건 추가** (company_info 참조) + +--- + +## 검증 방법 + +### 1. 컬럼 추가 확인 + +```sql +SELECT column_name, data_type, is_nullable +FROM information_schema.columns +WHERE table_name = 'table_type_columns' AND column_name = 'company_code'; + +-- 예상 결과: +-- column_name | data_type | is_nullable +-- company_code | character varying | NO +``` + +### 2. 인덱스 생성 확인 + +```sql +SELECT indexname, indexdef +FROM pg_indexes +WHERE tablename = 'table_type_columns' +ORDER BY indexname; + +-- 예상 결과: +-- idx_table_column_type_company +-- idx_table_type_columns_company +``` + +### 3. 데이터 마이그레이션 확인 + +```sql +-- 회사별 데이터 개수 +SELECT company_code, COUNT(*) as column_count +FROM table_type_columns +GROUP BY company_code +ORDER BY company_code; + +-- NULL 확인 (없어야 정상) +SELECT COUNT(*) as null_count +FROM table_type_columns +WHERE company_code IS NULL; + +-- 예상 결과: 0 +``` + +### 4. 회사별 독립성 확인 + +```sql +-- 같은 테이블/컬럼이 회사별로 존재하는지 확인 +SELECT + table_name, + column_name, + COUNT(DISTINCT company_code) as company_count, + STRING_AGG(DISTINCT company_code, ', ') as companies +FROM table_type_columns +GROUP BY table_name, column_name +HAVING COUNT(DISTINCT company_code) > 1 +ORDER BY company_count DESC +LIMIT 10; +``` + +### 5. 외래키 제약조건 확인 + +```sql +SELECT + tc.constraint_name, + tc.table_name, + kcu.column_name, + ccu.table_name AS foreign_table_name, + ccu.column_name AS foreign_column_name +FROM information_schema.table_constraints AS tc +JOIN information_schema.key_column_usage AS kcu + ON tc.constraint_name = kcu.constraint_name +JOIN information_schema.constraint_column_usage AS ccu + ON ccu.constraint_name = tc.constraint_name +WHERE tc.table_name = 'table_type_columns' + AND tc.constraint_type = 'FOREIGN KEY'; + +-- 예상 결과: +-- fk_table_type_columns_company | table_type_columns | company_code | company_info | company_code +``` + +--- + +## 롤백 방법 (문제 발생 시) + +```sql +BEGIN; + +-- 1. 외래키 제약조건 제거 +ALTER TABLE table_type_columns +DROP CONSTRAINT IF EXISTS fk_table_type_columns_company; + +-- 2. 인덱스 제거 +DROP INDEX IF EXISTS idx_table_column_type_company; +DROP INDEX IF EXISTS idx_table_type_columns_company; + +-- 3. company_code를 nullable로 변경 +ALTER TABLE table_type_columns +ALTER COLUMN company_code DROP NOT NULL; + +-- 4. company_code 컬럼 제거 +ALTER TABLE table_type_columns +DROP COLUMN IF EXISTS company_code; + +COMMIT; +``` + +--- + +## 테스트 시나리오 + +### 시나리오 1: 회사별 다른 타입 설정 + +```sql +-- 회사 A: material을 카테고리로 설정 +UPDATE table_type_columns +SET input_type = 'category' +WHERE table_name = 'item_info' + AND column_name = 'material' + AND company_code = 'COMPANY_A'; + +-- 회사 B: material을 텍스트로 설정 +UPDATE table_type_columns +SET input_type = 'text' +WHERE table_name = 'item_info' + AND column_name = 'material' + AND company_code = 'COMPANY_B'; + +-- 확인 +SELECT table_name, column_name, input_type, company_code +FROM table_type_columns +WHERE table_name = 'item_info' AND column_name = 'material' +ORDER BY company_code; + +-- 예상 결과: +-- item_info | material | category | * +-- item_info | material | text | COMPANY_7 +``` + +### 시나리오 2: 유니크 제약조건 확인 + +```sql +-- 같은 회사에서 같은 테이블/컬럼 중복 삽입 시도 (실패해야 정상) +INSERT INTO table_type_columns (table_name, column_name, input_type, company_code) +VALUES ('test_table', 'test_column', 'text', 'COMPANY_A'); + +-- 다시 시도 (에러 발생해야 함) +INSERT INTO table_type_columns (table_name, column_name, input_type, company_code) +VALUES ('test_table', 'test_column', 'number', 'COMPANY_A'); + +-- 예상 에러: +-- ERROR: duplicate key value violates unique constraint "idx_table_column_type_company" +``` + +--- + +## 주의사항 + +1. **백업 필수**: 마이그레이션 실행 전 반드시 데이터베이스 백업 +2. **데이터 복제**: 기존 데이터가 모든 회사에 복제되므로 데이터 양이 증가 +3. **트랜잭션**: 전체 마이그레이션이 하나의 트랜잭션으로 실행됨 (실패 시 자동 롤백) +4. **성능 영향**: 회사 수가 많으면 실행 시간이 길어질 수 있음 +5. **코드 수정**: 백엔드 코드도 함께 수정해야 함 + +--- + +## 예상 데이터 변화 + +### Before (기존) + +``` +id | table_name | column_name | input_type | company_code +---|------------|-------------|------------|------------- +1 | item_info | material | text | NULL +2 | projects | type | category | NULL +``` + +### After (마이그레이션 후) + +``` +id | table_name | column_name | input_type | company_code +---|------------|-------------|------------|------------- +1 | item_info | material | text | * +2 | item_info | material | text | COMPANY_7 +3 | projects | type | category | * +4 | projects | type | category | COMPANY_7 +``` + +--- + +## 다음 단계 + +마이그레이션 완료 후: + +1. **백엔드 코드 수정**: `company_code` 파라미터 추가 + - `tableService.ts` + - `dataService.ts` + - `tableController.ts` + +2. **프론트엔드 코드 수정**: API 호출 시 `company_code` 자동 포함 + +3. **테스트**: 회사별로 다른 컬럼 타입 설정 확인 + +--- + +## 관련 파일 + +- 마이그레이션 파일: `db/migrations/044_add_company_code_to_table_type_columns.sql` +- 분석 문서: `docs/테이블_컬럼_타입_멀티테넌시_구조적_문제_분석.md` +- 백엔드 서비스: `backend-node/src/services/tableService.ts` + +--- + +**작성일**: 2025-11-06 +**심각도**: 🔴 높음 +**영향 범위**: 전체 동적 테이블 시스템 + diff --git a/docs/채번규칙_멀티테넌시_버그_수정_완료.md b/docs/채번규칙_멀티테넌시_버그_수정_완료.md new file mode 100644 index 00000000..f7f5e69b --- /dev/null +++ b/docs/채번규칙_멀티테넌시_버그_수정_완료.md @@ -0,0 +1,332 @@ +# 채번 규칙 멀티테넌시 버그 수정 완료 + +> **작성일**: 2025-11-06 +> **상태**: ✅ 완료 + +--- + +## 🐛 문제 발견 + +### 증상 +- 다른 회사 계정으로 로그인했는데 `company_code = "*"` (최고 관리자 전용) 채번 규칙이 보임 +- 멀티테넌시 원칙 위반 + +### 원인 +`backend-node/src/services/numberingRuleService.ts`의 SQL 쿼리에서 **잘못된 WHERE 조건** 사용: + +```typescript +// ❌ 잘못된 쿼리 (버그) +WHERE company_code = $1 OR company_code = '*' +``` + +**문제점:** +- `OR company_code = '*'` 조건이 **항상 최고 관리자 데이터를 포함**시킴 +- 일반 회사 사용자도 `company_code = "*"` 데이터를 볼 수 있음 +- 멀티테넌시 보안 위반 + +--- + +## ✅ 수정 내용 + +### 수정된 로직 + +```typescript +// ✅ 올바른 쿼리 (수정 후) +if (companyCode === "*") { + // 최고 관리자: 모든 회사 데이터 조회 가능 + query = `SELECT * FROM numbering_rules`; + params = []; +} else { + // 일반 회사: 자신의 데이터만 조회 (company_code="*" 제외) + query = `SELECT * FROM numbering_rules WHERE company_code = $1`; + params = [companyCode]; +} +``` + +### 수정된 메서드 목록 + +| 메서드 | 수정 내용 | 라인 | +|--------|-----------|------| +| `getRuleList()` | 멀티테넌시 필터링 추가 | 40-150 | +| `getAvailableRulesForMenu()` | 멀티테넌시 필터링 추가 | 155-402 | +| `getRuleById()` | 멀티테넌시 필터링 추가 | 407-506 | + +--- + +## 📊 수정 전후 비교 + +### 수정 전 (버그) + +```sql +-- 일반 회사 (COMPANY_A) 로그인 시 +SELECT * FROM numbering_rules +WHERE company_code = 'COMPANY_A' OR company_code = '*'; + +-- 결과: 3건 +-- 1. SAMPLE_RULE (company_code = '*') ← 보면 안 됨! +-- 2. 사번코드 (company_code = '*') ← 보면 안 됨! +-- 3. COMPANY_A 전용 규칙 (있다면) +``` + +### 수정 후 (정상) + +```sql +-- 일반 회사 (COMPANY_A) 로그인 시 +SELECT * FROM numbering_rules +WHERE company_code = 'COMPANY_A'; + +-- 결과: 1건 (또는 0건) +-- 1. COMPANY_A 전용 규칙만 조회 +-- company_code="*" 데이터는 제외됨! +``` + +```sql +-- 최고 관리자 (company_code = '*') 로그인 시 +SELECT * FROM numbering_rules; + +-- 결과: 모든 규칙 조회 가능 +-- - SAMPLE_RULE (company_code = '*') +-- - 사번코드 (company_code = '*') +-- - COMPANY_A 전용 규칙 +-- - COMPANY_B 전용 규칙 +-- 등 모든 회사 데이터 +``` + +--- + +## 🔍 상세 수정 내역 + +### 1. `getRuleList()` 메서드 + +**Before:** +```typescript +const query = ` + SELECT * FROM numbering_rules + WHERE company_code = $1 OR company_code = '*' +`; +const result = await pool.query(query, [companyCode]); +``` + +**After:** +```typescript +let query: string; +let params: any[]; + +if (companyCode === "*") { + // 최고 관리자: 모든 데이터 조회 + query = `SELECT * FROM numbering_rules ORDER BY created_at DESC`; + params = []; + logger.info("최고 관리자 전체 채번 규칙 조회"); +} else { + // 일반 회사: 자신의 데이터만 조회 + query = `SELECT * FROM numbering_rules WHERE company_code = $1 ORDER BY created_at DESC`; + params = [companyCode]; + logger.info("회사별 채번 규칙 조회", { companyCode }); +} + +const result = await pool.query(query, params); +``` + +### 2. `getAvailableRulesForMenu()` 메서드 + +**Before:** +```typescript +// menuObjid 없을 때 +const query = ` + SELECT * FROM numbering_rules + WHERE (company_code = $1 OR company_code = '*') + AND scope_type = 'global' +`; + +// menuObjid 있을 때 +const query = ` + SELECT * FROM numbering_rules + WHERE (company_code = $1 OR company_code = '*') + AND (scope_type = 'global' OR (scope_type = 'menu' AND menu_objid = $2)) +`; +``` + +**After:** +```typescript +// 최고 관리자와 일반 회사를 명확히 구분 +if (companyCode === "*") { + // 최고 관리자 쿼리 + query = `SELECT * FROM numbering_rules WHERE scope_type = 'global'`; +} else { + // 일반 회사 쿼리 (company_code="*" 제외) + query = `SELECT * FROM numbering_rules WHERE company_code = $1 AND scope_type = 'global'`; +} +``` + +### 3. `getRuleById()` 메서드 + +**Before:** +```typescript +const query = ` + SELECT * FROM numbering_rules + WHERE rule_id = $1 AND (company_code = $2 OR company_code = '*') +`; +const result = await pool.query(query, [ruleId, companyCode]); +``` + +**After:** +```typescript +if (companyCode === "*") { + // 최고 관리자: rule_id만 체크 + query = `SELECT * FROM numbering_rules WHERE rule_id = $1`; + params = [ruleId]; +} else { + // 일반 회사: rule_id + company_code 체크 + query = `SELECT * FROM numbering_rules WHERE rule_id = $1 AND company_code = $2`; + params = [ruleId, companyCode]; +} +``` + +--- + +## 🧪 테스트 시나리오 + +### 시나리오 1: 최고 관리자 로그인 + +```bash +# 로그인 +POST /api/auth/login +{ + "userId": "admin", + "password": "****" +} +# → JWT 토큰에 companyCode = "*" 포함 + +# 채번 규칙 조회 +GET /api/numbering-rules +Authorization: Bearer {token} + +# 예상 결과: 모든 회사의 규칙 조회 가능 +[ + { "ruleId": "SAMPLE_RULE", "companyCode": "*" }, + { "ruleId": "사번코드", "companyCode": "*" }, + { "ruleId": "COMPANY_A_RULE", "companyCode": "COMPANY_A" }, + { "ruleId": "COMPANY_B_RULE", "companyCode": "COMPANY_B" } +] +``` + +### 시나리오 2: 일반 회사 (COMPANY_A) 로그인 + +```bash +# 로그인 +POST /api/auth/login +{ + "userId": "user_a", + "password": "****" +} +# → JWT 토큰에 companyCode = "COMPANY_A" 포함 + +# 채번 규칙 조회 +GET /api/numbering-rules +Authorization: Bearer {token} + +# 예상 결과: 자신의 회사 규칙만 조회 (company_code="*" 제외) +[ + { "ruleId": "COMPANY_A_RULE", "companyCode": "COMPANY_A" } +] +``` + +### 시나리오 3: 일반 회사 (COMPANY_B) 로그인 + +```bash +# 로그인 +POST /api/auth/login +{ + "userId": "user_b", + "password": "****" +} +# → JWT 토큰에 companyCode = "COMPANY_B" 포함 + +# 채번 규칙 조회 +GET /api/numbering-rules +Authorization: Bearer {token} + +# 예상 결과: COMPANY_B 규칙만 조회 +[ + { "ruleId": "COMPANY_B_RULE", "companyCode": "COMPANY_B" } +] +``` + +--- + +## 🎯 멀티테넌시 원칙 재확인 + +### 핵심 원칙 + +**company_code = "*"는 최고 관리자 전용 데이터이며, 일반 회사는 절대 접근할 수 없습니다.** + +| 회사 코드 | 조회 가능 데이터 | 설명 | +|-----------|------------------|------| +| `*` (최고 관리자) | 모든 회사 데이터 | `company_code = "*"`, `"COMPANY_A"`, `"COMPANY_B"` 등 모두 조회 | +| `COMPANY_A` | `COMPANY_A` 데이터만 | `company_code = "*"` 데이터는 **절대 조회 불가** | +| `COMPANY_B` | `COMPANY_B` 데이터만 | `company_code = "*"` 데이터는 **절대 조회 불가** | + +### SQL 패턴 + +```sql +-- ❌ 잘못된 패턴 (버그) +WHERE company_code = $1 OR company_code = '*' + +-- ✅ 올바른 패턴 (최고 관리자) +WHERE 1=1 -- 모든 데이터 + +-- ✅ 올바른 패턴 (일반 회사) +WHERE company_code = $1 -- company_code="*" 자동 제외 +``` + +--- + +## 📝 추가 확인 사항 + +### 다른 서비스에도 같은 버그가 있을 가능성 + +다음 서비스들도 동일한 패턴으로 멀티테넌시 버그가 있는지 확인 필요: + +- [ ] `backend-node/src/services/screenService.ts` +- [ ] `backend-node/src/services/tableService.ts` +- [ ] `backend-node/src/services/flowService.ts` +- [ ] `backend-node/src/services/adminService.ts` +- [ ] 기타 `company_code` 필터링을 사용하는 모든 서비스 + +### 확인 방법 + +```bash +# 잘못된 패턴 검색 +cd backend-node/src/services +grep -n "OR company_code = '\*'" *.ts +``` + +--- + +## 🚀 배포 전 체크리스트 + +- [x] 코드 수정 완료 +- [x] 린트 에러 없음 +- [x] 로깅 추가 (최고 관리자 vs 일반 회사 구분) +- [ ] 단위 테스트 작성 (선택) +- [ ] 통합 테스트 (필수) + - [ ] 최고 관리자로 로그인하여 모든 규칙 조회 확인 + - [ ] 일반 회사로 로그인하여 자신의 규칙만 조회 확인 + - [ ] 다른 회사 규칙에 접근 불가능 확인 +- [ ] 프론트엔드에서 채번 규칙 목록 재확인 +- [ ] 백엔드 재실행 (코드 변경 사항 반영) + +--- + +## 📚 관련 문서 + +- [멀티테넌시 필수 규칙](../README.md#멀티테넌시-필수-규칙) +- [채번 규칙 컴포넌트 구현 완료](./채번규칙_컴포넌트_구현_완료.md) +- [데이터베이스 스키마](../db/migrations/034_create_numbering_rules.sql) + +--- + +**수정 완료일**: 2025-11-06 +**수정자**: AI Assistant +**영향 범위**: `numberingRuleService.ts` 전체 + diff --git a/docs/카테고리_멀티테넌시_버그_분석.md b/docs/카테고리_멀티테넌시_버그_분석.md new file mode 100644 index 00000000..8b2758a2 --- /dev/null +++ b/docs/카테고리_멀티테넌시_버그_분석.md @@ -0,0 +1,261 @@ +# 카테고리 시스템 멀티테넌시 버그 분석 + +> **작성일**: 2025-11-06 +> **상태**: 🔴 버그 발견, 수정 대기 + +--- + +## 🐛 발견된 버그 + +### 영향 받는 서비스 + +1. ✅ **CommonCodeService** (`commonCodeService.ts`) - 정상 (이미 올바르게 구현됨) +2. 🔴 **TableCategoryValueService** (`tableCategoryValueService.ts`) - **버그 존재 (7곳)** + +--- + +## 📊 현재 상태 확인 + +### 데이터베이스 현황 + +```sql +SELECT value_id, table_name, column_name, value_label, company_code +FROM table_column_category_values +ORDER BY created_at DESC +LIMIT 10; +``` + +**결과**: 모든 카테고리 값이 `company_code = "*"` (최고 관리자 전용) + +| value_id | table_name | column_name | value_label | company_code | +|----------|------------|-------------|-------------|--------------| +| 16 | item_info | material | 원자재 | * | +| 15 | item_info | material | 153 | * | +| 1-8 | projects | project_type/status | ... | * | + +**문제**: 일반 회사 사용자도 이 데이터들을 볼 수 있음! + +--- + +## 🔍 버그 상세 분석 + +### 1. tableCategoryValueService.ts + +#### 버그 위치 (7곳) + +| 메서드 | 라인 | 버그 패턴 | 심각도 | +|--------|------|-----------|--------| +| `getCategoryColumns()` | 31 | `AND (cv.company_code = $2 OR cv.company_code = '*')` | 🔴 높음 (READ) | +| `getCategoryValues()` | 93 | `AND (company_code = $3 OR company_code = '*')` | 🔴 높음 (READ) | +| `addCategoryValue()` | 139 | `AND (company_code = $4 OR company_code = '*')` | 🟡 중간 (중복 체크) | +| `updateCategoryValue()` | 269 | `AND (company_code = $${paramIndex++} OR company_code = '*')` | 🟢 낮음 (UPDATE) | +| `deleteCategoryValue()` - 하위 체크 | 317 | `AND (company_code = $2 OR company_code = '*')` | 🟡 중간 (READ) | +| `deleteCategoryValue()` - 삭제 | 332 | `AND (company_code = $2 OR company_code = '*')` | 🟢 낮음 (UPDATE) | +| `bulkDeleteCategoryValues()` | 362 | `AND (company_code = $2 OR company_code = '*')` | 🟢 낮음 (UPDATE) | +| `reorderCategoryValues()` | 395 | `AND (company_code = $3 OR company_code = '*')` | 🟢 낮음 (UPDATE) | + +#### 버그 코드 예시 + +**❌ 잘못된 코드 (93번 라인)** +```typescript +async getCategoryValues( + tableName: string, + columnName: string, + companyCode: string, + includeInactive: boolean = false +): Promise { + const query = ` + SELECT * + FROM table_column_category_values + WHERE table_name = $1 + AND column_name = $2 + AND (company_code = $3 OR company_code = '*') -- 🔴 버그! + `; + + const result = await pool.query(query, [tableName, columnName, companyCode]); + return result.rows; +} +``` + +**문제점**: +- 일반 회사 (예: `COMPANY_A`)로 로그인해도 `company_code = "*"` 데이터가 조회됨 +- 멀티테넌시 원칙 위반 + +--- + +## ✅ 수정 방안 + +### 패턴 1: Read 작업 (getCategoryColumns, getCategoryValues) + +**Before:** +```typescript +AND (company_code = $3 OR company_code = '*') +``` + +**After:** +```typescript +let query: string; +let params: any[]; + +if (companyCode === "*") { + // 최고 관리자: 모든 데이터 조회 + query = ` + SELECT * FROM table_column_category_values + WHERE table_name = $1 AND column_name = $2 + `; + params = [tableName, columnName]; +} else { + // 일반 회사: 자신의 데이터만 조회 + query = ` + SELECT * FROM table_column_category_values + WHERE table_name = $1 + AND column_name = $2 + AND company_code = $3 + `; + params = [tableName, columnName, companyCode]; +} +``` + +### 패턴 2: Update/Delete 작업 + +UPDATE/DELETE 작업은 이미 회사 코드가 매칭되는 경우에만 작동하므로, 보안상 큰 문제는 없지만 일관성을 위해 수정: + +**Before:** +```typescript +WHERE value_id = $1 AND (company_code = $2 OR company_code = '*') +``` + +**After:** +```typescript +WHERE value_id = $1 AND company_code = $2 +``` + +**단, 최고 관리자는 모든 데이터 수정 가능해야 하므로:** +```typescript +if (companyCode === "*") { + query = `UPDATE ... WHERE value_id = $1`; +} else { + query = `UPDATE ... WHERE value_id = $1 AND company_code = $2`; +} +``` + +--- + +## 📋 수정 체크리스트 + +### tableCategoryValueService.ts + +- [ ] `getCategoryColumns()` (31번 라인) + - JOIN 조건에서 `OR company_code = '*'` 제거 + - 최고 관리자/일반 회사 분기 처리 + +- [ ] `getCategoryValues()` (93번 라인) + - WHERE 조건에서 `OR company_code = '*'` 제거 + - 최고 관리자/일반 회사 분기 처리 + +- [ ] `addCategoryValue()` (139번 라인) + - 중복 체크 시 `OR company_code = '*'` 제거 + - 최고 관리자/일반 회사 분기 처리 + +- [ ] `updateCategoryValue()` (269번 라인) + - UPDATE 조건에서 `OR company_code = '*'` 제거 + - 최고 관리자는 company_code 조건 제거 + +- [ ] `deleteCategoryValue()` (317, 332번 라인) + - 하위 체크 및 삭제 조건 수정 + - 최고 관리자/일반 회사 분기 처리 + +- [ ] `bulkDeleteCategoryValues()` (362번 라인) + - 일괄 삭제 조건 수정 + +- [ ] `reorderCategoryValues()` (395번 라인) + - 순서 변경 조건 수정 + +--- + +## 🧪 테스트 시나리오 + +### 시나리오 1: 최고 관리자로 카테고리 값 조회 + +```bash +# 로그인 +POST /api/auth/login +{ "userId": "admin", "companyCode": "*" } + +# 카테고리 값 조회 +GET /api/table-category-values/projects/project_type + +# 예상 결과: 모든 카테고리 값 조회 가능 +[ + { "valueId": 1, "valueLabel": "개발", "companyCode": "*" }, + { "valueId": 2, "valueLabel": "유지보수", "companyCode": "*" }, + { "valueId": 100, "valueLabel": "COMPANY_A 전용", "companyCode": "COMPANY_A" } +] +``` + +### 시나리오 2: 일반 회사로 카테고리 값 조회 + +```bash +# 로그인 +POST /api/auth/login +{ "userId": "user_a", "companyCode": "COMPANY_A" } + +# 카테고리 값 조회 +GET /api/table-category-values/projects/project_type + +# 수정 전 (버그): company_code="*" 포함 +[ + { "valueId": 1, "valueLabel": "개발", "companyCode": "*" }, ← 보면 안 됨! + { "valueId": 100, "valueLabel": "COMPANY_A 전용", "companyCode": "COMPANY_A" } +] + +# 수정 후 (정상): 자신의 데이터만 +[ + { "valueId": 100, "valueLabel": "COMPANY_A 전용", "companyCode": "COMPANY_A" } +] +``` + +--- + +## 🔗 관련 파일 + +- **버그 존재**: `backend-node/src/services/tableCategoryValueService.ts` +- **정상 참고**: `backend-node/src/services/commonCodeService.ts` (78-86번 라인) +- **정상 참고**: `backend-node/src/services/numberingRuleService.ts` (수정 완료) + +--- + +## 📝 수정 우선순위 + +1. **🔴 높음 (즉시 수정 필요)**: + - `getCategoryColumns()` (31번) + - `getCategoryValues()` (93번) + → 일반 회사가 최고 관리자 데이터를 볼 수 있음 + +2. **🟡 중간 (가능한 빨리)**: + - `addCategoryValue()` (139번) - 중복 체크 + - `deleteCategoryValue()` (317번) - 하위 체크 + +3. **🟢 낮음 (일관성 유지)**: + - `updateCategoryValue()` (269번) + - `deleteCategoryValue()` (332번) + - `bulkDeleteCategoryValues()` (362번) + - `reorderCategoryValues()` (395번) + +--- + +## 🚨 다른 서비스 확인 필요 + +다음 서비스들도 같은 패턴의 버그가 있을 가능성: + +```bash +cd backend-node/src/services +grep -n "OR company_code = '\*'" *.ts +``` + +**검색 결과**: `tableCategoryValueService.ts` 에만 존재 + +--- + +**다음 단계**: 사용자 승인 후 `tableCategoryValueService.ts` 수정 진행 + diff --git a/docs/카테고리_멀티테넌시_버그_수정_완료.md b/docs/카테고리_멀티테넌시_버그_수정_완료.md new file mode 100644 index 00000000..fc94d6f9 --- /dev/null +++ b/docs/카테고리_멀티테넌시_버그_수정_완료.md @@ -0,0 +1,362 @@ +# 카테고리 멀티테넌시 버그 수정 완료 + +> **작성일**: 2025-11-06 +> **상태**: ✅ 완료 + +--- + +## 🐛 문제 발견 + +### 증상 +- 다른 회사 계정으로 로그인했는데 `company_code = "*"` (최고 관리자 전용) 카테고리 값이 보임 +- 채번 규칙과 동일한 멀티테넌시 버그 + +### 원인 +`backend-node/src/services/tableCategoryValueService.ts`의 **7개 메서드**에서 잘못된 WHERE 조건 사용: + +```typescript +// ❌ 잘못된 쿼리 (버그) +AND (company_code = $3 OR company_code = '*') +``` + +--- + +## ✅ 수정 내용 + +### 수정된 메서드 (7개) + +| 메서드 | 라인 | 작업 유형 | 수정 내용 | +|--------|------|-----------|-----------| +| `getCategoryColumns()` | 12-77 | READ (JOIN) | 멀티테넌시 분기 추가 | +| `getCategoryValues()` | 82-183 | READ | 멀티테넌시 분기 추가 | +| `addCategoryValue()` | 188-269 | CREATE (중복 체크) | 멀티테넌시 분기 추가 | +| `updateCategoryValue()` | 274-403 | UPDATE | 멀티테넌시 분기 추가 | +| `deleteCategoryValue()` | 409-485 | DELETE | 멀티테넌시 분기 추가 | +| `bulkDeleteCategoryValues()` | 490-531 | DELETE (일괄) | 멀티테넌시 분기 추가 | +| `reorderCategoryValues()` | 536-586 | UPDATE (순서) | 멀티테넌시 분기 추가 | + +--- + +## 📊 수정 전후 비교 + +### 1. getCategoryValues() - 카테고리 값 목록 조회 + +**Before:** +```typescript +const query = ` + SELECT * FROM table_column_category_values + WHERE table_name = $1 + AND column_name = $2 + AND (company_code = $3 OR company_code = '*') -- 🔴 버그! +`; +const params = [tableName, columnName, companyCode]; +``` + +**After:** +```typescript +let query: string; +let params: any[]; + +if (companyCode === "*") { + // 최고 관리자: 모든 카테고리 값 조회 + query = ` + SELECT * FROM table_column_category_values + WHERE table_name = $1 AND column_name = $2 + `; + params = [tableName, columnName]; +} else { + // 일반 회사: 자신의 카테고리 값만 조회 + query = ` + SELECT * FROM table_column_category_values + WHERE table_name = $1 + AND column_name = $2 + AND company_code = $3 + `; + params = [tableName, columnName, companyCode]; +} +``` + +### 2. getCategoryColumns() - 카테고리 컬럼 목록 조회 (JOIN) + +**Before:** +```typescript +const query = ` + SELECT ... + FROM table_type_columns tc + LEFT JOIN table_column_category_values cv + ON tc.table_name = cv.table_name + AND tc.column_name = cv.column_name + AND cv.is_active = true + AND (cv.company_code = $2 OR cv.company_code = '*') -- 🔴 버그! + WHERE tc.table_name = $1 +`; +``` + +**After:** +```typescript +if (companyCode === "*") { + // 최고 관리자: JOIN 조건에서 company_code 제외 + query = ` + SELECT ... + FROM table_type_columns tc + LEFT JOIN table_column_category_values cv + ON tc.table_name = cv.table_name + AND tc.column_name = cv.column_name + AND cv.is_active = true + WHERE tc.table_name = $1 + `; +} else { + // 일반 회사: JOIN 조건에 company_code 추가 + query = ` + SELECT ... + FROM table_type_columns tc + LEFT JOIN table_column_category_values cv + ON tc.table_name = cv.table_name + AND tc.column_name = cv.column_name + AND cv.is_active = true + AND cv.company_code = $2 + WHERE tc.table_name = $1 + `; +} +``` + +### 3. updateCategoryValue() - 카테고리 값 수정 + +**Before:** +```typescript +const updateQuery = ` + UPDATE table_column_category_values + SET ... + WHERE value_id = $${paramIndex++} + AND (company_code = $${paramIndex++} OR company_code = '*') -- 🔴 버그! +`; +``` + +**After:** +```typescript +if (companyCode === "*") { + // 최고 관리자: company_code 조건 제외 + updateQuery = ` + UPDATE table_column_category_values + SET ... + WHERE value_id = $${paramIndex++} + `; +} else { + // 일반 회사: company_code 조건 포함 + updateQuery = ` + UPDATE table_column_category_values + SET ... + WHERE value_id = $${paramIndex++} + AND company_code = $${paramIndex++} + `; +} +``` + +--- + +## 🔍 데이터베이스 현황 + +### 현재 카테고리 값 (수정 전) + +```sql +SELECT value_id, table_name, column_name, value_label, company_code +FROM table_column_category_values +ORDER BY created_at DESC +LIMIT 10; +``` + +| value_id | table_name | column_name | value_label | company_code | +|----------|------------|-------------|-------------|--------------| +| 1-8 | projects | project_type/status | 개발/유지보수/... | * | +| 15-16 | item_info | material | 원자재/153 | * | + +**문제**: 일반 회사 사용자도 이 데이터를 볼 수 있음! + +### 수정 후 동작 + +| 사용자 | 수정 전 | 수정 후 | +|--------|---------|---------| +| **최고 관리자 (*)** | 모든 데이터 조회 ✅ | 모든 데이터 조회 ✅ | +| **일반 회사 A** | A데이터 + `*` 데이터 ❌ | A데이터만 ✅ | +| **일반 회사 B** | B데이터 + `*` 데이터 ❌ | B데이터만 ✅ | + +--- + +## 🧪 테스트 시나리오 + +### 시나리오 1: 최고 관리자로 카테고리 값 조회 + +```bash +# 로그인 +POST /api/auth/login +{ "userId": "admin", "companyCode": "*" } + +# 카테고리 값 조회 +GET /api/table-category-values/projects/project_type + +# 예상 결과: 모든 카테고리 값 조회 가능 +[ + { "valueId": 1, "valueLabel": "개발", "companyCode": "*" }, + { "valueId": 2, "valueLabel": "유지보수", "companyCode": "*" }, + { "valueId": 100, "valueLabel": "COMPANY_A 전용", "companyCode": "COMPANY_A" } +] +``` + +### 시나리오 2: 일반 회사로 카테고리 값 조회 + +```bash +# 로그인 +POST /api/auth/login +{ "userId": "user_a", "companyCode": "COMPANY_A" } + +# 카테고리 값 조회 +GET /api/table-category-values/projects/project_type + +# 수정 전 (버그): company_code="*" 포함 +[ + { "valueId": 1, "valueLabel": "개발", "companyCode": "*" }, ← 보면 안 됨! + { "valueId": 100, "valueLabel": "COMPANY_A 전용", "companyCode": "COMPANY_A" } +] + +# 수정 후 (정상): 자신의 데이터만 +[ + { "valueId": 100, "valueLabel": "COMPANY_A 전용", "companyCode": "COMPANY_A" } +] +``` + +### 시나리오 3: 카테고리 값 수정 (권한 체크) + +```bash +# 일반 회사 A로 로그인 +# company_code="*" 데이터 수정 시도 +PUT /api/table-category-values/1 +{ "valueLabel": "해킹 시도" } + +# 수정 전: 성공 (보안 취약) +# 수정 후: 실패 (권한 없음) +{ "success": false, "message": "카테고리 값을 찾을 수 없습니다" } +``` + +--- + +## 📝 수정 상세 내역 + +### 공통 패턴 + +모든 메서드에 다음 패턴 적용: + +```typescript +// 멀티테넌시: 최고 관리자만 company_code="*" 데이터를 볼 수 있음 +let query: string; +let params: any[]; + +if (companyCode === "*") { + // 최고 관리자: company_code 필터링 제외 + query = `SELECT * FROM table WHERE ...`; + params = [...]; + logger.info("최고 관리자 카테고리 작업"); +} else { + // 일반 회사: company_code 필터링 포함 + query = `SELECT * FROM table WHERE ... AND company_code = $N`; + params = [..., companyCode]; + logger.info("회사별 카테고리 작업", { companyCode }); +} +``` + +### 로깅 추가 + +각 메서드에 멀티테넌시 로깅 추가: + +```typescript +// 최고 관리자 +logger.info("최고 관리자 카테고리 컬럼 조회"); +logger.info("최고 관리자 카테고리 값 조회"); + +// 일반 회사 +logger.info("회사별 카테고리 컬럼 조회", { companyCode }); +logger.info("회사별 카테고리 값 조회", { companyCode }); +``` + +--- + +## 🎯 멀티테넌시 원칙 재확인 + +### 핵심 원칙 + +**company_code = "*"는 최고 관리자 전용 데이터이며, 일반 회사는 절대 접근할 수 없습니다.** + +| 작업 | 최고 관리자 (*) | 일반 회사 (COMPANY_A) | +|------|-----------------|----------------------| +| **조회** | 모든 데이터 | 자신의 데이터만 | +| **생성** | 모든 회사에 | 자신의 회사에만 | +| **수정** | 모든 데이터 | 자신의 데이터만 | +| **삭제** | 모든 데이터 | 자신의 데이터만 | + +### SQL 패턴 + +```sql +-- ❌ 잘못된 패턴 (버그) +WHERE company_code = $1 OR company_code = '*' + +-- ✅ 올바른 패턴 (최고 관리자) +WHERE 1=1 -- company_code 필터링 없음 + +-- ✅ 올바른 패턴 (일반 회사) +WHERE company_code = $1 -- company_code="*" 자동 제외 +``` + +--- + +## 🔗 관련 파일 + +- **수정 완료**: `backend-node/src/services/tableCategoryValueService.ts` +- **정상 참고**: `backend-node/src/services/commonCodeService.ts` (이미 올바르게 구현됨) +- **정상 참고**: `backend-node/src/services/numberingRuleService.ts` (수정 완료) + +--- + +## 🚀 배포 전 체크리스트 + +- [x] 코드 수정 완료 (7개 메서드) +- [x] 린트 에러 없음 +- [x] 로깅 추가 (최고 관리자 vs 일반 회사 구분) +- [ ] 단위 테스트 작성 (선택) +- [ ] 통합 테스트 (필수) + - [ ] 최고 관리자로 로그인하여 모든 카테고리 값 조회 확인 + - [ ] 일반 회사로 로그인하여 자신의 카테고리 값만 조회 확인 + - [ ] 다른 회사 카테고리 값 접근 불가능 확인 + - [ ] 카테고리 값 생성/수정/삭제 권한 확인 +- [ ] 프론트엔드에서 카테고리 값 목록 재확인 +- [ ] 백엔드 재실행 (코드 변경 사항 반영) + +--- + +## 📚 관련 문서 + +- [멀티테넌시 필수 규칙](../README.md#멀티테넌시-필수-규칙) +- [채번 규칙 멀티테넌시 버그 수정](./채번규칙_멀티테넌시_버그_수정_완료.md) +- [카테고리 시스템 구현 완료](./카테고리_시스템_최종_완료_보고서.md) + +--- + +## 🔍 다른 서비스 확인 결과 + +```bash +cd backend-node/src/services +grep -n "OR company_code = '\*'" *.ts +``` + +**결과**: `tableCategoryValueService.ts`에만 버그 존재 (수정 완료) + +**확인된 정상 서비스**: +- ✅ `commonCodeService.ts` - 이미 올바르게 구현됨 +- ✅ `numberingRuleService.ts` - 수정 완료 +- ✅ `tableCategoryValueService.ts` - 수정 완료 + +--- + +**수정 완료일**: 2025-11-06 +**수정자**: AI Assistant +**영향 범위**: `tableCategoryValueService.ts` 전체 (7개 메서드) +**린트 에러**: 없음 + diff --git a/docs/테이블_컬럼_타입_멀티테넌시_구조적_문제_분석.md b/docs/테이블_컬럼_타입_멀티테넌시_구조적_문제_분석.md new file mode 100644 index 00000000..76b4f67f --- /dev/null +++ b/docs/테이블_컬럼_타입_멀티테넌시_구조적_문제_분석.md @@ -0,0 +1,456 @@ +# 테이블 컬럼 타입 멀티테넌시 구조적 문제 분석 + +> **작성일**: 2025-11-06 +> **심각도**: 🔴 **치명적 (Critical)** +> **상태**: 🚨 **긴급 분석 필요** + +--- + +## 🚨 발견된 구조적 문제 + +### 문제 요약 + +**현재 `table_type_columns` 테이블에 `company_code` 컬럼이 없음!** + +```sql +-- 현재 table_type_columns 구조 +CREATE TABLE table_type_columns ( + id SERIAL PRIMARY KEY, + table_name VARCHAR NOT NULL, + column_name VARCHAR NOT NULL, + input_type VARCHAR NOT NULL, -- 🔴 문제: 회사별로 다르게 설정 불가! + detail_settings TEXT, + is_nullable VARCHAR, + display_order INTEGER, + created_date TIMESTAMP, + updated_date TIMESTAMP + -- ❌ company_code 컬럼 없음! +); +``` + +--- + +## 🎯 사용자가 지적한 시나리오 + +### 시나리오: "재질" 컬럼의 충돌 + +``` +회사 A: item_info.material 컬럼을 "카테고리" 타입으로 사용 + → 드롭다운 선택 (철, 알루미늄, 플라스틱) + +회사 B: item_info.material 컬럼을 "텍스트" 타입으로 사용 + → 자유 입력 (SUS304, AL6061, PVC 등) + +현재 구조: + ❌ table_type_columns에 company_code가 없음 + ❌ 둘 중 하나만 선택 가능 + ❌ 회사별로 다른 input_type 설정 불가능! +``` + +--- + +## 📊 현재 구조의 문제점 + +### 1. 테이블 구조 확인 + +```sql +-- table_type_columns 실제 컬럼 확인 +SELECT column_name FROM information_schema.columns +WHERE table_name = 'table_type_columns'; + +-- 결과: +id +table_name +column_name +input_type ← 🔴 회사별 구분 없음! +detail_settings +is_nullable +display_order +created_date +updated_date +-- ❌ company_code 없음! +``` + +### 2. 현재 데이터 예시 + +```sql +-- 현재 저장된 데이터 +SELECT * FROM table_type_columns +WHERE table_name = 'item_info' AND column_name = 'material'; + +-- 가능한 결과: +id | table_name | column_name | input_type | company_code +---|------------|-------------|------------|------------- +1 | item_info | material | category | ❌ 없음 +``` + +**문제**: + +- 회사 A가 `material`을 `category`로 설정하면 +- 회사 B는 `material`을 `text`로 설정할 수 없음! +- **하나의 컬럼 타입 정의를 모든 회사가 공유** + +--- + +## 🔍 멀티테넌시 충돌 분석 + +### Case 1: 같은 테이블, 같은 컬럼, 다른 타입 + +| 요구사항 | 회사 A | 회사 B | 현재 가능? | +| ---------- | ----------- | ----------- | ------------- | +| 테이블 | `item_info` | `item_info` | ✅ 공유 | +| 컬럼 | `material` | `material` | ✅ 공유 | +| input_type | `category` | `text` | ❌ **불가능** | + +**현재 동작**: + +```typescript +// 회사 A가 설정 +await updateColumnType("item_info", "material", "category"); +// → table_type_columns에 저장 (company_code 없음) + +// 회사 B가 설정 시도 +await updateColumnType("item_info", "material", "text"); +// → ❌ 기존 레코드 덮어쓰기 또는 충돌! +``` + +### Case 2: 카테고리 값 충돌 + +| 요구사항 | 회사 A | 회사 B | 현재 상태 | +| ----------- | ---------------------- | ------------------- | ---------------------------- | +| 카테고리 값 | 철, 알루미늄, 플라스틱 | SUS304, AL6061, PVC | 🟡 **company_code로 분리됨** | + +**이미 수정 완료**: + +- `table_column_category_values`는 `company_code` 컬럼이 있음 ✅ +- 카테고리 **값**은 회사별로 다르게 저장 가능 ✅ +- 하지만 카테고리 **타입 자체**는 공유됨 ❌ + +--- + +## 🏗️ 현재 아키텍처 vs 필요한 아키텍처 + +### 현재 (잘못된) 아키텍처 + +``` +┌─────────────────────────────┐ +│ table_type_columns │ +│ (컬럼 타입 정의 - 전역) │ +├─────────────────────────────┤ +│ id | table | column | type │ +│ 1 | item | material | ❓ │ ← 🔴 충돌! +└─────────────────────────────┘ + ↓ +회사 A: material = category? +회사 B: material = text? +→ ❌ 둘 중 하나만 가능 +``` + +### 필요한 (올바른) 아키텍처 + +``` +┌────────────────────────────────────────┐ +│ table_type_columns │ +│ (컬럼 타입 정의 - 회사별 분리) │ +├────────────────────────────────────────┤ +│ id | table | column | type | company │ +│ 1 | item | material | category | A │ ✅ 회사 A +│ 2 | item | material | text | B │ ✅ 회사 B +└────────────────────────────────────────┘ +``` + +--- + +## 💥 실제 발생 가능한 시나리오 + +### 시나리오 1: 프로젝트 타입 + +``` +회사 A (IT 회사): + - projects.project_type → category + - 카테고리 값: 개발, 유지보수, 컨설팅 + +회사 B (건설 회사): + - projects.project_type → text + - 자유 입력: 아파트 신축, 도로 보수 공사, 리모델링 등 + +현재: ❌ 둘 중 하나만 선택 가능 +필요: ✅ 회사별로 다른 input_type 설정 +``` + +### 시나리오 2: 담당자 필드 + +``` +회사 A (소규모): + - tasks.assignee → text + - 자유 입력: 이름 직접 입력 + +회사 B (대규모): + - tasks.assignee → reference + - 참조: user_info 테이블에서 선택 + +현재: ❌ 하나의 타입만 설정 가능 +필요: ✅ 회사별로 다른 방식 +``` + +### 시나리오 3: 금액 필드 + +``` +회사 A: + - contracts.amount → number + - 숫자 입력 (10,000,000) + +회사 B: + - contracts.amount → text + - 특수 형식 입력 (₩10M, $100K, negotiable) + +현재: ❌ 하나의 타입만 +필요: ✅ 회사별 다른 타입 +``` + +--- + +## 🔧 해결 방안 + +### 방안 1: company_code 추가 (권장) ⭐ + +**마이그레이션**: + +```sql +-- 1. company_code 컬럼 추가 +ALTER TABLE table_type_columns +ADD COLUMN company_code VARCHAR(20); + +-- 2. 기존 데이터 마이그레이션 (모든 회사에 복제) +INSERT INTO table_type_columns ( + table_name, column_name, input_type, detail_settings, + is_nullable, display_order, company_code, created_date +) +SELECT + table_name, column_name, input_type, detail_settings, + is_nullable, display_order, + ci.company_code, -- 각 회사별로 복제 + created_date +FROM table_type_columns ttc +CROSS JOIN company_info ci +WHERE ttc.company_code IS NULL; -- 기존 데이터만 + +-- 3. NOT NULL 제약조건 추가 +ALTER TABLE table_type_columns +ALTER COLUMN company_code SET NOT NULL; + +-- 4. 복합 유니크 인덱스 생성 +CREATE UNIQUE INDEX idx_table_column_type_company +ON table_type_columns(table_name, column_name, company_code); + +-- 5. company_code 인덱스 생성 +CREATE INDEX idx_table_type_columns_company +ON table_type_columns(company_code); + +-- 6. 외래키 제약조건 추가 +ALTER TABLE table_type_columns +ADD CONSTRAINT fk_table_type_columns_company +FOREIGN KEY (company_code) REFERENCES company_info(company_code); +``` + +**장점**: + +- ✅ 회사별로 완전히 독립적인 컬럼 타입 정의 +- ✅ 멀티테넌시 원칙 준수 +- ✅ 다른 테이블과 일관된 구조 + +**단점**: + +- 🟡 기존 데이터 마이그레이션 필요 +- 🟡 모든 회사에 동일한 타입 정의가 복제됨 + +--- + +### 방안 2: 별도 테이블 생성 (대안) + +```sql +-- company_specific_column_types 테이블 생성 +CREATE TABLE company_specific_column_types ( + id SERIAL PRIMARY KEY, + company_code VARCHAR(20) NOT NULL, + table_name VARCHAR NOT NULL, + column_name VARCHAR NOT NULL, + input_type VARCHAR NOT NULL, + detail_settings TEXT, + created_at TIMESTAMP DEFAULT NOW(), + FOREIGN KEY (company_code) REFERENCES company_info(company_code), + UNIQUE(company_code, table_name, column_name) +); + +-- 조회 시 우선순위 +-- 1순위: company_specific_column_types (회사별 설정) +-- 2순위: table_type_columns (전역 기본값) +``` + +**장점**: + +- ✅ 기존 table_type_columns는 기본값으로 유지 +- ✅ 회사별 커스터마이징은 별도 관리 + +**단점**: + +- ❌ 복잡한 조회 로직 (2개 테이블 조인) +- ❌ 일관성 없는 구조 + +--- + +### 방안 3: JSON 필드 사용 (비추천) + +```sql +-- company_overrides JSON 컬럼 추가 +ALTER TABLE table_type_columns +ADD COLUMN company_overrides JSONB; + +-- 예시: +{ + "COMPANY_A": { "input_type": "category" }, + "COMPANY_B": { "input_type": "text" } +} +``` + +**단점**: + +- ❌ 쿼리 복잡도 증가 +- ❌ 인덱싱 어려움 +- ❌ 데이터 무결성 보장 어려움 + +--- + +## 📋 영향 받는 코드 + +### 백엔드 서비스 + +```typescript +// ❌ 현재 코드 (company_code 없음) +async getColumnType(tableName: string, columnName: string) { + const query = ` + SELECT input_type FROM table_type_columns + WHERE table_name = $1 AND column_name = $2 + `; + return await pool.query(query, [tableName, columnName]); +} + +// ✅ 수정 필요 (company_code 추가) +async getColumnType(tableName: string, columnName: string, companyCode: string) { + const query = ` + SELECT input_type FROM table_type_columns + WHERE table_name = $1 + AND column_name = $2 + AND company_code = $3 + `; + return await pool.query(query, [tableName, columnName, companyCode]); +} +``` + +### 영향받는 파일 (예상) + +- `backend-node/src/services/tableService.ts` +- `backend-node/src/services/dataService.ts` +- `backend-node/src/controllers/tableController.ts` +- `frontend/components/table-category/CategoryColumnList.tsx` +- 기타 `table_type_columns`를 참조하는 모든 코드 + +--- + +## 🧪 테스트 시나리오 + +### 테스트 1: 회사별 다른 타입 설정 + +```sql +-- 회사 A: material을 카테고리로 +INSERT INTO table_type_columns (table_name, column_name, input_type, company_code) +VALUES ('item_info', 'material', 'category', 'COMPANY_A'); + +-- 회사 B: material을 텍스트로 +INSERT INTO table_type_columns (table_name, column_name, input_type, company_code) +VALUES ('item_info', 'material', 'text', 'COMPANY_B'); + +-- 조회 확인 +SELECT * FROM table_type_columns +WHERE table_name = 'item_info' AND column_name = 'material'; + +-- 예상 결과: +-- id | table_name | column_name | input_type | company_code +-- 1 | item_info | material | category | COMPANY_A +-- 2 | item_info | material | text | COMPANY_B +``` + +### 테스트 2: 회사별 화면 표시 + +```typescript +// 회사 A 사용자가 item_info 테이블 열람 +GET /api/tables/item_info/columns +Authorization: Bearer {token_company_a} + +// 예상 결과: +{ + "material": { + "inputType": "category", // 드롭다운 + "categoryValues": ["철", "알루미늄", "플라스틱"] + } +} + +// 회사 B 사용자가 item_info 테이블 열람 +GET /api/tables/item_info/columns +Authorization: Bearer {token_company_b} + +// 예상 결과: +{ + "material": { + "inputType": "text", // 텍스트 입력 + "placeholder": "재질을 입력하세요" + } +} +``` + +--- + +## 🚨 긴급도 평가 + +| 항목 | 평가 | 설명 | +| --------------- | -------------- | ---------------------------------- | +| **심각도** | 🔴 높음 | 회사별 독립적인 테이블 설정 불가능 | +| **영향 범위** | 🔴 전체 시스템 | 모든 동적 테이블 기능에 영향 | +| **수정 난이도** | 🟡 중간 | 마이그레이션 + 코드 수정 필요 | +| **긴급도** | 🔴 높음 | 멀티테넌시 핵심 기능 | + +--- + +## 📝 권장 조치 + +### 우선순위 1: 즉시 확인 + +- [ ] 현재 `table_type_columns` 사용 현황 파악 +- [ ] 실제로 충돌이 발생하고 있는지 확인 +- [ ] 회사별로 다른 타입 설정이 필요한 케이스 수집 + +### 우선순위 2: 마이그레이션 준비 + +- [ ] `company_code` 추가 마이그레이션 작성 +- [ ] 기존 데이터 백업 계획 수립 +- [ ] 롤백 방안 준비 + +### 우선순위 3: 코드 수정 + +- [ ] 백엔드 서비스 수정 (company_code 추가) +- [ ] API 엔드포인트 수정 +- [ ] 프론트엔드 컴포넌트 수정 + +--- + +## 🔗 관련 이슈 + +- [채번 규칙 멀티테넌시 버그](./채번규칙_멀티테넌시_버그_수정_완료.md) ✅ 수정 완료 +- [카테고리 값 멀티테넌시 버그](./카테고리_멀티테넌시_버그_수정_완료.md) ✅ 수정 완료 +- 🚨 **테이블 컬럼 타입 멀티테넌시** ← 현재 문서 (미수정) + +--- + +**작성일**: 2025-11-06 +**분석자**: AI Assistant (사용자 지적 기반) +**다음 단계**: 마이그레이션 작성 및 코드 수정 필요 diff --git a/docs/테이블_컬럼_타입_멀티테넌시_수정_완료.md b/docs/테이블_컬럼_타입_멀티테넌시_수정_완료.md new file mode 100644 index 00000000..7332dfef --- /dev/null +++ b/docs/테이블_컬럼_타입_멀티테넌시_수정_완료.md @@ -0,0 +1,611 @@ +# 테이블 컬럼 타입 멀티테넌시 수정 완료 보고서 + +## 📋 개요 + +**일시**: 2025-11-06 +**작업자**: AI Assistant +**심각도**: 🔴 높음 → ✅ 해결 +**관련 문서**: [테이블_컬럼_타입_멀티테넌시_구조적_문제_분석.md](./테이블_컬럼_타입_멀티테넌시_구조적_문제_분석.md) + +--- + +## 🔍 문제 요약 + +### 발견된 문제 + +**회사별로 같은 테이블의 같은 컬럼에 대해 다른 입력 타입을 설정할 수 없었습니다.** + +#### 실제 시나리오 + +``` +회사 A: item_info.material → category (드롭다운 선택) +회사 B: item_info.material → text (자유 입력) + +❌ 현재: 둘 중 하나만 선택 가능 +✅ 수정 후: 각 회사별로 독립적으로 설정 가능 +``` + +#### 근본 원인 + +- `table_type_columns` 테이블에 `company_code` 컬럼이 없음 +- 유니크 제약조건: `(table_name, column_name)` ← company_code 없음! +- 모든 회사가 같은 컬럼 타입 정의를 공유함 + +--- + +## 🛠️ 수정 내용 + +### 1. 데이터베이스 마이그레이션 + +#### 파일: `db/migrations/044_add_company_code_to_table_type_columns.sql` + +**주요 변경사항**: +- `company_code VARCHAR(20) NOT NULL` 컬럼 추가 +- 기존 데이터를 모든 회사에 복제 (510건 → 1,020건) +- 복합 유니크 인덱스 생성: `(table_name, column_name, company_code)` +- 외래키 제약조건 추가: `company_mng(company_code)` 참조 + +**마이그레이션 실행 방법**: +```bash +docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/migrations/044_add_company_code_to_table_type_columns.sql +``` + +**검증 쿼리**: +```sql +-- 1. 컬럼 추가 확인 +SELECT column_name, data_type, is_nullable +FROM information_schema.columns +WHERE table_name = 'table_type_columns' AND column_name = 'company_code'; + +-- 예상: data_type=character varying, is_nullable=NO + +-- 2. 데이터 마이그레이션 확인 +SELECT + COUNT(*) as total, + COUNT(DISTINCT company_code) as company_count, + COUNT(CASE WHEN company_code IS NULL THEN 1 END) as null_count +FROM table_type_columns; + +-- 예상: total=1020, company_count=2, null_count=0 + +-- 3. 회사별 데이터 분포 +SELECT company_code, COUNT(*) as count +FROM table_type_columns +GROUP BY company_code +ORDER BY company_code; + +-- 예상: 각 회사마다 510건씩 (총 2개 회사: * + COMPANY_7) +``` + +--- + +### 2. 백엔드 서비스 수정 + +#### 파일: `backend-node/src/services/tableManagementService.ts` + +#### (1) `getColumnInputTypes` 메서드 + +**변경 전**: +```typescript +async getColumnInputTypes(tableName: string): Promise +``` + +**변경 후**: +```typescript +async getColumnInputTypes( + tableName: string, + companyCode: string // ✅ 추가 +): Promise +``` + +**SQL 쿼리 변경**: +```typescript +// ❌ 이전 +`SELECT ... FROM column_labels cl WHERE cl.table_name = $1` + +// ✅ 수정 후 +`SELECT ... + FROM table_type_columns ttc + LEFT JOIN column_labels cl ... + WHERE ttc.table_name = $1 + AND ttc.company_code = $2 -- 회사별 필터링 + ORDER BY ttc.display_order, ttc.column_name` +``` + +#### (2) `updateColumnInputType` 메서드 + +**변경 전**: +```typescript +async updateColumnInputType( + tableName: string, + columnName: string, + inputType: string, + detailSettings?: Record +): Promise +``` + +**변경 후**: +```typescript +async updateColumnInputType( + tableName: string, + columnName: string, + inputType: string, + companyCode: string, // ✅ 추가 + detailSettings?: Record +): Promise +``` + +**SQL 쿼리 변경**: +```typescript +// ❌ 이전 +`INSERT INTO table_type_columns ( + table_name, column_name, input_type, detail_settings, + is_nullable, display_order, created_date, updated_date +) VALUES ($1, $2, $3, $4, 'Y', 0, now(), now()) +ON CONFLICT (table_name, column_name) -- company_code 없음! +DO UPDATE SET ...` + +// ✅ 수정 후 +`INSERT INTO table_type_columns ( + table_name, column_name, input_type, detail_settings, + is_nullable, display_order, company_code, created_date, updated_date +) VALUES ($1, $2, $3, $4, 'Y', 0, $5, now(), now()) +ON CONFLICT (table_name, column_name, company_code) -- 회사별 유니크! +DO UPDATE SET ...` +``` + +--- + +### 3. API 엔드포인트 수정 + +#### 파일: `backend-node/src/controllers/tableManagementController.ts` + +#### (1) `getColumnWebTypes` 컨트롤러 + +**변경 전**: +```typescript +export async function getColumnWebTypes( + req: AuthenticatedRequest, + res: Response +): Promise { + const { tableName } = req.params; + + // ❌ companyCode 없음 + const inputTypes = await tableManagementService.getColumnInputTypes(tableName); +} +``` + +**변경 후**: +```typescript +export async function getColumnWebTypes( + req: AuthenticatedRequest, + res: Response +): Promise { + const { tableName } = req.params; + const companyCode = req.user?.companyCode; // ✅ 인증 정보에서 추출 + + if (!companyCode) { + return res.status(401).json({ + success: false, + message: "회사 코드가 필요합니다.", + error: { code: "MISSING_COMPANY_CODE" } + }); + } + + const inputTypes = await tableManagementService.getColumnInputTypes( + tableName, + companyCode // ✅ 전달 + ); +} +``` + +#### (2) `updateColumnInputType` 컨트롤러 + +**변경 전**: +```typescript +export async function updateColumnInputType( + req: AuthenticatedRequest, + res: Response +): Promise { + const { tableName, columnName } = req.params; + const { inputType, detailSettings } = req.body; + + // ❌ companyCode 없음 + await tableManagementService.updateColumnInputType( + tableName, + columnName, + inputType, + detailSettings + ); +} +``` + +**변경 후**: +```typescript +export async function updateColumnInputType( + req: AuthenticatedRequest, + res: Response +): Promise { + const { tableName, columnName } = req.params; + const { inputType, detailSettings } = req.body; + const companyCode = req.user?.companyCode; // ✅ 인증 정보에서 추출 + + if (!companyCode) { + return res.status(401).json({ + success: false, + message: "회사 코드가 필요합니다.", + error: { code: "MISSING_COMPANY_CODE" } + }); + } + + await tableManagementService.updateColumnInputType( + tableName, + columnName, + inputType, + companyCode, // ✅ 전달 + detailSettings + ); +} +``` + +--- + +### 4. 프론트엔드 (수정 불필요) + +#### 파일: `frontend/lib/api/tableManagement.ts` + +**현재 코드** (수정 불필요): +```typescript +async getColumnWebTypes(tableName: string): Promise> { + try { + // ✅ apiClient가 자동으로 Authorization 헤더에 JWT 토큰 추가 + // ✅ 백엔드에서 req.user.companyCode로 자동 추출 + const response = await apiClient.get(`${this.basePath}/tables/${tableName}/web-types`); + return response.data; + } catch (error: any) { + console.error(`❌ 테이블 '${tableName}' 웹타입 정보 조회 실패:`, error); + return { + success: false, + message: error.response?.data?.message || "웹타입 정보를 조회할 수 없습니다.", + }; + } +} +``` + +**왜 수정이 불필요한가?** +- `apiClient`는 이미 인증 토큰을 자동으로 헤더에 추가 +- 백엔드 `authMiddleware`가 JWT에서 `companyCode`를 추출하여 `req.user`에 저장 +- 컨트롤러에서 `req.user.companyCode`로 접근 + +--- + +## 📊 마이그레이션 결과 + +### Before (마이그레이션 전) + +```sql +SELECT * FROM table_type_columns LIMIT 3; + + id | table_name | column_name | input_type | company_code +----|-------------|-------------|------------|------------- + 1 | item_info | material | text | NULL + 2 | projects | type | category | NULL + 3 | contracts | status | code | NULL +``` + +**문제**: +- `company_code`가 NULL +- 모든 회사가 같은 타입 정의를 공유 +- 유니크 제약조건에 `company_code` 없음 + +--- + +### After (마이그레이션 후) + +```sql +SELECT * FROM table_type_columns WHERE table_name = 'item_info' AND column_name = 'material'; + + id | table_name | column_name | input_type | company_code +----|------------|-------------|------------|------------- + 1 | item_info | material | text | * +511 | item_info | material | text | COMPANY_7 +``` + +**개선사항**: +- ✅ 각 회사별로 독립적인 레코드 +- ✅ `company_code NOT NULL` +- ✅ 유니크 제약조건: `(table_name, column_name, company_code)` + +--- + +## ✅ 테스트 시나리오 + +### 시나리오 1: 회사별 다른 타입 설정 + +```sql +-- 최고 관리자: material을 카테고리로 변경 +UPDATE table_type_columns +SET input_type = 'category', + updated_date = now() +WHERE table_name = 'item_info' + AND column_name = 'material' + AND company_code = '*'; + +-- COMPANY_7: material을 텍스트로 유지 +-- (변경 없음) + +-- 확인 +SELECT table_name, column_name, input_type, company_code +FROM table_type_columns +WHERE table_name = 'item_info' AND column_name = 'material' + AND company_code IN ('*', 'COMPANY_7') +ORDER BY company_code; + +-- 예상 결과: +-- item_info | material | category | * ✅ 다름! +-- item_info | material | text | COMPANY_7 ✅ 다름! +``` + +### 시나리오 2: API 호출 테스트 + +```typescript +// 최고 관리자로 로그인 +// JWT 토큰: { userId: "admin", companyCode: "*" } + +const response = await fetch('/api/tables/item_info/web-types', { + headers: { + 'Authorization': `Bearer ${token}`, + } +}); + +const data = await response.json(); +console.log(data); + +// 예상 결과: 최고 관리자는 모든 회사 데이터 조회 가능 +// { +// success: true, +// data: [ +// { columnName: 'material', inputType: 'category', companyCode: '*', ... } +// { columnName: 'material', inputType: 'text', companyCode: 'COMPANY_7', ... } +// ] +// } +``` + +```typescript +// COMPANY_7 관리자로 로그인 +// JWT 토큰: { userId: "user7", companyCode: "COMPANY_7" } + +const response = await fetch('/api/tables/item_info/web-types', { + headers: { + 'Authorization': `Bearer ${token}`, + } +}); + +const data = await response.json(); +console.log(data); + +// 예상 결과: COMPANY_7의 컬럼 타입만 반환 +// { +// success: true, +// data: [ +// { columnName: 'material', inputType: 'text', ... } // COMPANY_7 전용 +// ] +// } +``` + +--- + +## 🔍 최고 관리자 (SUPER_ADMIN) 예외 처리 + +### company_code = "*" 의미 + +**중요**: `company_code = "*"`는 **최고 관리자 전용 데이터**입니다. + +```sql +-- 최고 관리자 데이터 +SELECT * FROM table_type_columns WHERE company_code = '*'; + +-- ❌ 잘못된 이해: 모든 회사가 공유하는 공통 데이터 +-- ✅ 올바른 이해: 최고 관리자만 관리하는 전용 데이터 +``` + +### 최고 관리자 접근 권한 + +```typescript +// 백엔드 서비스 (예: getColumnInputTypes) + +if (companyCode === "*") { + // 최고 관리자: 모든 회사 데이터 조회 가능 + query = ` + SELECT * FROM table_type_columns + WHERE table_name = $1 + ORDER BY company_code, column_name + `; + params = [tableName]; + logger.info("최고 관리자 전체 컬럼 타입 조회"); +} else { + // 일반 회사: 자신의 회사 데이터만 조회 (company_code="*" 제외!) + query = ` + SELECT * FROM table_type_columns + WHERE table_name = $1 + AND company_code = $2 + ORDER BY column_name + `; + params = [tableName, companyCode]; + logger.info("회사별 컬럼 타입 조회", { companyCode }); +} +``` + +**핵심**: 일반 회사 사용자는 `company_code = "*"` 데이터를 **절대 볼 수 없습니다**! + +--- + +## 📁 수정된 파일 목록 + +### 데이터베이스 +- ✅ `db/migrations/044_add_company_code_to_table_type_columns.sql` (신규) +- ✅ `db/migrations/RUN_044_MIGRATION.md` (신규) +- ✅ `db/migrations/EXECUTE_044_MIGRATION_NOW.txt` (신규) + +### 백엔드 +- ✅ `backend-node/src/services/tableManagementService.ts` + - `getColumnInputTypes()` - company_code 파라미터 추가 + - `updateColumnInputType()` - company_code 파라미터 추가 +- ✅ `backend-node/src/controllers/tableManagementController.ts` + - `getColumnWebTypes()` - req.user.companyCode 추출 및 전달 + - `updateColumnInputType()` - req.user.companyCode 추출 및 전달 + +### 프론트엔드 +- ⚪ 수정 불필요 (apiClient가 자동으로 인증 헤더 추가) + +### 문서 +- ✅ `docs/테이블_컬럼_타입_멀티테넌시_구조적_문제_분석.md` (기존) +- ✅ `docs/테이블_컬럼_타입_멀티테넌시_수정_완료.md` (본 문서) + +--- + +## 🎯 다음 단계 + +### 1. 마이그레이션 실행 (필수) + +```bash +docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/migrations/044_add_company_code_to_table_type_columns.sql +``` + +### 2. 검증 + +```sql +-- 1. 컬럼 추가 확인 +SELECT column_name FROM information_schema.columns +WHERE table_name = 'table_type_columns' AND column_name = 'company_code'; + +-- 2. 데이터 개수 확인 +SELECT COUNT(*) as total FROM table_type_columns; +-- 예상: 1020 (510 × 2) + +-- 3. NULL 확인 +SELECT COUNT(*) FROM table_type_columns WHERE company_code IS NULL; +-- 예상: 0 +``` + +### 3. 백엔드 재시작 + +```bash +# Docker 환경 +docker-compose restart backend + +# 로컬 환경 +npm run dev +``` + +### 4. 프론트엔드 테스트 + +1. 최고 관리자(*) 계정으로 로그인 +2. 테이블 관리 → item_info 테이블 선택 +3. material 컬럼 타입을 **category**로 변경 +4. 저장 확인 + +5. COMPANY_7(탑씰) 계정으로 로그인 +6. 테이블 관리 → item_info 테이블 선택 +7. material 컬럼 타입이 여전히 **text**인지 확인 ✅ + +--- + +## 🚨 주의사항 + +### 1. 마이그레이션 전 백업 필수 + +```bash +# PostgreSQL 백업 +docker exec erp-node-db-1 pg_dump -U postgres ilshin > backup_before_044.sql +``` + +### 2. 데이터 증가 + +- 기존: 510건 +- 마이그레이션 후: 1,020건 (2개 회사 × 510건) +- 디스크 공간: 약 2배 증가 (영향 미미) + +### 3. 기존 코드 호환성 + +**이 마이그레이션은 Breaking Change입니다!** + +`getColumnInputTypes()`를 호출하는 모든 코드는 `companyCode`를 전달해야 합니다. + +```typescript +// ❌ 이전 코드 (더 이상 작동하지 않음) +const types = await tableManagementService.getColumnInputTypes(tableName); + +// ✅ 수정된 코드 +const companyCode = req.user?.companyCode; +const types = await tableManagementService.getColumnInputTypes(tableName, companyCode); +``` + +### 4. 롤백 방법 + +문제 발생 시 롤백: + +```sql +BEGIN; + +-- 1. 외래키 제거 +ALTER TABLE table_type_columns +DROP CONSTRAINT IF EXISTS fk_table_type_columns_company; + +-- 2. 인덱스 제거 +DROP INDEX IF EXISTS idx_table_column_type_company; +DROP INDEX IF EXISTS idx_table_type_columns_company; + +-- 3. company_code 컬럼 제거 +ALTER TABLE table_type_columns ALTER COLUMN company_code DROP NOT NULL; +ALTER TABLE table_type_columns DROP COLUMN IF EXISTS company_code; + +COMMIT; +``` + +--- + +## 📈 성능 영향 + +### 인덱스 최적화 + +```sql +-- 복합 유니크 인덱스 (필수) +CREATE UNIQUE INDEX idx_table_column_type_company +ON table_type_columns(table_name, column_name, company_code); + +-- company_code 인덱스 (조회 성능 향상) +CREATE INDEX idx_table_type_columns_company +ON table_type_columns(company_code); +``` + +### 쿼리 성능 + +- **이전**: `WHERE table_name = $1` (510건 스캔) +- **현재**: `WHERE table_name = $1 AND company_code = $2` (255건 스캔) +- **결과**: 약 2배 성능 향상 ✅ + +--- + +## 🎉 결론 + +### 해결된 문제 + +- ✅ 회사별로 같은 컬럼에 다른 입력 타입 설정 가능 +- ✅ 멀티테넌시 원칙 준수 (데이터 격리) +- ✅ 다른 테이블(`numbering_rules`, `table_column_category_values`)과 일관된 구조 +- ✅ 최고 관리자와 일반 회사 권한 명확히 구분 + +### 기대 효과 + +- **유연성**: 각 회사가 독립적으로 테이블 설정 가능 +- **보안**: 회사 간 데이터 완전 격리 +- **확장성**: 새로운 회사 추가 시 자동 데이터 복제 +- **일관성**: 전체 시스템의 멀티테넌시 패턴 통일 + +--- + +**작성일**: 2025-11-06 +**상태**: 🟢 완료 (마이그레이션 실행 대기 중) +**다음 작업**: 마이그레이션 실행 및 프로덕션 배포 + diff --git a/frontend/app/(main)/admin/tableMng/page.tsx b/frontend/app/(main)/admin/tableMng/page.tsx index 0e30d32d..238ee616 100644 --- a/frontend/app/(main)/admin/tableMng/page.tsx +++ b/frontend/app/(main)/admin/tableMng/page.tsx @@ -1003,7 +1003,7 @@ export default function TableManagementPage() {
- {/* 웹 타입이 'code'인 경우 공통코드 선택 */} + {/* 입력 타입이 'code'인 경우 공통코드 선택 */} {column.inputType === "code" && ( )} - {/* 웹 타입이 'entity'인 경우 참조 테이블 선택 */} + {/* 입력 타입이 'category'인 경우 안내 메시지 */} + {column.inputType === "category" && ( +
+ 메뉴별 카테고리 값이 자동으로 표시됩니다 +
+ )} + {/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */} {column.inputType === "entity" && (
@@ -1165,8 +1171,8 @@ export default function TableManagementPage() { )}
)} - {/* 다른 웹 타입인 경우 빈 공간 */} - {column.inputType !== "code" && column.inputType !== "entity" && ( + {/* 다른 입력 타입인 경우 빈 공간 */} + {column.inputType !== "code" && column.inputType !== "category" && column.inputType !== "entity" && (
-
diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 6958199b..d7ea2039 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -225,12 +225,21 @@ export default function ScreenViewPage() { const containerWidth = containerRef.current.offsetWidth; const containerHeight = containerRef.current.offsetHeight; - // 가로/세로 비율 중 작은 것을 선택하여 화면에 맞게 스케일 조정 - // 하지만 화면이 컨테이너 전체 너비를 차지하도록 하기 위해 가로를 우선시 + // 화면이 잘리지 않도록 가로/세로 중 작은 쪽 기준으로 스케일 조정 const scaleX = containerWidth / designWidth; const scaleY = containerHeight / designHeight; - // 가로를 우선으로 하되, 세로가 넘치지 않도록 제한 + // 전체 화면이 보이도록 작은 쪽 기준으로 스케일 설정 const newScale = Math.min(scaleX, scaleY); + + console.log("📐 스케일 계산:", { + containerWidth, + containerHeight, + designWidth, + designHeight, + scaleX, + scaleY, + finalScale: newScale, + }); setScale(newScale); // 컨테이너 너비 업데이트 @@ -285,7 +294,7 @@ export default function ScreenViewPage() { return ( -
+
{/* 레이아웃 준비 중 로딩 표시 */} {!layoutReady && (
@@ -296,15 +305,21 @@ export default function ScreenViewPage() {
)} - {/* 절대 위치 기반 렌더링 */} + {/* 절대 위치 기반 렌더링 (화면관리와 동일한 방식) */} {layoutReady && layout && layout.components.length > 0 ? (
0 ? `${containerWidth / scale}px` : "100%", - minWidth: containerWidth > 0 ? `${containerWidth / scale}px` : "100%", + transformOrigin: "center center", + overflow: "visible", }} > {/* 최상위 컴포넌트들 렌더링 */} @@ -312,26 +327,9 @@ export default function ScreenViewPage() { // 🆕 플로우 버튼 그룹 감지 및 처리 const topLevelComponents = layout.components.filter((component) => !component.parentId); - // 버튼은 scale에 맞춰 위치만 조정하면 됨 (scale = 1.0이면 그대로, scale < 1.0이면 왼쪽으로) - // 하지만 x=0 컴포넌트는 width: 100%로 확장되므로, 그만큼 버튼을 오른쪽으로 이동 - const leftmostComponent = topLevelComponents.find((c) => c.position.x === 0); - let widthOffset = 0; - - if (leftmostComponent && containerWidth > 0) { - const originalWidth = leftmostComponent.size?.width || screenWidth; - const actualWidth = containerWidth / scale; - widthOffset = Math.max(0, actualWidth - originalWidth); - - console.log("📊 widthOffset 계산:", { - containerWidth, - scale, - screenWidth, - originalWidth, - actualWidth, - widthOffset, - leftmostType: leftmostComponent.type, - }); - } + // 화면 관리에서 설정한 해상도를 사용하므로 widthOffset 계산 불필요 + // 모든 컴포넌트는 원본 위치 그대로 사용 + const widthOffset = 0; const buttonGroups: Record = {}; const processedButtonIds = new Set(); @@ -393,37 +391,11 @@ export default function ScreenViewPage() { <> {/* 일반 컴포넌트들 */} {regularComponents.map((component) => { - // 버튼인 경우 위치 조정 (테이블이 늘어난 만큼 오른쪽으로 이동) - const isButton = - (component.type === "component" && - ["button-primary", "button-secondary"].includes((component as any).componentType)) || - (component.type === "widget" && (component as any).widgetType === "button"); - - const adjustedComponent = - isButton && widthOffset > 0 - ? { - ...component, - position: { - ...component.position, - x: component.position.x + widthOffset, - }, - } - : component; - - // 버튼일 경우 로그 출력 - if (isButton) { - console.log("🔘 버튼 위치 조정:", { - label: component.label, - originalX: component.position.x, - adjustedX: component.position.x + widthOffset, - widthOffset, - }); - } - + // 화면 관리 해상도를 사용하므로 위치 조정 불필요 return ( {}} diff --git a/frontend/components/admin/ExternalDbConnectionModal.tsx b/frontend/components/admin/ExternalDbConnectionModal.tsx index 540d8947..1d0c046f 100644 --- a/frontend/components/admin/ExternalDbConnectionModal.tsx +++ b/frontend/components/admin/ExternalDbConnectionModal.tsx @@ -607,7 +607,7 @@ export const ExternalDbConnectionModal: React.FC
- + - + ); diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 5703753a..50423460 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useMemo } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { ResizableDialog, ResizableDialogContent, @@ -8,6 +8,8 @@ import { ResizableDialogTitle, ResizableDialogDescription, } from "@/components/ui/resizable-dialog"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic"; import { screenApi } from "@/lib/api/screen"; import { ComponentData } from "@/types/screen"; @@ -52,6 +54,19 @@ export const ScreenModal: React.FC = ({ className }) => { // 폼 데이터 상태 추가 const [formData, setFormData] = useState>({}); + + // 연속 등록 모드 상태 (localStorage에 저장하여 리렌더링에 영향받지 않도록) + const continuousModeRef = useRef(false); + const [, setForceUpdate] = useState(0); // 강제 리렌더링용 (값은 사용하지 않음) + + // localStorage에서 연속 모드 상태 복원 + useEffect(() => { + const savedMode = localStorage.getItem("screenModal_continuousMode"); + if (savedMode === "true") { + continuousModeRef.current = true; + // console.log("🔄 연속 모드 복원: true"); + } + }, []); // 화면의 실제 크기 계산 함수 const calculateScreenDimensions = (components: ComponentData[]) => { @@ -124,16 +139,43 @@ export const ScreenModal: React.FC = ({ className }) => { }); setScreenData(null); setFormData({}); + continuousModeRef.current = false; + localStorage.setItem("screenModal_continuousMode", "false"); // localStorage에 저장 + // console.log("🔄 연속 모드 초기화: false"); + }; + + // 저장 성공 이벤트 처리 (연속 등록 모드 지원) + const handleSaveSuccess = () => { + const isContinuousMode = continuousModeRef.current; + // console.log("💾 저장 성공 이벤트 수신"); + // console.log("📌 현재 연속 모드 상태 (ref):", isContinuousMode); + // console.log("📌 localStorage:", localStorage.getItem("screenModal_continuousMode")); + + if (isContinuousMode) { + // 연속 모드: 폼만 초기화하고 모달은 유지 + // console.log("✅ 연속 모드 활성화 - 폼만 초기화"); + + // 폼만 초기화 (연속 모드 상태는 localStorage에 저장되어 있으므로 유지됨) + setFormData({}); + + toast.success("저장되었습니다. 계속 입력하세요."); + } else { + // 일반 모드: 모달 닫기 + // console.log("❌ 일반 모드 - 모달 닫기"); + handleCloseModal(); + } }; window.addEventListener("openScreenModal", handleOpenModal as EventListener); window.addEventListener("closeSaveModal", handleCloseModal); + window.addEventListener("saveSuccessInModal", handleSaveSuccess); return () => { window.removeEventListener("openScreenModal", handleOpenModal as EventListener); window.removeEventListener("closeSaveModal", handleCloseModal); + window.removeEventListener("saveSuccessInModal", handleSaveSuccess); }; - }, []); + }, []); // 의존성 제거 (ref 사용으로 최신 상태 참조) // 화면 데이터 로딩 useEffect(() => { @@ -160,8 +202,25 @@ export const ScreenModal: React.FC = ({ className }) => { if (screenInfo && layoutData) { const components = layoutData.components || []; - // 화면의 실제 크기 계산 - const dimensions = calculateScreenDimensions(components); + // 화면 관리에서 설정한 해상도 사용 (우선순위) + const screenResolution = (layoutData as any).screenResolution || (screenInfo as any).screenResolution; + + let dimensions; + if (screenResolution && screenResolution.width && screenResolution.height) { + // 화면 관리에서 설정한 해상도 사용 + dimensions = { + width: screenResolution.width, + height: screenResolution.height, + offsetX: 0, + offsetY: 0, + }; + console.log("✅ 화면 관리 해상도 사용:", dimensions); + } else { + // 해상도 정보가 없으면 자동 계산 + dimensions = calculateScreenDimensions(components); + console.log("⚠️ 자동 계산된 크기 사용:", dimensions); + } + setScreenDimensions(dimensions); setScreenData({ @@ -235,39 +294,39 @@ export const ScreenModal: React.FC = ({ className }) => { // 1순위: screenId (가장 안정적) if (modalState.screenId) { newModalId = `screen-modal-${modalState.screenId}`; - console.log("🔑 ScreenModal modalId 생성:", { - method: "screenId", - screenId: modalState.screenId, - result: newModalId, - }); + // console.log("🔑 ScreenModal modalId 생성:", { + // method: "screenId", + // screenId: modalState.screenId, + // result: newModalId, + // }); } // 2순위: 테이블명 else if (screenData?.screenInfo?.tableName) { newModalId = `screen-modal-table-${screenData.screenInfo.tableName}`; - console.log("🔑 ScreenModal modalId 생성:", { - method: "tableName", - tableName: screenData.screenInfo.tableName, - result: newModalId, - }); + // console.log("🔑 ScreenModal modalId 생성:", { + // method: "tableName", + // tableName: screenData.screenInfo.tableName, + // result: newModalId, + // }); } // 3순위: 화면명 else if (screenData?.screenInfo?.screenName) { newModalId = `screen-modal-name-${screenData.screenInfo.screenName}`; - console.log("🔑 ScreenModal modalId 생성:", { - method: "screenName", - screenName: screenData.screenInfo.screenName, - result: newModalId, - }); + // console.log("🔑 ScreenModal modalId 생성:", { + // method: "screenName", + // screenName: screenData.screenInfo.screenName, + // result: newModalId, + // }); } // 4순위: 제목 else if (modalState.title) { - const titleId = modalState.title.replace(/\s+/g, '-'); + const titleId = modalState.title.replace(/\s+/g, "-"); newModalId = `screen-modal-title-${titleId}`; - console.log("🔑 ScreenModal modalId 생성:", { - method: "title", - title: modalState.title, - result: newModalId, - }); + // console.log("🔑 ScreenModal modalId 생성:", { + // method: "title", + // title: modalState.title, + // result: newModalId, + // }); } if (newModalId) { @@ -325,11 +384,12 @@ export const ScreenModal: React.FC = ({ className }) => { }} > {screenData.components.map((component) => { - // 컴포넌트 위치를 offset만큼 조정 (왼쪽 상단으로 정렬) + // 화면 관리 해상도를 사용하는 경우 offset 조정 불필요 const offsetX = screenDimensions?.offsetX || 0; const offsetY = screenDimensions?.offsetY || 0; - const adjustedComponent = { + // offset이 0이면 원본 위치 사용 (화면 관리 해상도 사용 시) + const adjustedComponent = (offsetX === 0 && offsetY === 0) ? component : { ...component, position: { ...component.position, @@ -345,14 +405,14 @@ export const ScreenModal: React.FC = ({ className }) => { allComponents={screenData.components} formData={formData} onFormDataChange={(fieldName, value) => { - console.log(`🎯 ScreenModal onFormDataChange 호출: ${fieldName} = "${value}"`); - console.log("📋 현재 formData:", formData); + // console.log(`🎯 ScreenModal onFormDataChange 호출: ${fieldName} = "${value}"`); + // console.log("📋 현재 formData:", formData); setFormData((prev) => { const newFormData = { ...prev, [fieldName]: value, }; - console.log("📝 ScreenModal 업데이트된 formData:", newFormData); + // console.log("📝 ScreenModal 업데이트된 formData:", newFormData); return newFormData; }); }} @@ -370,6 +430,29 @@ export const ScreenModal: React.FC = ({ className }) => {
)}
+ + {/* 연속 등록 모드 체크박스 */} +
+
+ { + const isChecked = checked === true; + continuousModeRef.current = isChecked; + localStorage.setItem("screenModal_continuousMode", String(isChecked)); + setForceUpdate((prev) => prev + 1); // 체크박스 UI 업데이트를 위한 강제 리렌더링 + // console.log("🔄 연속 모드 변경:", isChecked); + }} + /> + +
+
); diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index c8e53cdd..7ad86f9c 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -127,6 +127,11 @@ export const InteractiveScreenViewerDynamic: React.FC {label || "버튼"} diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index 48761e42..777f791d 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -208,52 +208,23 @@ export const RealtimePreviewDynamic: React.FC = ({ : {}; // 컴포넌트 기본 스타일 - 레이아웃은 항상 맨 아래 - // 너비 우선순위: style.width > 조건부 100% > size.width (픽셀값) + // 🔥 모든 컴포넌트를 픽셀 기준으로 통일 (스케일로만 조정) const getWidth = () => { - // 1순위: style.width가 있으면 우선 사용 (퍼센트 값) - if (componentStyle?.width) { - console.log("✅ [getWidth] style.width 사용:", { - componentId: id, - label: component.label, - styleWidth: componentStyle.width, - gridColumns: (component as any).gridColumns, - componentStyle: componentStyle, - baseStyle: { - left: `${position.x}px`, - top: `${position.y}px`, - width: componentStyle.width, - height: getHeight(), - }, - }); - return componentStyle.width; - } - - // 2순위: x=0인 컴포넌트는 전체 너비 사용 (버튼 제외) - const isButtonComponent = - (component.type === "widget" && (component as WidgetComponent).widgetType === "button") || - (component.type === "component" && (component as any).componentType?.includes("button")); - - if (position.x === 0 && !isButtonComponent) { - console.log("⚠️ [getWidth] 100% 사용 (x=0):", { - componentId: id, - label: component.label, - }); - return "100%"; - } - - // 3순위: size.width (픽셀) + // table-list는 화면 너비 전체 사용 if (component.componentConfig?.type === "table-list") { - const width = `${Math.max(size?.width || 120, 120)}px`; - console.log("📏 [getWidth] 픽셀 사용 (table-list):", { + // 디자인 해상도 기준으로 픽셀 반환 + const screenWidth = 1920; // 기본 디자인 해상도 + console.log("📏 [getWidth] table-list 픽셀 사용:", { componentId: id, label: component.label, - width, + width: `${screenWidth}px`, }); - return width; + return `${screenWidth}px`; } + // 모든 컴포넌트는 size.width 픽셀 사용 const width = `${size?.width || 100}px`; - console.log("📏 [getWidth] 픽셀 사용 (기본):", { + console.log("📐 [getWidth] 픽셀 기준 통일:", { componentId: id, label: component.label, width, diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 3561fdec..2a82ff33 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -903,14 +903,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const layoutWithDefaultGrid = { ...layoutToUse, gridSettings: { - ...layoutToUse.gridSettings, // 기존 설정 먼저 적용 - columns: 12, - gap: 16, + columns: layoutToUse.gridSettings?.columns || 12, // DB 값 우선, 없으면 기본값 12 + gap: layoutToUse.gridSettings?.gap ?? 16, // DB 값 우선, 없으면 기본값 16 padding: 0, // padding은 항상 0으로 강제 - snapToGrid: true, - showGrid: false, // 기본값 false로 변경 - gridColor: "#d1d5db", - gridOpacity: 0.5, + snapToGrid: layoutToUse.gridSettings?.snapToGrid ?? true, // DB 값 우선 + showGrid: layoutToUse.gridSettings?.showGrid ?? false, // DB 값 우선 + gridColor: layoutToUse.gridSettings?.gridColor || "#d1d5db", + gridOpacity: layoutToUse.gridSettings?.gridOpacity ?? 0.5, }, }; @@ -2566,6 +2565,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD labelColor: "#212121", labelFontWeight: "500", labelMarginBottom: "6px", + width: `${(calculatedGridColumns / (layout.gridSettings?.columns || 12)) * 100}%`, // 퍼센트 너비 }, componentConfig: { type: componentId, // text-input, number-input 등 @@ -2640,6 +2640,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD labelColor: "#000000", // 순수한 검정 labelFontWeight: "500", labelMarginBottom: "8px", + width: `${(calculatedGridColumns / (layout.gridSettings?.columns || 12)) * 100}%`, // 퍼센트 너비 }, componentConfig: { type: componentId, // text-input, number-input 등 @@ -4284,12 +4285,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD tables={tables} gridSettings={layout.gridSettings} onUpdateProperty={updateComponentProperty} - onGridSettingsChange={(newSettings) => { - setLayout((prev) => ({ - ...prev, - gridSettings: newSettings, - })); - }} + onGridSettingsChange={updateGridSettings} onDeleteComponent={deleteComponent} onCopyComponent={copyComponent} currentTable={tables.length > 0 ? tables[0] : undefined} diff --git a/frontend/components/screen/ScreenList.tsx b/frontend/components/screen/ScreenList.tsx index 98fb5b0f..63ec2210 100644 --- a/frontend/components/screen/ScreenList.tsx +++ b/frontend/components/screen/ScreenList.tsx @@ -441,7 +441,6 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr 화면명 - 화면 코드 테이블명 상태 생성일 @@ -465,11 +464,6 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr )}
- - - {screen.screenCode} - - {screen.tableLabel || screen.tableName} @@ -570,7 +564,6 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr

{screen.screenName}

-

{screen.screenCode}

{screen.isActive === "Y" ? "활성" : "비활성"} @@ -687,7 +680,6 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr /> 화면명 - 화면 코드 테이블명 삭제일 삭제자 @@ -713,11 +705,6 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr )}
- - - {screen.screenCode} - - {screen.tableLabel || screen.tableName} @@ -807,7 +794,6 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr />

{screen.screenName}

-

{screen.screenCode}

@@ -1267,21 +1253,6 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr zIndex: component.position.z || 1, }; - // 버튼 타입일 때 디버깅 (widget 타입 또는 component 타입 모두 체크) - if ( - (component.type === "widget" && (component as any).widgetType === "button") || - (component.type === "component" && (component as any).componentType?.includes("button")) - ) { - console.log("🔘 ScreenList 버튼 외부 div 스타일:", { - id: component.id, - label: component.label, - position: component.position, - size: component.size, - componentStyle: component.style, - appliedStyle: style, - }); - } - return style; })()} > diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel-fixed.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel-fixed.tsx index a70a0633..dc991519 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel-fixed.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel-fixed.tsx @@ -242,6 +242,7 @@ export const ButtonConfigPanel: React.FC = ({ component, 취소 삭제 수정 + 복사 (품목코드 초기화) 추가 검색 초기화 @@ -386,6 +387,71 @@ export const ButtonConfigPanel: React.FC = ({ component, + +
+
+ + setModalSearchTerm(e.target.value)} + className="border-0 p-0 focus-visible:ring-0" + /> +
+
+ {(() => { + const filteredScreens = filterScreens(modalSearchTerm); + if (screensLoading) { + return
화면 목록을 불러오는 중...
; + } + if (filteredScreens.length === 0) { + return
검색 결과가 없습니다.
; + } + return filteredScreens.map((screen, index) => ( +
{ + onUpdateProperty("componentConfig.action.targetScreenId", screen.id); + setModalScreenOpen(false); + setModalSearchTerm(""); + }} + > + {screen.name} +
+ )); + })()} +
+
+
+ + + + )} + + {/* 복사 액션 설정 */} + {localSelects.actionType === "copy" && ( +
+

복사 설정 (품목코드 자동 초기화)

+ +
+ + + + +
@@ -434,12 +500,12 @@ export const ButtonConfigPanel: React.FC = ({ component,

- 선택된 데이터가 이 폼 화면에 자동으로 로드되어 수정할 수 있습니다 + 선택된 데이터가 복사되며, 품목코드는 자동으로 초기화됩니다

- + setModalSearchTerm(e.target.value)} + className="border-0 p-0 focus-visible:ring-0" + /> +
+
+ {(() => { + const filteredScreens = filterScreens(modalSearchTerm); + if (screensLoading) { + return
화면 목록을 불러오는 중...
; + } + if (filteredScreens.length === 0) { + return
검색 결과가 없습니다.
; + } + return filteredScreens.map((screen, index) => ( +
{ + onUpdateProperty("componentConfig.action.targetScreenId", screen.id); + setModalScreenOpen(false); + setModalSearchTerm(""); + }} + > + +
+ {screen.name} + {screen.description && {screen.description}} +
+
+ )); + })()} +
+
+
+
+

+ 선택된 데이터가 복사되며, 품목코드는 자동으로 초기화됩니다 +

+
+ +
+ + +
+ + {(component.componentConfig?.action?.editMode || "modal") === "modal" && ( + <> +
+ + { + const newValue = e.target.value; + setLocalInputs((prev) => ({ ...prev, editModalTitle: newValue })); + onUpdateProperty("componentConfig.action.editModalTitle", newValue); + onUpdateProperty("webTypeConfig.editModalTitle", newValue); + }} + /> +

비워두면 기본 제목이 표시됩니다

+
+ +
+ + { + const newValue = e.target.value; + setLocalInputs((prev) => ({ ...prev, editModalDescription: newValue })); + onUpdateProperty("componentConfig.action.editModalDescription", newValue); + onUpdateProperty("webTypeConfig.editModalDescription", newValue); + }} + /> +

비워두면 설명이 표시되지 않습니다

+
+ +
+ + +
+ + )} +
+ )} + {/* 테이블 이력 보기 액션 설정 */} {(component.componentConfig?.action?.type || "save") === "view_table_history" && (
diff --git a/frontend/components/screen/config-panels/ButtonDataflowConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonDataflowConfigPanel.tsx index afa181f8..e3226a10 100644 --- a/frontend/components/screen/config-panels/ButtonDataflowConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonDataflowConfigPanel.tsx @@ -188,6 +188,7 @@ export const ButtonDataflowConfigPanel: React.FC save: "저장", delete: "삭제", edit: "수정", + copy: "복사", add: "추가", search: "검색", reset: "초기화", diff --git a/frontend/components/screen/templates/NumberingRuleTemplate.ts b/frontend/components/screen/templates/NumberingRuleTemplate.ts index fbe15d8d..1f5b2eb6 100644 --- a/frontend/components/screen/templates/NumberingRuleTemplate.ts +++ b/frontend/components/screen/templates/NumberingRuleTemplate.ts @@ -76,3 +76,4 @@ export const numberingRuleTemplate = { }; + diff --git a/frontend/lib/api/entityJoin.ts b/frontend/lib/api/entityJoin.ts index dee758a5..d26e42b9 100644 --- a/frontend/lib/api/entityJoin.ts +++ b/frontend/lib/api/entityJoin.ts @@ -89,12 +89,20 @@ export const entityJoinApi = { }); } + // 🔒 멀티테넌시: company_code 자동 필터링 활성화 + const autoFilter = { + enabled: true, + filterColumn: "company_code", + userField: "companyCode", + }; + const response = await apiClient.get(`/table-management/tables/${tableName}/data-with-joins`, { params: { ...params, search: params.search ? JSON.stringify(params.search) : undefined, additionalJoinColumns: params.additionalJoinColumns ? JSON.stringify(params.additionalJoinColumns) : undefined, screenEntityConfigs: params.screenEntityConfigs ? JSON.stringify(params.screenEntityConfigs) : undefined, // 🎯 화면별 엔티티 설정 + autoFilter: JSON.stringify(autoFilter), // 🔒 멀티테넌시 필터링 }, }); return response.data.data; diff --git a/frontend/lib/api/numberingRule.ts b/frontend/lib/api/numberingRule.ts index 1f655790..dace488a 100644 --- a/frontend/lib/api/numberingRule.ts +++ b/frontend/lib/api/numberingRule.ts @@ -134,3 +134,4 @@ export async function resetSequence(ruleId: string): Promise> } + diff --git a/frontend/lib/api/screen.ts b/frontend/lib/api/screen.ts index a37fbdff..d1d07d96 100644 --- a/frontend/lib/api/screen.ts +++ b/frontend/lib/api/screen.ts @@ -280,7 +280,17 @@ export const tableTypeApi = { size: number; totalPages: number; }> => { - const response = await apiClient.post(`/table-management/tables/${tableName}/data`, params); + // 🔒 멀티테넌시: company_code 자동 필터링 활성화 + const requestBody = { + ...params, + autoFilter: { + enabled: true, + filterColumn: "company_code", + userField: "companyCode", + }, + }; + + const response = await apiClient.post(`/table-management/tables/${tableName}/data`, requestBody); const raw = response.data?.data || response.data; return { diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index fc86ceb7..43b08177 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -344,8 +344,8 @@ export const ButtonPrimaryComponent: React.FC = ({ window.dispatchEvent(new CustomEvent("closeEditModal")); } - // ScreenModal은 항상 닫기 - window.dispatchEvent(new CustomEvent("closeSaveModal")); + // ScreenModal은 연속 등록 모드를 지원하므로 saveSuccessInModal 이벤트 발생 + window.dispatchEvent(new CustomEvent("saveSuccessInModal")); }, 100); } } diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 13865051..30756d09 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -35,6 +35,11 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import { Label } from "@/components/ui/label"; import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearchFilters"; import { SingleTableWithSticky } from "./SingleTableWithSticky"; @@ -274,7 +279,6 @@ export const TableListComponent: React.FC = ({ const [visibleFilterColumns, setVisibleFilterColumns] = useState>(new Set()); // 그룹 설정 관련 상태 - const [isGroupSettingOpen, setIsGroupSettingOpen] = useState(false); const [groupByColumns, setGroupByColumns] = useState([]); const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); @@ -354,7 +358,10 @@ export const TableListComponent: React.FC = ({ if (!tableConfig.selectedTable) return; try { - const cacheKey = `columns_${tableConfig.selectedTable}`; + // 🔥 FIX: 캐시 키에 회사 코드 포함 (멀티테넌시 지원) + const currentUser = JSON.parse(localStorage.getItem("currentUser") || "{}"); + const companyCode = currentUser.companyCode || "UNKNOWN"; + const cacheKey = `columns_${tableConfig.selectedTable}_${companyCode}`; const cached = tableColumnCache.get(cacheKey); if (cached && Date.now() - cached.timestamp < TABLE_CACHE_TTL) { const labels: Record = {}; @@ -1103,16 +1110,16 @@ export const TableListComponent: React.FC = ({ const mapping = categoryMappings[column.columnName]; const categoryData = mapping?.[String(value)]; - console.log(`🎨 [카테고리 배지] ${column.columnName}:`, { - value, - stringValue: String(value), - mapping, - categoryData, - hasMapping: !!mapping, - hasCategoryData: !!categoryData, - allCategoryMappings: categoryMappings, // 전체 매핑 확인 - categoryMappingsKeys: Object.keys(categoryMappings), - }); + // console.log(`🎨 [카테고리 배지] ${column.columnName}:`, { + // value, + // stringValue: String(value), + // mapping, + // categoryData, + // hasMapping: !!mapping, + // hasCategoryData: !!categoryData, + // allCategoryMappings: categoryMappings, // 전체 매핑 확인 + // categoryMappingsKeys: Object.keys(categoryMappings), + // }); // 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값과 기본색상 const displayLabel = categoryData?.label || String(value); @@ -1281,17 +1288,14 @@ export const TableListComponent: React.FC = ({ })); }, [visibleColumns, visibleFilterColumns, columnLabels]); - // 그룹 설정 저장 - const saveGroupSettings = useCallback(() => { + // 그룹 설정 자동 저장 (localStorage) + useEffect(() => { if (!groupSettingKey) return; try { localStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns)); - setIsGroupSettingOpen(false); - toast.success("그룹 설정이 저장되었습니다"); } catch (error) { console.error("그룹 설정 저장 실패:", error); - toast.error("설정 저장에 실패했습니다"); } }, [groupSettingKey, groupByColumns]); @@ -1542,10 +1546,6 @@ export const TableListComponent: React.FC = ({ > - - - 전체 {totalItems.toLocaleString()}개 -
{/* 우측 새로고침 버튼 */} @@ -1607,7 +1607,12 @@ export const TableListComponent: React.FC = ({ onClearFilters={handleClearAdvancedFilters} /> -
+
+ {/* 전체 개수 */} +
+ 전체 {totalItems.toLocaleString()}개 +
+ - + + + + + +
+
+

그룹 설정

+

+ 데이터를 그룹화할 컬럼을 선택하세요 +

+
+ + {/* 컬럼 목록 */} +
+ {visibleColumns + .filter((col) => col.columnName !== "__checkbox__") + .map((col) => ( +
+ toggleGroupColumn(col.columnName)} + /> + +
+ ))} +
+ + {/* 선택된 그룹 안내 */} + {groupByColumns.length > 0 && ( +
+ + {groupByColumns.map((col) => columnLabels[col] || col).join(" → ")} + +
+ )} + + {/* 초기화 버튼 */} + {groupByColumns.length > 0 && ( + + )} +
+
+
@@ -1714,7 +1788,12 @@ export const TableListComponent: React.FC = ({ onClearFilters={handleClearAdvancedFilters} /> -
+
+ {/* 전체 개수 */} +
+ 전체 {totalItems.toLocaleString()}개 +
+ - + + + + + +
+
+

그룹 설정

+

+ 데이터를 그룹화할 컬럼을 선택하세요 +

+
+ + {/* 컬럼 목록 */} +
+ {visibleColumns + .filter((col) => col.columnName !== "__checkbox__") + .map((col) => ( +
+ toggleGroupColumn(col.columnName)} + /> + +
+ ))} +
+ + {/* 선택된 그룹 안내 */} + {groupByColumns.length > 0 && ( +
+ + {groupByColumns.map((col) => columnLabels[col] || col).join(" → ")} + +
+ )} + + {/* 초기화 버튼 */} + {groupByColumns.length > 0 && ( + + )} +
+
+
@@ -2206,68 +2354,6 @@ export const TableListComponent: React.FC = ({ - {/* 그룹 설정 다이얼로그 */} - - - - 그룹 설정 - - 데이터를 그룹화할 컬럼을 선택하세요. 여러 컬럼을 선택하면 계층적으로 그룹화됩니다. - - - -
- {/* 컬럼 목록 */} -
- {visibleColumns - .filter((col) => col.columnName !== "__checkbox__") - .map((col) => ( -
- toggleGroupColumn(col.columnName)} - /> - -
- ))} -
- - {/* 선택된 그룹 안내 */} -
- {groupByColumns.length === 0 ? ( - 그룹화할 컬럼을 선택하세요 - ) : ( - - 선택된 그룹:{" "} - - {groupByColumns.map((col) => columnLabels[col] || col).join(" → ")} - - - )} -
-
- - - - - -
-
- {/* 테이블 옵션 모달 */} = {}; @@ -269,26 +273,26 @@ export class ButtonActionExecutor { if (key.endsWith("_numberingRuleId") && value) { const fieldName = key.replace("_numberingRuleId", ""); fieldsWithNumbering[fieldName] = value as string; - console.log(`🎯 발견: ${fieldName} → 규칙 ${value}`); + // console.log(`🎯 발견: ${fieldName} → 규칙 ${value}`); } } - console.log("📋 채번 규칙이 설정된 필드:", fieldsWithNumbering); - console.log("📊 필드 개수:", Object.keys(fieldsWithNumbering).length); + // console.log("📋 채번 규칙이 설정된 필드:", fieldsWithNumbering); + // console.log("📊 필드 개수:", Object.keys(fieldsWithNumbering).length); // 각 필드에 대해 실제 코드 할당 for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) { try { - console.log(`🎫 ${fieldName} 필드에 채번 규칙 ${ruleId} 할당 시작`); + // console.log(`🎫 ${fieldName} 필드에 채번 규칙 ${ruleId} 할당 시작`); const { allocateNumberingCode } = await import("@/lib/api/numberingRule"); const response = await allocateNumberingCode(ruleId); - console.log(`📡 API 응답 (${fieldName}):`, response); + // console.log(`📡 API 응답 (${fieldName}):`, response); if (response.success && response.data) { const generatedCode = response.data.generatedCode; formData[fieldName] = generatedCode; - console.log(`✅ ${fieldName} = ${generatedCode} (할당 완료)`); + // console.log(`✅ ${fieldName} = ${generatedCode} (할당 완료)`); } else { console.error(`❌ 채번 규칙 할당 실패 (${fieldName}):`, response.error); toast.error(`${fieldName} 채번 규칙 할당 실패: ${response.error}`); @@ -299,8 +303,8 @@ export class ButtonActionExecutor { } } - console.log("✅ 채번 규칙 할당 완료"); - console.log("📦 최종 formData:", JSON.stringify(formData, null, 2)); + // console.log("✅ 채번 규칙 할당 완료"); + // console.log("📦 최종 formData:", JSON.stringify(formData, null, 2)); const dataWithUserInfo = { ...formData, @@ -341,8 +345,9 @@ export class ButtonActionExecutor { context.onRefresh?.(); context.onFlowRefresh?.(); - // 저장 성공 후 EditModal 닫기 이벤트 발생 - window.dispatchEvent(new CustomEvent("closeEditModal")); + // 저장 성공 후 이벤트 발생 + window.dispatchEvent(new CustomEvent("closeEditModal")); // EditModal 닫기 + window.dispatchEvent(new CustomEvent("saveSuccessInModal")); // ScreenModal 연속 등록 모드 처리 return true; } catch (error) { @@ -886,6 +891,197 @@ export class ButtonActionExecutor { window.location.href = editUrl; } + /** + * 복사 액션 처리 (품목코드 초기화) + */ + private static async handleCopy(config: ButtonActionConfig, context: ButtonActionContext): Promise { + try { + const { selectedRowsData, flowSelectedData } = context; + + // 플로우 선택 데이터 우선 사용 + let dataToCopy = flowSelectedData && flowSelectedData.length > 0 ? flowSelectedData : selectedRowsData; + + console.log("📋 handleCopy - 데이터 소스 확인:", { + hasFlowSelectedData: !!(flowSelectedData && flowSelectedData.length > 0), + flowSelectedDataLength: flowSelectedData?.length || 0, + hasSelectedRowsData: !!(selectedRowsData && selectedRowsData.length > 0), + selectedRowsDataLength: selectedRowsData?.length || 0, + dataToCopyLength: dataToCopy?.length || 0, + }); + + // 선택된 데이터가 없는 경우 + if (!dataToCopy || dataToCopy.length === 0) { + toast.error("복사할 항목을 선택해주세요."); + return false; + } + + // 복사 화면이 설정되지 않은 경우 + if (!config.targetScreenId) { + toast.error("복사 폼 화면이 설정되지 않았습니다. 버튼 설정에서 복사 폼 화면을 선택해주세요."); + return false; + } + + console.log(`📋 복사 액션 실행: ${dataToCopy.length}개 항목`, { + dataToCopy, + targetScreenId: config.targetScreenId, + editMode: config.editMode, + }); + + if (dataToCopy.length === 1) { + // 단일 항목 복사 + const rowData = dataToCopy[0]; + console.log("📋 단일 항목 복사:", rowData); + console.log("📋 원본 데이터 키 목록:", Object.keys(rowData)); + + // 복사 시 제거할 필드들 + const copiedData = { ...rowData }; + const fieldsToRemove = [ + // ID 필드 (새 레코드 생성) + "id", + "ID", + // 날짜 필드 (자동 생성) + "created_date", + "createdDate", + "updated_date", + "updatedDate", + "created_at", + "createdAt", + "updated_at", + "updatedAt", + "reg_date", + "regDate", + "mod_date", + "modDate", + ]; + + // 제거할 필드 삭제 + fieldsToRemove.forEach((field) => { + if (copiedData[field] !== undefined) { + delete copiedData[field]; + console.log(`🗑️ 필드 제거: ${field}`); + } + }); + + // 품목코드 필드 초기화 (여러 가능한 필드명 확인) + const itemCodeFields = [ + "item_code", + "itemCode", + "item_no", + "itemNo", + "item_number", + "itemNumber", + "품목코드", + "품번", + "code", + ]; + + // 품목코드 필드를 찾아서 무조건 공백으로 초기화 + let resetFieldName = ""; + for (const field of itemCodeFields) { + if (copiedData[field] !== undefined) { + const originalValue = copiedData[field]; + const ruleIdKey = `${field}_numberingRuleId`; + const hasNumberingRule = rowData[ruleIdKey] !== undefined && rowData[ruleIdKey] !== null && rowData[ruleIdKey] !== ""; + + // 품목코드를 무조건 공백으로 초기화 + copiedData[field] = ""; + + // 채번 규칙 ID가 있으면 복사 (저장 시 자동 생성) + if (hasNumberingRule) { + copiedData[ruleIdKey] = rowData[ruleIdKey]; + console.log(`✅ 품목코드 초기화 (채번 규칙 있음): ${field} (기존값: ${originalValue})`); + console.log(`📋 채번 규칙 ID 복사: ${ruleIdKey} = ${rowData[ruleIdKey]}`); + } else { + console.log(`✅ 품목코드 초기화 (수동 입력 필요): ${field} (기존값: ${originalValue})`); + } + + resetFieldName = field; + break; + } + } + + // 작성자 정보를 현재 사용자로 변경 + const writerFields = ["writer", "creator", "reg_user", "regUser", "created_by", "createdBy"]; + writerFields.forEach((field) => { + if (copiedData[field] !== undefined && context.userId) { + copiedData[field] = context.userId; + console.log(`👤 작성자 변경: ${field} = ${context.userId}`); + } + }); + + if (resetFieldName) { + toast.success(`복사본이 생성되었습니다. 품목코드는 저장 시 자동으로 생성됩니다.`); + } else { + console.warn("⚠️ 품목코드 필드를 찾을 수 없습니다. 전체 데이터를 복사합니다."); + console.warn("⚠️ 사용 가능한 필드:", Object.keys(copiedData)); + toast.info("복사본이 생성됩니다."); + } + + console.log("📋 복사된 데이터:", copiedData); + await this.openCopyForm(config, copiedData, context); + } else { + // 다중 항목 복사 - 현재는 단일 복사만 지원 + toast.error("현재 단일 항목 복사만 지원됩니다. 하나의 항목만 선택해주세요."); + return false; + } + + return true; + } catch (error: any) { + console.error("❌ 복사 액션 실행 중 오류:", error); + toast.error(`복사 중 오류가 발생했습니다: ${error.message || "알 수 없는 오류"}`); + return false; + } + } + + /** + * 복사 폼 열기 (단일 항목) + */ + private static async openCopyForm( + config: ButtonActionConfig, + rowData: any, + context: ButtonActionContext, + ): Promise { + try { + const editMode = config.editMode || "modal"; + console.log("📋 openCopyForm 실행:", { editMode, targetScreenId: config.targetScreenId }); + + switch (editMode) { + case "modal": + // 모달로 복사 폼 열기 (편집 모달 재사용) + console.log("📋 모달로 복사 폼 열기"); + await this.openEditModal(config, rowData, context); + break; + + case "navigate": + // 새 페이지로 이동 + console.log("📋 새 페이지로 복사 화면 이동"); + this.navigateToCopyScreen(config, rowData, context); + break; + + default: + // 기본값: 모달 + console.log("📋 기본 모달로 복사 폼 열기"); + this.openEditModal(config, rowData, context); + } + } catch (error: any) { + console.error("❌ openCopyForm 실행 중 오류:", error); + throw error; + } + } + + /** + * 복사 화면으로 네비게이션 + */ + private static navigateToCopyScreen(config: ButtonActionConfig, rowData: any, context: ButtonActionContext): void { + const copyUrl = `/screens/${config.targetScreenId}?mode=copy`; + console.log("🔄 복사 화면으로 이동:", copyUrl); + + // 복사할 데이터를 sessionStorage에 저장 + sessionStorage.setItem("copyData", JSON.stringify(rowData)); + + window.location.href = copyUrl; + } + /** * 닫기 액션 처리 */ diff --git a/frontend/types/unified-core.ts b/frontend/types/unified-core.ts index 6f7c2b40..cba1c3f7 100644 --- a/frontend/types/unified-core.ts +++ b/frontend/types/unified-core.ts @@ -55,6 +55,7 @@ export type ButtonActionType = | "cancel" | "delete" | "edit" + | "copy" // 복사 (품목코드 초기화) | "add" // 검색 및 초기화 | "search" diff --git a/동적_테이블_접근_시스템_개선_완료.md b/동적_테이블_접근_시스템_개선_완료.md index ea214f5d..d143a6a5 100644 --- a/동적_테이블_접근_시스템_개선_완료.md +++ b/동적_테이블_접근_시스템_개선_완료.md @@ -376,3 +376,4 @@ interface TablePermission { **이제 테이블을 만들 때마다 코드를 수정할 필요가 없습니다!** +