102 Commits

Author SHA1 Message Date
johngreen f8ef029e10 fix(테이블타입): 탭 너비 fix (#34)
Build & Deploy to K8s / build-and-deploy (push) Successful in 6m34s
flex-none override
2026-05-22 10:16:41 +00:00
johngreen 5cd8e72bf0 fix(테이블타입): 탭 너비 컨텐츠에 맞춤 — shadcn TabsTrigger flex-1 기본값 해제
shadcn TabsTrigger 가 기본 flex-1 이라 두 탭이 가로 폭 절반씩 차지해서 너무
넓게 늘어남. flex-none 으로 override 해서 글자/아이콘 폭만큼만 차지.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:16:08 +09:00
johngreen 387a1ae611 style(테이블타입): 탭 Chrome outline 스타일 (#33)
Build & Deploy to K8s / build-and-deploy (push) Successful in 6m37s
탭 스타일 v5-tab 톤 매칭
2026-05-22 09:49:53 +00:00
johngreen eeb130e3a8 style(테이블타입): 탭 스타일 Chrome 식 outline 으로 변경 — 페이지 탭(v5-tab)과 톤 일치
밑줄(underline) 스타일이 너무 평면이라 사용자가 페이지 상단 v5-tab(브라우저 탭처럼
border 두른 outline 카드 스타일) 과 톤을 맞추길 원함.

- TabsList: 좌측 정렬 + 하단 1px 구분선 + 좌우 padding
- TabsTrigger: 위 모서리만 둥글게 (rounded-t-md), 1px border 둘러, -mb-px 로
  컨테이너 하단선과 겹쳐 카드처럼 떠 보임
- 활성 탭: bg-card + primary 색 텍스트 + 굵게 + 위쪽 2px primary 강조선 (inset shadow)
  + 하단 border 를 카드색으로 덮어 본문과 seamless

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:49:14 +09:00
johngreen 3ffa5c8ff5 chore(테이블관리): 사소 3건 (PR-D) (#32)
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m34s
PR-D: 사소 3건 정리
2026-05-22 06:12:02 +00:00
johngreen acbab68a12 chore(테이블관리): 사소 3건 정리 (PR-D) — 디버그 로그 / dead code / 에러 메시지
1. 운영 console.log/warn 제거 (G15) — page.tsx 의 이모지 prefix 디버그 로그
   (🔍 🔄  🗑️ 📥 📊 📋) 일괄 제거. catch 블록의 console.error 는 추적용으로
   유지. CreateTableModal 의 컬럼 조회 디버그 로그도 정리.

2. useLogTable dead code 정리 (G16) — CreateTableModal 의 useLogTable state,
   handleCreateTable 분기, 주석 처리된 체크박스 UI 모두 제거. 시그니처 안 맞는
   createLogTable 호출 페이로드까지 같이 사라짐. Activity / Checkbox import 도
   필요 없어졌으므로 제거.

3. 에러 메시지 일관화 (G17) — DdlController 와 TableManagementController 의
   "최고 관리자 권한이 필요합니다." 메시지 모두 "최고 관리자(SUPER_ADMIN) 권한이
   필요합니다." 로 통일. 일반 ADMIN 권한 메시지는 그대로 유지.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:11:21 +09:00
johngreen db63ba6901 fix(테이블관리): 중요 4건 (PR-C) (#31)
Build & Deploy to K8s / build-and-deploy (push) Successful in 10m8s
PR-C: 중요 4건 일괄
2026-05-22 06:04:41 +00:00
johngreen ff95c1950e fix(테이블관리): 중요 4건 일괄 수정 (PR-C)
1. getColumnGroup metaCols 확장 — 시스템 자동 생성 컬럼이 5개만 인식되어
   사용자 컬럼에 섞여 보이던 문제. objid / tenant_id / creator / modifier /
   created_at / updated_at 추가 (총 11개).

2. updateColumnWebType 회사코드 격리 — 모든 호출이 company_code='*' 로 저장돼
   회사 관리자가 자기 회사 전용 web_type 변경 시 모든 회사 공통 설정을 건드림.
   컨트롤러에서 @RequestAttribute("company_code") 받아 service 에 전달.

3. validateUniqueConstraints N+1 해소 — hasColumn 이 루프 안에서 매번 호출되어
   UNIQUE 컬럼 N 개일 때 N 번의 information_schema 조회. 루프 밖으로 빼서 1 번.

4. sanitize 강화 — 빈 문자열 / 숫자 시작 / 63자 초과 / SQL 예약어 모두
   IllegalArgumentException 으로 거부. 빈 식별자가 동적 SQL 에 끼어들어 500
   에러 노출되던 패턴 방지.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:04:02 +09:00
hjjeong 8a9285f13e Merge pull request 'fix(배치관리): DB 커넥션 변경 시 테이블 목록이 안 바뀌는 버그' (#30) from hjjeong into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m51s
Reviewed-on: #30
2026-05-22 05:57:24 +00:00
johngreen 88b0549a6d fix(테이블관리): 심각 5건 (PR-B) (#29)
Build & Deploy to K8s / build-and-deploy (push) Successful in 7m15s
PR-B: 심각 5건 일괄
2026-05-22 05:50:52 +00:00
johngreen 33f0647c61 fix(테이블관리): 심각 5건 일괄 수정 (PR-B)
1. setTimeout 1초 reload 제거 — saveAllSettings / handleSaveColumn 의
   setTimeout(loadColumnTypes, 1000) 두 곳을 await 직접 호출로. 1초 깜빡임
   + race 해소.

2. typeFilter 자동 해제 시 input_type 변경 — 도넛 차트 필터가 켜진 상태에서
   상세 패널로 input_type 을 다른 타입으로 바꾸면 그 행이 그리드에서 갑자기
   사라지는 혼란. 변경된 타입이 필터와 다르면 자동으로 필터 해제.

3. composite PK 다이얼로그 — 매 PK 토글마다 확인 다이얼로그 뜨던 답답함.
   "이번 세션 다시 묻지 않기" 체크박스 추가. 체크 시 applyPkChange 즉시 호출.

4. 카테고리 매핑 병렬화 — for-of await 직렬 호출을 Promise.allSettled 로
   변경. 메뉴 수십 개 × 컬럼 수 = 수십 초 멈춤 → 한 번의 병렬 호출.

5. 좁은 화면 상세 패널 stuck 해소 — 패널이 열린 상태에서 column=null 이 되면
   X 버튼이 사라져 닫을 수단이 없던 문제. 빈 상태 UI 에도 X 버튼 유지 +
   ESC 키 핸들러 추가.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:50:12 +09:00
hjjeong 8606f0aaa3 Merge remote-tracking branch 'origin/main' into hjjeong 2026-05-22 14:49:50 +09:00
johngreen 24106929fa fix(테이블관리): 블로커 5건 (PR-A) (#28)
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m26s
PR-A: 블로커 5건 일괄 수정
2026-05-22 05:44:32 +00:00
johngreen f530b3cf31 fix(테이블관리): 블로커 5건 일괄 수정 (PR-A)
종합 감사 결과 발견된 블로커 5건:

1. AddColumnModal / CreateTableModal prop 미스매치 — 호출부 tableName/sourceTableName(camelCase)
   vs 인터페이스 table_name/source_table_name(snake_case). 결과적으로 컬럼 추가 기능이 실제로
   동작하지 않았음 (/api/ddl/tables/undefined/columns). 호출부를 인터페이스에 맞춰 통일.

2. IS_NULLABLE 강제 'Y' 덮어쓰기 (backend mapper) — upsertColumnSettings 의 VALUES 절이
   모든 케이스에서 'Y' 리터럴을 박음. NN 토글 후 "컬럼 설정 저장" 누르면 필수 입력 제약이
   조용히 사라짐. COALESCE(#{is_nullable}, 'Y') 로 변경 + ON CONFLICT 절에 IS_NULLABLE
   COALESCE 추가. Service 도 settings 에서 is_nullable 을 읽어 'Y'/'N' 으로 정규화 후 전달.

3. 저장 안 한 편집 침묵 손실 — 우측 패널에서 편집 후 저장 안 누르고 다른 테이블로 이동 시
   변경 사항 소실. hasUnsavedChanges memo 추가 (columns vs originalColumns JSON 비교).
   handleTableSelect 에서 dirty 면 confirm 다이얼로그.

4. PK / NN / IDX / UQ 위치별 비대칭 저장 — 그리드 행 칩은 즉시 저장하는데 우측 상세 패널
   토글은 메모리만 변경하고 저장 버튼 필요. 두 위치 모두 즉시 저장으로 통일 (상세 패널의
   onColumnChange 가 is_nullable / is_unique 필드를 받으면 handleNullableToggle /
   handleUniqueToggle 호출하도록).

5. SUPER_ADMIN role 검증 누락 (backend 보안) — DdlController 의 isSuperAdmin 이 company_code
   == "*" 만 보고 role 클레임 무시. 토큰 변조 또는 "*" 회사 소속만으로 운영 DB DROP / ALTER
   가능. company_code "*" AND role "SUPER_ADMIN" 둘 다 충족 시에만 통과로 변경. 11개 엔드포인트
   에 @RequestAttribute("role") 추가.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:43:15 +09:00
johngreen 99487049fb style(테이블타입): 탭 스타일 Google 식 underline (#27)
Build & Deploy to K8s / build-and-deploy (push) Successful in 6m22s
johngreen → main: 탭 스타일 Google 식 underline 으로 변경
2026-05-22 05:04:26 +00:00
johngreen 6233877029 style(테이블타입): 탭 스타일 Google 식 underline 으로 변경 — 가시성 ↑
기존 shadcn 기본 Tabs 가 둥근 알약 (segmented control) 스타일이라 탭이 아니라 토글처럼
보임. 사용자가 "탭이 눈에 잘 안 띈다" 지적.

Gmail/Drive/GitHub/Vercel 공통 패턴인 underline 스타일로 변경:
- TabsList: 전체 폭 + 하단 1px 구분선, 배경/padding 제거
- TabsTrigger: 평면 + 활성 시 2px primary 밑줄 + 글자 색 강조, 비활성은 muted
- 글자 / 아이콘 크기 한 단계 ↑ (text-xs→text-sm, h-3.5→h-4)
- 비활성에도 transparent border-b-2 줘서 layout shift 방지

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:03:52 +09:00
johngreen 4031fe8b60 chore: retrigger CI (gradle plugin download timed out)
Build & Deploy to K8s / build-and-deploy (push) Successful in 13m24s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:21:01 +09:00
johngreen a5288647c9 feat(테이블타입): 와이드 모니터 우측 패널 고정 표시 (#26)
Build & Deploy to K8s / build-and-deploy (push) Failing after 5m9s
johngreen → main: 우측 상세 패널 와이드 모니터에서 고정 표시 + 빈 상태 안내
2026-05-22 04:09:46 +00:00
johngreen 7dbeccc182 feat(테이블타입): 와이드 모니터에서 우측 상세 패널 고정 표시 — 슬라이드 in 제거
기존엔 컬럼 행 클릭해야 우측 패널이 슬라이드로 들어옴. 와이드 모니터에선 우측에 큰 빈 공간이
남는데도 패널이 숨어있어 화면이 흔들려 보임 → 일관성 ↓

와이드(xl 이상)에선 패널을 일반 flex child 로 고정해서 항상 표시. 좁은 화면은 기존
오버레이 슬라이드 유지 (가운데 영역 좁아지는 것 방지).

- page.tsx: 우측 패널 컨테이너에 xl: 상태로 relative + 슬라이드 무력화
- ColumnDetailPanel: 컬럼 선택 안 한 상태일 때 "컬럼을 선택해주세요" 빈 상태 안내 UI 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:09:08 +09:00
johngreen c857e4f715 fix(테이블타입): 컬럼 탭 그룹 정리 (#25)
Build & Deploy to K8s / build-and-deploy (push) Successful in 6m33s
johngreen → main: 컬럼 탭 그룹 헤더 사용자/시스템 2그룹으로 정리
2026-05-22 03:32:37 +00:00
johngreen cf5f7ef9af fix(테이블타입): 컬럼 탭 그룹 헤더 사용자/시스템 2그룹으로 정리 — 참조 탭과 중복 해소
PR #24 에서 컬럼/참조 탭은 분리했지만 ColumnGrid 의 기존 3그룹 (기본/참조/메타) 헤더가
그대로 남아있어 "참조 정보" 그룹이 컬럼 탭에서도 보이고 참조 탭에서도 보이는 시각적 중복
발생.

DBeaver 의 Columns 탭처럼 모든 컬럼은 컬럼 탭에서 보여야 함 (참조 컬럼 포함). 단,
"참조 정보" 그룹 헤더는 참조 탭이 같은 역할을 하므로 제거.

- "기본 정보" + "참조 정보" → "사용자 컬럼" 으로 합침
- "메타 정보" → "시스템 컬럼" 으로 이름만 평이하게
- 참조 컬럼은 평범한 한 행으로 표시 (타입 칩으로 종류는 식별)
- 미사용 Layers 아이콘 import 제거

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:31:59 +09:00
johngreen 7e71730015 feat(테이블타입): DBeaver 식 탭 분리 + 입력 타입 아이콘 lucide 통일 (#24)
Build & Deploy to K8s / build-and-deploy (push) Successful in 11m4s
johngreen → main: 컬럼 그리드 탭 분리 + 아이콘 통일 + CLAUDE.md 컨벤션
2026-05-21 10:22:54 +00:00
johngreen 2d39d17428 feat(테이블타입): 컬럼 그리드 DBeaver 식 탭 분리 — 컬럼 / 참조
ColumnGrid 의 "참조/설정" 컬럼이 두 가지 다른 역할 (entity/code/numbering 의 참조 대상 표시
vs 그 외 타입의 default_value 표시) 을 한 셀에 욱여넣고 있었음. text/number/date 행에선
대부분 — 빈 칸. 진단 결과는 architect 가 동의한 대로 컬럼 통째 제거가 답.

DBeaver 의 Columns / Foreign Keys 탭 분리 패턴 차용:
- 컬럼 탭 (기본 활성) — 컬럼 본연의 속성만 (라벨 / 타입 / PK NN IDX UQ / ⋯). 참조/설정 컬럼
  통째 제거, grid 6→5 컬럼으로 슬림화. 라벨 셀 폭 1fr 로 확보
- 참조 탭 — entity / code / category / numbering 컬럼만 모아 표 형태로. 종류별 그룹 헤더
  + (소스 컬럼 / 참조 종류 / 참조 대상) 3컬럼 그리드. code 행에서 누락되어있던 코드 그룹
  표시도 같이 채움

ReferenceListView 신규 컴포넌트로 분리. 사용자가 행 클릭하면 우측 상세 패널 슬라이드 in
하는 기존 동작은 양 탭 모두 동일.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:21:55 +09:00
johngreen 30ebb14023 fix(테이블타입): 입력 타입 아이콘 lucide 로 통일 — letter/symbol/emoji 혼재 정리
기존엔 iconChar 문자열에 'T'(글자) / '#'(기호) / '{}'(코드) / '📎'(emoji) 식으로 일관성 없게
표시. 카드 그리드에서 시각 노이즈 큼.

- TypeColorConfig.iconChar(string) → Icon(LucideIcon) 으로 교체
- 13개 입력 타입 전부 lucide 아이콘 매핑 (Type/Hash/Calendar/Braces/Link2 등)
- FALLBACK_TYPE_CONFIG export 해서 TypeOverviewStrip 의 legacy/unknown 도 같은 인터페이스 따름

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:21:30 +09:00
johngreen 895cb48ee0 docs(claude): UI/구조 변경 제안 시 ASCII Before/After 그림 의무화
기존 컨벤션에 § 💬 추가. 글로만 설명하면 사용자가 이해 못 함 — 변경 전/후 두 그림을 무조건
그려서 보여줘야 함. 코드 인용 (file:line, CSS class) 최소화하고 영문/SQL/전문용어 풀어쓰기.
3줄 패턴 (무슨 일 / 사용자 영향 / 어떻게 고치는지) 권장.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:21:13 +09:00
hjjeong 067193efa9 fix(배치관리): 대시보드 NaN 제거 + 24시간 차트 더미데이터 → 실데이터
- 백엔드: getBatchManagementGlobalSparklineData 쿼리 추가 (generate_series 로
  24개 슬롯 고정, 회사 전체 배치 LEFT JOIN 집계)
- 백엔드: GET /api/batch-management/sparkline 엔드포인트 추가
- 프론트: BatchStats/SparklineData 타입을 백엔드 mapper 의 snake_case 응답키와
  일치시킴 (today_count, today_failed_count, hour_slot, success_count, ...).
  키 미스매치로 stats 카드가 NaN 으로 표시되던 버그 해소
- 프론트: GlobalSparkline 컴포넌트의 Math.random() 더미 막대를 실데이터 prop 으로
  교체. row-level Sparkline 도 동일 키 정렬로 정상 렌더되도록 수정

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:59:52 +09:00
DDD1542 318cac4f68 Merge remote-tracking branch 'origin/main' into gbpark-node
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m44s
2026-05-19 21:31:11 +09:00
DDD1542 2f398ae0b3 chore: 제어모드 IDE 작업 + v2/legacy 레지스트리 컴포넌트 폐기
- 제어모드 IDE: ControlCardPanel, control/ide/* (Canvas/LeftRail/RightRail/PanZoomStage/V3RuleNode 등), schemas, lib/api/control
- 레지스트리 정리: aggregation-widget, status-count, section-card/paper, table-list(legacy/v2), tabs-widget 폐기 → table/_shared/ 로 통합
- InvLegacyButtonConfigPanel cp 마이그레이션
- canonical data view cleanup 후속 노트
2026-05-19 21:31:03 +09:00
johngreen 58ede650ae fix(테이블타입): ⋯ 드롭다운 row click 누수 차단 (#23)
Build & Deploy to K8s / build-and-deploy (push) Successful in 6m30s
johngreen → main: Radix DropdownMenuTrigger onPointerDown 으로 인한 row click 누수 차단
2026-05-19 09:45:37 +00:00
johngreen 4c5b672f40 fix(테이블타입): ⋯ 드롭다운 클릭이 row 로 새서 상세 패널이 열리던 문제
Radix DropdownMenuTrigger 는 onPointerDown 으로 트리거되는데 기존엔 onClick
stopPropagation 만 있어서, 부모 row 의 onClick(=setSelectedColumn)이 같이
발화 → 상세 패널이 슬라이드 in → 중앙 ColumnGrid 가 오버레이에 가려져
squish 처럼 보이던 문제.

⋯ 버튼을 감싸는 div 에 onClick / onPointerDown / onMouseDown 세 가지에
모두 stopPropagation 추가.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 18:44:55 +09:00
johngreen 904fdd33e7 feat(테이블타입): 컬럼 단건 DROP + CreateTableModal flex 레이아웃 수정 (#22)
Build & Deploy to K8s / build-and-deploy (push) Successful in 13m20s
johngreen → main: 컬럼 단건 DROP 기능 + CreateTableModal 다이얼로그 flex 레이아웃 수정
2026-05-19 06:02:51 +00:00
johngreen f73e468f66 feat(테이블타입): 컬럼 단건 DROP 기능 — ColumnGrid ⋯ 메뉴에 "컬럼 삭제" 추가
- DdlService.dropColumn: ALTER TABLE ... DROP COLUMN (CASCADE 미사용 → FK 참조 시 Postgres 거부, DBeaver 동일)
- 시스템 테이블 / 예약 컬럼(id/created_date/updated_date/company_code/writer) 보호
- 같은 트랜잭션에서 table_type_columns / column_labels 메타 청소 + ddl_execution_log 기록
- DdlController: DELETE /api/ddl/tables/{table}/columns/{column} (SUPER_ADMIN 전용)
- ddlApi.dropColumn 헬퍼
- ColumnGrid: ... 버튼을 DropdownMenu 로 교체, "컬럼 삭제" destructive 메뉴 아이템
- page.tsx: 컬럼 삭제 확인 다이얼로그 + 핸들러, FK 거부 시 토스트로 안내

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:49:07 +09:00
johngreen b25a6324f8 fix(테이블타입): CreateTableModal 다이얼로그 flex 레이아웃 — 스크롤 가능한 본문 + 고정 푸터
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:48:47 +09:00
hjjeong 8a10edd8e1 fix(배치관리): DB 커넥션 변경 시 테이블 목록이 안 바뀌는 버그
- 외부 DB id 비교를 strict === 에서 toString() 기반 string 비교로 변경 — number/string 어느 쪽으로 오든 매칭. find 실패로 toConnection=null 되면 auto-select useEffect 가 "내부 DB" 로 강제 복귀시키던 문제 해소
- 연결 변경 시 toTables/fromTables 즉시 초기화 — fetch 실패해도 직전 DB 의 테이블이 잔존하지 않도록
- 배치 파이프라인 / 외부커넥션 멀티 DB 작업 핸드오프 노트 함께 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:23:24 +09:00
hjjeong fc615a70be Merge pull request 'fix(admin): 외부커넥션 mapper varchar 캐스팅 + 외부커넥션/배치관리 UI 정돈' (#21) from hjjeong into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 15m1s
Reviewed-on: #21
2026-05-19 02:25:10 +00:00
hjjeong 947b31eff5 Merge remote-tracking branch 'origin/main' into hjjeong 2026-05-19 11:10:04 +09:00
hjjeong 46707bd116 feat(admin): 외부 DB 커넥션 멀티 DB 테스트 + 프로비저닝 시퀀스 reset 보강
기존엔 PostgreSQL 만 테스트 가능했고, V001 (SERIAL→VARCHAR) 마이그레이션 이후 회사 프로비저닝 시 시퀀스가 max(id) 보다 작은 상태로 남아있어서 새 외부 커넥션 등록 시 duplicate key 가 재발하던 문제 해결.

backend
- build.gradle: MariaDB/MySQL/MSSQL/SQLite JDBC 드라이버 4종 runtimeOnly 추가
- ExternalDbConnectionService.executeConnectionTest: PostgreSQL-only 가드 제거, dbType 별 JDBC URL/props 분기 구현 (postgresql/mysql/mariadb/mssql/sqlite). defaultPort helper 추가
- mapper/externalDbConnection.xml: INSERT/UPDATE 의 port/connection_timeout/query_timeout/max_connections 에 ::varchar 캐스팅 추가 (V001 으로 VARCHAR 가 됐는데 클라가 숫자로 보내서 character varying = bigint 비교 불가로 500 나던 것)
- DataCopier.resetSequences: VARCHAR PK + 시퀀스 의존성이 남은 컬럼도 setval 대상에 포함. MAX(col::bigint) + col ~ '^[0-9]+$' 정규식으로 type mismatch 회피하면서 숫자형 VARCHAR PK 만 안전하게 reset

frontend
- ExternalDbConnectionModal: DialogContent 를 flex 컬럼으로, 본문에 자체 스크롤 + Footer shrink-0 → 길어진 폼에서도 취소/생성 버튼이 항상 보이도록

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:01:14 +09:00
DDD1542 467a41a3a8 Merge origin/main into gbpark-node — johngreen 11 commits 받음 2026-05-18 11:47:41 +09:00
johngreen 75f6883497 fix(테이블타입): 헤더 인라인 편집을 명시적 연필 아이콘 진입으로 + 컬럼명 폰트 축소
Build & Deploy to K8s / build-and-deploy (push) Successful in 16m6s
1) 테이블 헤더 (표시명/설명) 편집 진입 방식 변경
   - 기존: 텍스트 div 자체가 role="button" + onClick 이라 무심코 클릭 시 input 으로 전환
   - 변경: 텍스트는 단순 span, 옆에 작은 Pencil 아이콘 버튼 추가. 그 버튼 클릭해야 편집 모드 진입.
   - 연필 아이콘은 평소 muted-foreground/50 톤, hover 시 진해짐 (group-hover 의존 X — Tailwind variant 캐시 회피).
   - 편집 모드 동작 (Enter / Esc / blur 커밋) 은 그대로.

2) ColumnGrid: 컬럼 라벨 text-sm → text-xs (14px → 12px)
   - 가운데 본문 컬럼 행이 너무 커보이던 문제. 좌측 list 폰트(이전 commit) 와 비례 맞춤.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:06:07 +09:00
johngreen d306ac2865 fix(테이블타입): dropdown key 중복 + hook 순서 + 탭바 outline + 좌측 list 폰트 사이즈
Build & Deploy to K8s / build-and-deploy (push) Successful in 6m43s
오늘 시리즈 후속 UX 다듬기 + 회귀 fix:

1) ColumnDetailPanel: dropdown key 중복 방어
   - codeInfoOptions 에 placeholder "none" + 데이터 "none" 중복 시 React 가 'two children
     with the same key, none' 으로 거부 → filter 로 사전 제거.
   - refTableOpts 도 referenceTableOptions/tables 어디서든 중복 들어오면 같은 증상 →
     Set 기반 dedupe.

2) ColumnDetailPanel: hook 순서 위반 수정
   - 기존 'if (!column) return null' 이 useMemo(refTableOpts) 앞에 있어서
     column null/존재 케이스마다 hook 호출 수가 달라짐 (Rules of Hooks 위반).
     overlay 패턴 도입 후 column null 케이스가 자주 들어오면서 드러남.
   - early return 을 모든 hook 뒤로 이동.

3) v5-layout.css 탭바: Chrome 식 outline 스타일
   - 비활성 탭도 각자 outline 보이게 (border:1px solid var(--v5-border))로 카드처럼 분리.
   - 활성 탭은 border + surface-hover 배경 + 위쪽 primary 1px inset 강조선.
   - 위 모서리 rounded, margin-bottom:-1px 로 탭바 하단 border 와 seamless 연결.

4) 좌측 테이블 list 폰트 사이즈 축소
   - 한글명 16px → 13px, 영문명 12px → 10.5px, 행 padding 7px → 6px.
   - 280px 좁은 패널에 맞는 컴팩트 비율로 v5 컨벤션 정렬.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:42:45 +09:00
johngreen 78c5e3e358 fix(테이블타입): 좌측 일괄선택 영역 layout shift + 우측 디테일 패널 overlay/slide-in 으로 전환
Build & Deploy to K8s / build-and-deploy (push) Successful in 6m53s
기존 UX 이슈 3가지:
- 좌측 테이블 목록에서 체크박스 첫 선택 시 삭제 버튼이 등장하면서
  헤더 영역 높이가 변해 아래 리스트가 살짝 밀림 (layout shift).
- 우측 디테일 패널이 conditional render 라 컬럼 클릭 시 가운데 본문이
  380px 만큼 좁아져 컬럼명/라벨이 truncate 되며 "찌그러진" 느낌.
- 닫는 방법이 X 버튼뿐이라 토글 직관성 부족.

변경:
- 좌측 헤더 영역에 min-h-9 고정 — 삭제 버튼 등장해도 높이 고정, 리스트 안 흔들림.
- 우측 디테일 패널을 overlay 로 전환: absolute right-0 z-20 + shadow-2xl.
  transition-transform + translate-x-{0|full} 로 300ms ease-out slide-in/out.
  pointer-events-none 으로 닫혀있을 때 클릭 차단.
- 가운데 본문 width 변동 0 — 컬럼 클릭해도 안 좁아짐.
- 컬럼 토글: 같은 컬럼 재클릭 시 디테일 패널 닫힘. X 버튼/외부 트리거도 그대로 동작.

invyone admin 다른 화면들과의 일관성보다 가운데 본문 공간 보존이 우선이라
overlay 패턴 채택. 다른 화면(screenMngList, deptMngList)은 detail 영역이
처음부터 펼쳐진 2-pane 구조라 별개.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:59:53 +09:00
johngreen 6b204806b6 fix(공통코드): 그룹 코드 중복 INSERT 시 친절한 400 메시지 (500 → IllegalArgumentException)
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m17s
기존: 중복 그룹 코드 등록 시 PK violation 으로 500 + "서버 내부 오류"
→ 사용자가 왜 안 되는지 알 수 없음.

변경: insertCodeInfo 진입 시 getCodeInfoInfo 로 사전 체크. 이미 존재하면
IllegalArgumentException 으로 던져 GlobalExceptionHandler 가 자동으로
400 + "이미 존재하는 그룹 코드입니다: {코드}" 메시지로 응답.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:34:41 +09:00
johngreen d8877b243a chore(테이블타입): legacy input_type 1,207 row 표준 8종으로 통합 (V026 / RUN_091)
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m33s
5/15 common-code 재설계가 화이트리스트를 8종으로 좁히면서 빠뜨린
운영 DB 데이터 정리. 90787d83 의 화이트리스트 확장 fix 는 회복용
보호막이었고, 본 PR 은 데이터를 표준으로 통합하는 후속 정리.

매핑:
  category/select/radio/checkbox/boolean → code
  textarea → text
  datetime → date

영향: 메타 DB 1,207 row 갱신. 테넌트 DB 들은 비어있어 0 row.
WHERE input_type IN (...) 으로 멱등 (재실행 시 0 row).

화이트리스트 축소는 운영 안정 확인 후 별도 PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:30:39 +09:00
johngreen 90787d837f fix(테이블타입): USER_SELECTABLE_INPUT_TYPES 화이트리스트에 legacy input_type 7개 복원
Build & Deploy to K8s / build-and-deploy (push) Successful in 15m1s
5/15 common-code 재설계가 화이트리스트를 8종으로 좁히면서 운영 DB 의
옛 input_type 값들을 매핑/정리하는 마이그레이션을 빠뜨려, 컬럼 설정
저장 batch POST 가 한 row 라도 legacy 값(category/select/textarea/
checkbox/radio/datetime/boolean)을 포함하면 400 거부.

운영 메타 DB 실측: 화이트리스트 밖 row 1,207건 (category 886,
select 149, textarea 102, checkbox 55, radio 12, datetime 2, boolean 1).

운영 데이터/UI 의미를 보존하기 위해 매핑이 아닌 화이트리스트 확장
(legacy 7종 추가)으로 회복. legacy 정리는 별도 PR 에서 점진적으로.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:29:13 +09:00
johngreen 752e4fb644 fix(테이블타입): syncScreenLayoutsInputType SQL — SCREEN_LAYOUTS.PROPERTIES 가 varchar 라 JSONB 캐스팅 추가
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m19s
운영 DB 의 SCREEN_LAYOUTS.PROPERTIES 컬럼이 character varying 인데 mapper SQL 은
PROPERTIES->>'...' 와 JSONB_SET(PROPERTIES, ...) 를 그대로 사용해
PG 가 'operator does not exist: character varying ->> unknown' 으로 거부.
이로 인해 syncScreenLayouts 가 던지는 SQLException 이 try-catch 로
무시되긴 하지만 외부 @Transactional 이 이미 aborted 상태가 되어
후속 ensureTableInLabels (insertTableLabelIfNotExists) 가
'current transaction is aborted' 로 연쇄 실패 → 컬럼 설정 저장 500.

- SL.PROPERTIES::JSONB 캐스팅 (WHERE / SET 양쪽)
- JSONB_SET 결과를 ::TEXT 로 캐스팅해 varchar 컬럼에 안전 저장
- 운영 4 DB (invyone, siflex/test01/test02 _invyone) 전수 검증:
  invalid JSON row 0건 → 캐스팅 안전

mapper SQL 만 변경. DB 마이그레이션 불필요.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 23:55:48 +09:00
johngreen 14832a28ab fix(테이블타입): TABLE_TYPE_COLUMNS 에 ON CONFLICT 매칭용 UNIQUE INDEX 추가 + 중복 정리
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m27s
테이블 타입관리의 모든 쓰기 API (UNIQUE/NOT NULL 토글, 컬럼 설정 저장,
input-type upsert) 가 500 반환. 원인은 mapper SQL 의
ON CONFLICT (TABLE_NAME, COLUMN_NAME, COMPANY_CODE) 가 매칭할 unique
제약/인덱스가 운영 DB 에 존재하지 않아 PG 가
"there is no unique or exclusion constraint matching the ON CONFLICT
specification" 으로 거부.

- StartupSchemaMigrator MIGRATIONS 에 V025 / RUN_090 (1) (2) 추가:
  (1) ROW_NUMBER 로 (table, column, company) 중복 행 정리
      (운영 메타 DB 실측 2 그룹 / 4 row — 동일 데이터의 NULL updated_date
      옛 row 제거. 테넌트 DB 들은 중복 0건).
  (2) UX_TABLE_TYPE_COLUMNS_TCC UNIQUE INDEX 생성 (IF NOT EXISTS — 멱등).
- RUN_090_MIGRATION.md 신설.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 23:39:30 +09:00
johngreen a0a4dc3bf5 fix(테이블타입): TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO DB rename 누락 보완
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m24s
5/15 common-code 재설계(commit 2348800e) 가 mapper SQL 6 군데 컬럼
참조를 CODE_INFO 로 바꾸면서 DB 컬럼 rename 마이그레이션을 빠뜨려,
모든 테넌트 사이트에서 테이블타입관리 > 테이블 클릭 시
GET /api/table-management/tables/{name}/columns 가 500
(column "code_info" does not exist) 을 반환.

- StartupSchemaMigrator MIGRATIONS 에 V024 항목 추가
  DO 블록으로 information_schema 확인 후 rename — 멱등.
- RUN_089_MIGRATION.md 신설 (V023 IS_SOLUTION_ONLY 운영 가이드도 합본).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:26:38 +09:00
johngreen 8fff53b165 Merge pull request 'fix(멀티테넌시): 테넌트 사이트 관리자 메뉴에서 솔루션 전용 메뉴 차단' (#21) from johngreen into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m3s
2026-05-15 18:36:42 +09:00
johngreen c530a67cee fix(멀티테넌시): 테넌트 사이트 관리자 메뉴에서 솔루션 전용 메뉴 차단
- AdminController.getAdminMenus 에 Host 헤더 기반 is_management_host 추가
  (기존엔 user-menus 만 필터, /admin/menus 는 미적용이라 관리자 모드 사이드바에서 노출됨)
- admin.xml selectAdminMenuList anchor + recursive 양쪽에 IS_SOLUTION_ONLY 필터 추가
- StartupSchemaMigrator: ALTER 외에 UPDATE 추가, 프로비저닝된 테넌트 DB 의
  회사관리/서브도메인관리/감사로그 메뉴 행을 부팅 시 IS_SOLUTION_ONLY=TRUE 로 마킹

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:35:33 +09:00
DDD1542 34060d9534 Merge branch 'gbpark-node'
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m32s
2026-05-15 16:55:31 +09:00
DDD1542 2348800e68 refactor(common-code): 마스터-디테일 재설계 — code_info(그룹) + code_detail(재귀 트리)
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m22s
카테고리/캐스케이딩 시스템 (B/C/D) 전부 폐기:
- BE: mapper/Service/Controller 9세트 삭제 (cascading*, categoryTree, tableCategoryValue, categoryValueCascading, codeMerge)
- FE: 페이지 3 + API 8 + hooks 2 + 폐기 컴포넌트 6 삭제, 14곳 의존성 정리
- DB: 12 테이블 DROP, TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO rename

신설 commonCode 마스터-디테일:
- code_info: 1레벨 그룹 마스터
- code_detail: 2~∞ depth 재귀 트리 (parent_detail_id self-FK, depth 자동 계산)
- API: /api/common-codes/{info,detail}
- CodeCategoryFormModal/Panel → CodeInfoFormModal/Panel rename
- code_category 컬럼명 전부 code_info 로 치환 (mapper/Java/FE)
- 옛 commonCode API URL (/categories/...) → getCodeOptions 어댑터 + /detail?code_info=... 전환

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:50:50 +09:00
hjjeong d61777ab5f fix(admin): 외부커넥션 mapper varchar 캐스팅 + 외부커넥션/배치관리 UI 정돈
- mapper/externalDbConnection.xml: WHERE ID = #{id} 5곳 + ID != #{exclude_id} 1곳에 ::varchar 캐스팅 추가
  (EXTERNAL_DB_CONNECTIONS.ID 가 V001 마이그레이션으로 VARCHAR 인데 long 바인딩되어 character varying = bigint 비교 불가로 500 발생하던 것을 해결)
- exconList: 페이지 overflow-hidden + Tabs/TabsContent 가 flex 컨테이너, ResponsiveDataView scrollContainer 활성화로 테이블 안에서만 sticky header + 자체 스크롤
- exconList/RestApiConnectionList: text-3xl→text-lg/text-sm→text-xs/h-10→h-8 등 컴팩트 폰트로 통일 (배치관리/플로우관리와 톤 매칭)
- RestApiConnectionList: Table divClassName 으로 wrapper 자체에 스크롤 위임 + sticky TableHeader 적용
- ResponsiveDataView: compact 모드일 때 폰트/셀패딩/카드 폰트도 함께 축소, scrollContainer 모드에서 @3xl:block 이 flex 를 덮어쓰던 우선순위 충돌 해결, sticky header 알파 제거
- batchmngList: Pagination 컴포넌트 적용 (RPS batchmngList 참고, 페이지당 10/20/50/100 선택), 컨테이너를 h-full min-h-0 overflow-hidden + 리스트만 자체 스크롤로 변경

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:38:23 +09:00
johngreen d5f9814865 Merge remote-tracking branch 'origin/main' into johngreen
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m23s
2026-05-15 12:22:45 +09:00
johngreen 824a3100ce security(멀티테넌시): 관리 plane vs 테넌트 plane 격리 + 부서관리 후속
이번 PR 은 invyone 멀티테넌시 SaaS 의 "관리 plane vs 테넌트 plane" 격리를
4 영역(PR #A~D) 에서 강화하고, 별도로 진행 중이던 부서관리 후속 작업을 포함한다.

# 보안 (plane 격리)

PR #A — controller/CompanyManagementController 인증 누락 패치
  /api/company-management/* 가 JWT/role/host 체크 없이 외부에서 누구나 회사 삭제
  + 디스크 통계 호출 가능했던 critical 누수 막음. SuperAdminGuard.enforce() 적용.

PR #C — cross-tenant 컨트롤러 호스트 격리 + 감사 로그
  CrossTenantContext.requireManagementHost() 헬퍼 추가, 5 컨트롤러
  (CrossTenantContext/Controller/UserController/RoleController/DeptController) 모두
  테넌트 호스트에서 호출 시 403. CompanyAuditLogService 에 cross-tenant write 4종
  (USER_CREATE/DELETE, PW_RESET, ROLE_UPDATE) audit action 추가.
  SuperAdminGuard.isTenantHost 가시성 public static 으로 승격.

PR #B — 프론트 솔루션 전용 admin 페이지 가드
  admin/* 페이지 전수 분류 결과 솔루션 전용 3건 식별:
  subdomainList / companyList / audit-log. 각 페이지에 isManagementHost
  useEffect 가드 + redirect 추가. 사이드바도 같이 숨김.

PR #D — MENU_INFO.IS_SOLUTION_ONLY 컬럼 + DB-driven 메뉴 필터
  V023 마이그레이션으로 컬럼 추가 + 솔루션 메뉴 3개 마킹.
  admin.xml selectUserMenuList 에 호스트 기반 필터 추가, AdminController.getUserMenus
  가 Host 헤더로 is_management_host 결정. 프론트 MANAGEMENT_ONLY_MENU_URLS
  하드코딩 set 폐기 (DB 가 대신함). 페이지 자체 가드는 defense in depth 로 유지.
  StartupSchemaMigrator 에 V023 등록되어 모든 테넌트 DB 부팅 시 자동 적용.

# 부서관리 후속 (이전 PR #18/#19 follow-up)

DepartmentController/Service + frontend deptMngList/department.ts 의 추가 작업분.
이번 격리 작업과 무관하지만 같이 정리.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:59:15 +09:00
DDD1542 387a5c2bd7 Merge remote-tracking branch 'origin/main' into gbpark-node
Build & Deploy to K8s / build-and-deploy (push) Successful in 11m34s
2026-05-14 17:42:17 +09:00
DDD1542 3883031c0b feat(studio): Phase G — KPI stats / chart / cardList / groupedTable + canonical container tabs
INV Studio 데이터 뷰 시리즈. 솔루션 개발 단계라 backward-compat alias 없이 깔끔하게.

Backend:
- TableManagementController + Service: /aggregate, /aggregate-group, /select-rows endpoint 추가
  sanitize + hasColumn 검증 + buildAggregateWhere 공유 헬퍼

Frontend canonical view components (신규):
- stats: DB-first KPI editor (CPSegment 메타 chip, 컬럼 dropdown, 디자인 모드 debounce 350ms preview)
- chart: recharts (bar / horizontalBar / line / donut)
- card-list: title/subtitles/metrics 카드 카탈로그 (list / grid 레이아웃)
- grouped-table: 클라이언트 측 groupBy + 그룹 헤더 row

Canonical container (Phase G.2 / G.2.5 / G.2.6):
- containerType='tabs' 활성 탭만 mount, ChildSlot 으로 자식 렌더
- ScreenDesigner.handleComponentDrop 가 canonical container tabs 도 인식
- 우측 V2PropertiesPanel 4-way 분기: tab child / panel child / selected / empty
  nested path update + saveToHistory, delete handler 동기화

Shared utilities:
- useDbColumns hook (모듈 캐시), ColumnPicker (CPSelect 기반)
- OptionFilterRow 자연어 카드 형식 (컬럼 dropdown / 조건 select / 값 입력)
- _shared/use-table-rows.ts (cardList + groupedTable 공용 fetch)
- IconPicker: 한글 키워드 80+ alias, 휠 스크롤 fix, 360px 상한, 결과 80→300

stats DB-first UX (Phase G.4.x):
- DB / 정적 모드 이분법 제거 — 항상 dataSource 시작
- collapsed: 라벨 input + KpiMetaSegment chip (테이블 · 집계 · 컬럼 · 필터수)
- expanded: 데이터 / 필터 / 외형 / 고급 flat CP rows
- useSlideToggle hook 으로 펼침/닫힘 양방향 애니메이션
- 변화량 (delta) 수동 입력 UI 제거 — 향후 DB 자동 계산 영역
- 카드 fetch state 명시: loading / error / 대기 중 / 테이블 미설정

기타:
- ScreenDesigner.tsx → InvyoneStudio.tsx rename (활성 빌더 파일)
- 모든 hardcoded #6c5ce7 fallback 제거, hsl(var(--primary)) 토큰만 사용 (light/dark/테마 자동 적응)
- StatsDefinition default_config 도 DB-first placeholder (value: 0 박지 않음)

Docs:
- notes/gbpark/2026-05-14-studio-data-view-roadmap.md (G.0 ~ G.4.2 진행 기록)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:41:50 +09:00
johngreen 2f52d9587e Merge pull request 'fix(부서관리): 보안 + 운영 데이터 버그 8건 (PR #18/#19 후속)' (#20) from johngreen into main
Build & Deploy to K8s / build-and-deploy (push) Failing after 10m45s
2026-05-14 08:26:16 +00:00
johngreen 4f13d2e440 fix(부서관리): 보안 + 운영 데이터 버그 8건 (PR #18/#19 후속)
- security: syncManagers 가 user_id 의 회사 격리·실존 검증 (cross-tenant injection 차단)
- bug: base_date 필터에 START_DATE IS NULL 조건 추가 — 옛날 데이터가 기준일 켜자마자 사라지는 문제 해결
- bug: PUT partial update — 매니저 키 없으면 sync skip (단일 필드 수정 시 매니저 보존)
- bug: 페이지 로드 시 단일 컬럼 backfill — PR #19 이전 데이터가 chip UI 에서 사라지는 문제 해결
- validation: Controller base_date YYYY-MM-DD regex (잘못된 형식 시 400)
- validation: 프론트 handleSave start_date > end_date 체크
- robustness: parseManagersJson 64KB max + log.warn (catch silent swallow 제거)
- ops: StartupSchemaMigrator 실패 테넌트 DB 명단 부팅 종료 시 log.error 집계
2026-05-14 17:25:31 +09:00
johngreen 1613fae8fb Merge pull request 'feat(부서관리): 다중 관리자 + 조직장 (DEPT_MANAGERS)' (#19) from johngreen into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m54s
2026-05-14 06:20:59 +00:00
johngreen c350ebe86a feat(부서관리): 다중 결재/부서 관리자 + 조직장 (DEPT_MANAGERS 매핑 테이블)
- 마이그레이션 V022/RUN_088: DEPT_MANAGERS 신규 (role: approval/dept/org_leader, PK 3-tuple, FK CASCADE)
- StartupSchemaMigrator 에 V022 idempotent CREATE 추가 → 테넌트 DB 자동 동기화
- mapper.xml: SELECT 에 3 json_agg ::TEXT 컬럼 추가, insertDeptManagers + deleteDeptManagersByDeptAndRole 신규
- service: parseManagersJson + syncManagers (delete-all + insert-all, 최대 10명, 역할 한글 메시지)
- frontend: types 3 필드, DeptDetailDraft 확장, ManagerChipsField (chip+UserSearchModal 재사용), 조직장 Row 신규

기존 DEPT_INFO.APPROVAL_MANAGER / DEPT_MANAGER 단일 컬럼은 호환을 위해 유지.
2026-05-14 15:19:50 +09:00
johngreen 5335dc78b0 Merge pull request 'feat(부서관리): 기준일 필터 + 시작일/종료일 UI 노출' (#18) from johngreen into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m57s
2026-05-14 05:16:12 +00:00
johngreen ecad2915ce feat(부서관리): 기준일 필터 + 시작일/종료일 UI 노출
- 백엔드: selectDepartments 에 base_date <if> 블록 추가 (start_date <= base_date AND (end_date IS NULL OR end_date >= base_date))
- 서비스/컨트롤러에 3-arg overload 와 @RequestParam("base_date") 추가
- 프론트: 사용기간 RadioGroup + 시작일/종료일 Row 의 {false &&} hide 제거
- loadDepartments 가 periodMode === "date" 일 때 baseDate 를 API 에 전달
2026-05-14 14:12:59 +09:00
johngreen 0552425f47 Merge pull request 'feat(테이블타입): 헤더 표시명/설명 inline click-to-edit (Google Docs 패턴)' (#17) from johngreen into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 7m1s
2026-05-14 01:53:05 +00:00
johngreen ca241c017d feat(테이블타입): 헤더 표시명/설명 inline click-to-edit (Google Docs 패턴)
기존 "헤딩 + 옆에 input box 2개 + 저장 버튼" 구조의 UX 문제:
- 같은 값을 헤딩과 input 양쪽에서 중복 표시
- 항상 폼처럼 보여 컬럼 그리드 시선을 뺏음
- 라벨만 바꾸려는 의도가 "전체 설정 저장" 에 묶여 흐려짐

변경: 헤딩 텍스트 자체를 클릭하면 그 자리에서 input 으로 변신
(Google Docs 문서 제목 / Notion 패턴).
- blur 또는 Enter → PUT /label 즉시 저장
- Esc → 취소
- hover 시 muted/60 배경, cursor: text, title tooltip 으로 affordance
- 설명이 비어 있으면 "+ 설명 추가" 힌트 표시
- 표시명은 비울 수 없게 가드 (toast.error)

"전체 설정 저장" → "컬럼 설정 저장" 으로 책임 분리:
- 헤더 라벨/설명: inline 즉시 저장
- 컬럼 input_type/web_type/detail_settings 등 일괄: 버튼

키보드 접근성:
- Tab 으로 헤딩에 focus → Enter/Space 로 편집 모드
- Esc 로 취소, Enter 로 커밋 (blur 트리거)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 10:52:29 +09:00
johngreen ec679ac640 Merge pull request 'fix(테이블타입): constraints SQL ARRAY_AGG → text 캐스트로 일원화' (#16) from johngreen into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m45s
2026-05-14 00:18:53 +00:00
johngreen 1e1b3e103c fix(테이블타입): constraints SQL ARRAY_AGG → text 캐스트로 일원화
이전 PR (#15) 의 Java parseColumnArray 분기 추가가 실제 운영 환경에서
빈 배열을 그대로 반환 — MyBatis ↔ PostgreSQL JDBC 의 array 타입 변환이
java.sql.Array 가 아닌 다른 경로로 도착하는 듯.

방식 변경: SQL 단에서 ARRAY_AGG(...)::text 캐스트 → PostgreSQL 가
"{email,phone}" String 으로 반환. parseColumnArray 의 기존 String 분기
(중괄호 제거 + 쉼표 split) 가 자연스럽게 처리.

장점:
- JDBC 드라이버 / MyBatis 변환 동작에 의존하지 않음
- parseColumnArray 코드 단순 복원 (List/String 2분기)
- 한 줄 SQL 변경으로 PK/IDX 두 쿼리 모두 해결

검증:
- gradle compileJava BUILD SUCCESSFUL
- solution.invyone.com 에서 customer_mng PK columns / email IDX columns
  비어있지 않음 확인 예정

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 09:18:26 +09:00
johngreen 35d5a00b20 Merge pull request 'fix(테이블타입): constraints API PgArray 디코딩 + primary_key key 매칭' (#15) from johngreen into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m29s
2026-05-13 23:33:17 +00:00
johngreen 0365b743f5 fix(테이블타입): constraints API PgArray 디코딩 + primary_key key 매칭
테이블 타입 관리 페이지에서 PK/IDX 토글 후 UI 상태가 갱신되지 않던 버그.

1. Backend: TableManagementService.parseColumnArray
   - PostgreSQL ARRAY_AGG 가 java.sql.Array (PgArray) 로 오는데
     List/String 만 처리해서 항상 List.of() 반환 → columns 빈 배열
   - 조치: java.sql.Array 분기 추가, arr.getArray() → Object[] → List<String>

2. Frontend: loadConstraints
   - 백엔드는 result.put("primary_key", ...) 로 snake_case 반환
   - 프론트가 data.primaryKey 로 camelCase 로 읽어 undefined → 항상 빈 PK
   - 조치: data.primary_key 로 통일

이전 PR (#14) 의 IDX payload 수정과 합쳐, PK/IDX 토글이 API 호출/DB 적용/
UI 반영까지 한 사이클로 동작.

검증:
- gradle compileJava BUILD SUCCESSFUL
- solution.invyone.com 에서 IDX 토글 양방향 확인 예정

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 08:32:48 +09:00
johngreen ff3d4c2cc5 Merge pull request 'fix(테이블타입): IDX/label API payload key snake_case 통일' (#14) from johngreen into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 7m1s
2026-05-13 23:20:22 +00:00
johngreen 44f5b134a5 fix(테이블타입): IDX/label API payload key snake_case 로 통일
테이블 타입 관리 페이지에서 IDX 토글 / 테이블 라벨 저장이 400 에러로
조용히 실패하던 버그. 백엔드는 body.get("column_name") / get("index_type")
/ get("display_name") 등 snake_case 로 읽는데 프론트가 camelCase 로 보내고
있었음 (CLAUDE.md Map key snake_case 컨벤션 위반).

- POST /table-management/tables/:t/indexes
  { columnName, indexType, action } → { column_name, index_type, action }
- PUT /table-management/tables/:t/label
  { displayName } → { display_name }

PK 는 다이얼로그 확인 흐름, NN/UQ 는 key 가 맞아 영향 없음.
SUPER_ADMIN 으로 테스트 시 IDX 만 안 되던 증상 일치.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 08:18:09 +09:00
johngreen ff4033b927 Merge pull request #13 - fix+security: bug hunt 6 + 인가/SQL 2
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m46s
johngreen -> main: 테이블 타입 관리 bug fix 6 + 보안 hardening 2
2026-05-13 09:08:11 +00:00
johngreen efea906ead security(테이블타입): TableManagementController 인가 + createLogTable SQL injection 강화
bug hunt security-reviewer 발견 2건 보안 fix:

1. TableManagementController 인가 누락 (OWASP A01 Broken Access Control)
   - 15개 write/DDL endpoint 가 admin role 검증 없이 JWT 만 있으면 호출 가능
   - 일반 사용자가 PK 재설정/index 변경/컬럼 수정 가능했음
   - 조치:
     - DepartmentController 의 isAdmin/isSuperAdmin helper 패턴 복사
     - SUPER_ADMIN 전용 (DDL 5건): primary-key, indexes, nullable, unique, log
     - admin (COMPANY_ADMIN+) (10건): updateColumnSettings, addTableData, editTableData, deleteTableData, multi-save 등
     - read 19건은 그대로 (일반 사용자 접근 유지, company_code 격리만)

2. createLogTable SQL injection (OWASP A03 Injection)
   - information_schema.data_type 을 raw concat 으로 DDL 생성
   - 조치:
     - ALLOWED_LOG_COLUMN_TYPES Set 으로 화이트리스트 (varchar/text/integer/numeric/boolean/date/timestamp/jsonb 등 21개)
     - sanitize 빈 식별자 차단 + 원본 테이블에 없는 컬럼 skip
     - colDefs empty 시 IllegalArgumentException
     - 알 수 없는 type 은 text 로 안전 대체

검증:
- gradle compileJava BUILD SUCCESSFUL
- mapper XML 0건 변경 (READ 경로 보호)
- 별도 미해결 보안 이슈 (k8s secrets git 노출, CORS 와일드카드 등) 는 본 PR scope 외

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 18:07:40 +09:00
johngreen 420b92bc7b fix(테이블타입): bug hunt 발견 6건 수정 (B1~B6)
review 4명 (debugger + security-reviewer + architect + code-reviewer) 발견:

- B1 [CRITICAL]: DdlService.convertToInputType 에 file/image/numbering case 추가
  - 사용자가 파일/이미지/채번 선택해도 silent text 저장되던 버그
  - 박창현 image 2 의 8개 중 3개가 운영에서 작동 안 함
- B2 [MAJOR]: TableManagementService.updateColumnSettings null check
  - settings 에 input_type 키만 있고 값 null 일 때 500 에러 방지
- B3 [MAJOR]: TableSettingModal.tsx 'direct' default → 'text'
  - 운영의 bom.status 같은 컬럼이 UI 에 'direct' 표시되던 원인 제거
- B4 [MINOR/UX]: TypeOverviewStrip 에 Legacy 합산 칩 추가
  - V0 의 legacy 1,209 row (category 888 외) 가 strip 에서 보이도록 amber 칩 + 도넛 호
- B5 [DRY]: USER_SELECTABLE_INPUT_TYPES 공통 상수 추출
  - TableManagementService:30 + DdlService:43 중복 → InputTypeConstants 신설
- B6 [type safety]: context flag enum 화
  - 'user-insert'/'user-update-type'/'user-update-other'/'system-normalize' string → InputTypeContext enum
  - typo silent fail 차단

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 18:07:19 +09:00
hjjeong 0328f618b9 Merge pull request 'style(rolesList): 다른 메뉴 톤에 맞춰 사이즈/글씨 축소' (#12) from hjjeong into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m35s
Reviewed-on: #12
2026-05-13 08:23:51 +00:00
hjjeong f53307a72e Merge remote-tracking branch 'origin/main' into hjjeong 2026-05-13 17:20:59 +09:00
hjjeong cbf94dc90f feat(batch): TO DB 자동 선택 (internal) + Select 컴포넌트 controlled 화
새 배치 (REST API → DB) 진입 시 매번 사용자가 "데이터베이스 커넥션 선택" 셀렉트에서
"내부 DB" 를 직접 골라야 했음. 대부분의 배치가 internal 적재라 디폴트 채움이 자연스러움.

1) TO DB 자동 선택
   useEffect 로 batchType === "restapi-to-db" + connections 로드 + toConnection 비어있음
   조건 만족 시 handleToConnectionChange("internal") 자동 호출. 사용자가 외부 DB 로 변경하면
   toConnection != null 이 되어 더 이상 자동 동작 안 함.

2) Select controlled 화
   DB 커넥션/테이블 Select 가 value prop 없는 uncontrolled 상태였음.
   setToConnection/setToTable state 가 바뀌어도 Select UI 가 placeholder 그대로 →
   programmatic 자동 선택이 시각적으로 반영 안 됨.
   → value prop 추가:
     - DB 커넥션: toConnection.type === "internal" ? "internal" : String(toConnection.id)
     - 테이블: toTable

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 17:18:09 +09:00
hjjeong 54a8f97f78 fix(batch): 미리보기 → 매핑 카드 표시 흐름 정상화 + 매핑 카드 컴팩트화
배치 생성 흐름 검증 중 발견된 4가지 이슈 일괄 정정.

1) BatchManagementService.previewRestApiData — camelCase 키 명시 remap
   직전 커밋(b752de23)에서 convertCamelToSnake() 호출 추가했지만 그 함수의 실제 구현이
   batch_configs 전용 snake→snake remap 이라 사실상 no-op. 프론트의 apiUrl 등 camelCase
   가 변환되지 않아 isBlank(api_url)=true → 400.
   → previewRestApiData 진입부에 직접 remap (apiUrl/apiKey/requestBody/dataArrayPath/
     paramType/paramName/paramValue/paramSource/authServiceName 9개 키).

2) batchManagement.ts.previewRestApiData — 응답 totalCount 정규화
   백엔드는 total_count (snake_case) 로 응답하는데 프론트는 result.totalCount 로 읽음.
   토스트가 "2개 필드, undefined개 레코드" 로 표시됨.
   → 응답 normalize: total_count ?? totalCount ?? 0.

3) batch-management-new/page.tsx — root h-full overflow-y-auto
   페이지 root 가 overflow 처리가 없어 FROM/TO 카드 아래의 매핑 카드가 탭 컨테이너
   밖으로 잘려 사용자가 못 봄.
   → root div 에 h-full overflow-y-auto 추가.

4) RestApiToDbMappingCard — v5 컨벤션에 맞춘 컴팩트화
   다른 메뉴들과 톤 통일. CardHeader 패딩 축소, 폰트 size 일괄 다운,
   행 padding p-3 → p-2, Select/Input h-9 → h-7 text-xs, 순서 원형 h-6 → h-5,
   카드 내부 height 360 → 300px, 매핑 추가 버튼/삭제 버튼 컴팩트.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 16:32:41 +09:00
hjjeong b752de23a1 fix(batch): previewRestApiData 에 convertCamelToSnake 누락 보강 (400 원인)
데이터 불러오고 매핑하기 클릭 시 400 발생.
원인: 프론트는 camelCase (apiUrl/endpoint/method/apiKey/dataArrayPath/paramType/...)
로 body 를 보내는데 백엔드는 snake_case (api_url/endpoint/method/api_key/...) 키로 읽음.
다른 service 진입점 (updateBatchConfig / executeBatchConfig 등) 은 convertCamelToSnake
를 호출해서 자동 변환하는데 previewRestApiData 만 빠져있어 isBlank(apiUrl)=true 가
되며 IllegalArgumentException → 400.

수정: previewRestApiData 진입부에 convertCamelToSnake(body) 한 줄 추가.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:11:33 +09:00
hjjeong 6fcb101f59 style(batch): API 파라미터 설정을 collapsible 로 변경 — 기본 접힘
이전: 페이지 진입 시 항상 펼쳐져 큰 공간 차지 → 매핑 영역이 화면 밖으로 밀림
신규: <details> + summary 로 기본 접힘. 클릭 시만 펼침. 토글 아이콘 함께.

특정 사용자/조건으로 API 조회할 때만 쓰는 옵션이라 기본 접힘이 자연스러움.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:34:26 +09:00
hjjeong 47eed68072 fix(external-rest-api): WHERE ID = #{id} 에 ::varchar 캐스팅 추가
외부 커넥션 관리에서 연결 테스트 시 500 발생.
원인: 운영 DB 의 external_rest_api_connections.id 가 V001 legacy 마이그레이션으로
character varying 인데 mapper 의 WHERE ID = #{id} 가 controller 의 int id 를
그대로 받아 PgJDBC 가 보내는 BIND 가 정수.
PostgreSQL 이 "operator does not exist: character varying = integer" 거부 →
SQLException → ApiResponse 500 ("서버 내부 오류").

수정: 4곳 모두 #{id}::varchar 캐스팅 추가.
  - getExternalRestApiConnectionInfo (SELECT)
  - updateExternalRestApiConnection (UPDATE)
  - deleteExternalRestApiConnection (DELETE)
  - updateExternalRestApiConnectionTestResult (UPDATE)

배치 작업의 batch_config_id 패턴과 동일. 같은 V001 영향을 받은 다른 mapper
(externalDbConnection.xml / externalCallConfig.xml / booking.xml / delivery.xml /
multiConnection.xml / taxInvoice.xml 등) 도 같은 수정이 필요할 가능성 — 별도 작업으로 분리.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:25:17 +09:00
hjjeong d8f606ab00 style(batch): 기본 정보를 모드 토글과 한 행으로 통합 — 하단 매핑 영역 확보
사용자 보고: 기본 정보 카드가 별도 영역을 차지해서 한 화면에 매핑 영역(중요) 까지
스크롤 없이 안 들어옴. vexplor_rps 스크린샷처럼 상단을 한 줄로 컴팩트하게.

변경:
- 배치 타입 토글 grid + 기본 정보 카드를 단일 grid 로 통합
- xl(1280px+): grid-cols-[minmax(28rem,1.4fr)_1fr_1fr_1.5fr]
  → 모드토글그룹 + 배치명 + 스케줄 + 설명 한 줄
- xl 미만: grid-cols-1 stack
- 모드 토글 카드 컴팩트화: p-3 / h-9 아이콘 / text-[10px] description
- 설명: Textarea → Input (단일 행) 로 축소. 긴 설명은 어차피 메모 용도라 한 줄이면 충분.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:20:00 +09:00
hjjeong e8f517ed18 fix(batch): batch-management-new 도 풀폭 적용 — 이전 풀폭 커밋에서 누락된 파일
0bba1836 에서 batchmngList/page.tsx 와 edit/[id]/page.tsx 두 곳만 max-w 제거했고
batch-management-new 의 root mx-auto max-w-5xl 가 남아있어 가운데 1024px 컬럼으로
본문이 박혀있었음. 좌우 패널이 sparse 하게 보이는 원인.

mx-auto max-w-5xl → w-full

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:16:32 +09:00
hjjeong d02bc38f6c style(batch): FROM 카드 행 그룹화 + 컴팩트 폰트로 sparse 레이아웃 정리
이전: API 서버 URL / 인증 토큰 / 엔드포인트 / 메서드 / 데이터 배열 경로가 각각 단독 행
신규: 의미 단위로 그룹화 + 컴팩트화

- API 서버 URL (3fr) + HTTP 메서드 (1fr) 한 행
- 엔드포인트 + 데이터 배열 경로 50:50 한 행
- 인증 토큰: 라디오 → 세그먼티드 토글 버튼 그룹 + 입력을 한 행에 압축
- Label text-xs / Input h-9 text-sm 로 컴팩트 통일

vexplor_rps batch-management-new (L1417 부근) 의 컴팩트 레이아웃 패턴 참고.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:01:40 +09:00
hjjeong 0c9e22a679 feat(batch): 등록 REST API 연결 자동 호출 + 응답 필드 추출
vexplor_rps batch-management-new 의 applyRegisteredRestApi 핵심 흐름을 INVYONE 에
이식. 등록된 REST API 연결 선택 시:
  1. 폼(URL/엔드포인트/메서드/Body/인증 토큰) 자동 채움
  2. ExternalRestApiConnectionAPI.testConnectionById 로 자동 API 호출
  3. 응답 안에서 배열 자동 탐색 (depth ≤ 4)
  4. fromApiFields / fromApiData 채움 → 매핑 드롭다운에 필드 즉시 노출

UI: FROM 카드 최상단에 "🔗 등록된 연결" 셀렉터 추가.
로딩 중에는 셀렉터 라벨 옆에 스피너, 에러 시 destructive 톤 메시지.

vexplor_rps 와 다르게 제외한 부분:
  - Amaranth/Wehago 회사 전용 프리셋 (AMARANTH_TARGET_PRESETS) — 6종 ERP 동기화
    배치 전용 하드코딩이라 INVYONE 일반 사용자에겐 의미 없음. Phase 6 후속에서
    검토할 별도 영역.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:54:11 +09:00
hjjeong 570b3267ab feat(batch): batch-management-new 에 conditional 매핑 추가 (Phase 2 일관성)
vexplor_rps 와 비교 시 batch-management-new 에 조건 변환 모드가 빠져있어
batchmngList/edit/[id] 와 같은 패턴으로 정렬.

- MappingItem.sourceType 에 'conditional' 추가 + conditionalConfig 필드
- ConditionalConfig / ConditionalEditor / emptyConditionalConfig import
- Select 옵션에 "조건 변환" 추가
- conditional 일 때 ConditionalEditor 렌더
- validMappings 필터: conditional 은 평가 필드(apiField) + 룰 또는 default 필요
- apiMappings 변환: mapping_type='conditional' + mapping_config 객체 전송

note: 이 페이지는 신규 생성 전용이라 load(편집 복원) 로직은 없음.
편집은 batchmngList/edit/[id]/page.tsx 가 처리.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:29:21 +09:00
hjjeong 0bba1836fb fix(batch): 빈 화면 원인이던 openTab 키명 정정 + 본문 풀폭 적용
문제 1: 배치 수정/생성 탭이 빈 화면
- Tab type 정의는 admin_url (snake_case) 인데 호출자가 adminUrl (camelCase)
  로 전달 → TabPageRenderer 매칭 실패 → null 렌더 → 빈 화면
- 콘솔 경고: "[TabPageRenderer] 렌더링 불가 — 매칭 조건 없음" (TabContent.tsx:268)
- 11곳 (4파일) 의 adminUrl → admin_url 정정

문제 2: 리스트/편집 본문이 좁은 가운데 컬럼에 박혀있음
- batchmngList/page.tsx: max-w-[720px] → w-full
- batchmngList/edit/[id]/page.tsx: max-w-[640px] → w-full

해당 파일:
- batchmngList/page.tsx
- batchmngList/edit/[id]/page.tsx
- batchmngList/create/page.tsx
- batch-management-new/page.tsx

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:15:51 +09:00
hjjeong f70719aecb fix(batch): batch_execution_logs 의 VARCHAR 숫자 컬럼에 명시적 String 전달
운영 DB 검증 결과 batch_execution_logs.duration_ms / total_records /
success_records / failed_records 가 모두 character varying 으로 정의됨
(V001 legacy 마이그레이션 흔적). PgJDBC 가 Long/Integer 를 VARCHAR 컬럼에
자동 변환하지 못할 위험이 있어 명시적으로 String.valueOf 로 변환 후 전달.

mapper 의 COALESCE default 가 '0' (문자열) 인 점과도 일관.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:56:40 +09:00
hjjeong 3ab7deb196 test(batch): Phase 3 — MappingTransformerTest (18 cases)
vexplor_rps 알고리즘 1:1 이식이 정상 동작하는지 검증.

검증 항목:
- evaluateConditional: 단순 매칭 / null cfg / 빈 rules
- parseConditionalConfig: Map / String(JSON) / null / 손상 JSON 모두 안전
- getValueByPath: 단순 키 / 중첩 경로 / 없는 경로 / null obj
- partitionFixed: fixed / non-fixed 분리
- transformRow: direct / conditional("1"→"Y") / conditional default 폴백 /
  fixed 적용 / 점 표기법 ("user.profile.name") / company_code 자동주입 조건

18 tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:53:12 +09:00
hjjeong d592547242 fix(batch): writeTo @Transactional 제거 + end_time Timestamp 객체 전달
검증 전 선제 보강 2건.

1) BatchExecutor.writeTo 의 @Transactional 제거
   - 같은 클래스 내부 호출(execute → writeTo)이라 Spring AOP 가 우회되어 어차피 안 걸림
   - batch 의 정상 동작은 row 단위 독립 commit (일부 실패해도 다른 row 는 살아야 함).
     vexplor_rps 도 동일 패턴

2) BatchManagementService.executeBatchConfig 의 end_time 을 Timestamp 객체로 직접 전달
   - 기존: Timestamp.toString() 으로 변환 후 #{end_time}::timestamp 캐스트
   - 신규: Timestamp 객체 그대로 → PostgreSQL JDBC 가 timestamp 자동 변환,
     mapper 의 ::timestamp 캐스트는 noop

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:50:49 +09:00
hjjeong 6f8461a533 feat(batch): Phase 5 — executeBatchConfig 가 BatchExecutor 호출 + batch_execution_logs 기록
기존 stub(0 만 리턴) 였던 BatchManagementService.executeBatchConfig 를 실제 ETL 실행 + 로그 기록 흐름으로 교체.

흐름:
  1. RUNNING 상태로 batch_execution_logs INSERT (도중 비정상 종료 추적용)
  2. BatchExecutor.execute(batchConfig) 호출
     - 정상: failedRecords > 0 면 PARTIAL/FAILED, 아니면 SUCCESS
     - 예외: FAILED 로 마킹, error_message 기록
  3. 로그 UPDATE — execution_status, end_time, duration_ms, total/success/failed_records, error_message
  4. controller 응답에 execution_status + error_message 동봉

server_name 은 InetAddress.getLocalHost(), process_id 는 ProcessHandle.current().pid().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:31:44 +09:00
hjjeong 17172cf9b3 feat(batch): Phase 4 — BatchExecutor ETL 본체 (FROM → Transform → TO)
vexplor_rps batchSchedulerService.executeBatchMappings 의 1:1 이식.

흐름:
  1. mappings 를 fixed/non-fixed 로 partition
  2. non-fixed 를 (from_connection_type, from_connection_id, from_table_name) 으로 그룹화
  3. 그룹별 FROM 읽기 → MappingTransformer.transformRow → TO 저장
  4. (totalRecords, successRecords, failedRecords) 집계

FROM 소스:
  - internal     : sqlSession.getConnection() 의 동적 SELECT (식별자 화이트리스트 escape)
  - external_db  : ExternalDbConnectionService.executeQuery (SELECT-only)
  - restapi      : ExternalRestApiConnectionService.fetchData (등록된 연결 + dataArrayPath)

TO 대상:
  - internal     : INSERT / UPSERT(save_mode + conflict_key, ON CONFLICT DO UPDATE/NOTHING)
                   updated_date 컬럼 있으면 자동으로 NOW() 갱신
  - restapi      : 행 단위 POST/PUT/DELETE — testConnection 으로 호출
  - external_db  : 미지원 (보안 정책: ExternalDbConnectionService 가 SELECT-only)

vexplor_rps 대비 단순화 항목 (필요 시 후속 Phase 로 분리):
  - to_api_body 템플릿 기반 일괄 전송
  - URL_PATH_PARAM 컬럼 처리
  - auth_tokens 자동 조회 (inline-mode REST API: from_connection_id 없이 from_api_url 직접 호출)
  - row_filter_config

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:30:27 +09:00
hjjeong f9a9c67891 feat(batch): Phase 3 — MappingTransformer lookup 엔진
vexplor_rps batchSchedulerService L540~617 의 변환 로직 1:1 이식.
의존성 없는 정적 유틸 — BatchExecutor 가 FROM 읽기 결과를 TO 형태로 변환할 때 사용.

- transformRow: mapping_type 별 분기 (direct/conditional/fixed),
  멀티테넌시용 company_code 자동 주입, 점 표기법 path 평가
- evaluateConditional: ConditionalConfig.rules 의 when/then lookup + default 폴백.
  단순 문자열 동등 비교 (SpEL/JEXL 표현식 평가 안 함)
- getValueByPath: "response.access_token" 같은 중첩 키 지원
- parseConditionalConfig: JSONB 가 Map/String/null 셋 다 가능 — 안전 normalize
- partitionFixed: non-fixed / fixed 매핑 분리 헬퍼

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:26:28 +09:00
hjjeong f31a7f852f feat(batch): Phase 2 — 프런트 ConditionalEditor + 조건 변환 매핑 UI
- batch.ts: ConditionalRule / ConditionalConfig 타입 추가,
  BatchMapping 에 mapping_type ('direct'|'fixed'|'conditional') + mapping_config 필드
- ConditionalEditor.tsx: 평가 필드 선택 + when/then 룰 add/remove + default 입력 컴포넌트.
  emptyConditionalConfig / normalizeConditionalConfig 헬퍼 동봉. vexplor_rps 1:1 포팅
- batchmngList/edit/[id]/page.tsx:
  · MappingItem.sourceType 에 'conditional' 추가 + conditionalConfig 필드
  · 소스타입 Select 에 "조건 변환" 옵션
  · Load: mapping_type=conditional 인 매핑은 mapping_config JSON 파싱 후 복원
  · Save: sourceType=conditional 매핑은 mapping_config 객체와 함께 전송

저장된 룰: {"rules":[{"when":"1","then":"Y"}],"default":"?"} 형태.
Phase 1 의 BatchService 직렬화 경로로 JSONB 에 저장된다.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:25:08 +09:00
hjjeong 2675c82904 feat(batch): Phase 1 — BATCH_MAPPINGS.MAPPING_CONFIG JSONB 컬럼 + JSON 직렬화
- V021 Flyway: ALTER TABLE BATCH_MAPPINGS ADD COLUMN MAPPING_CONFIG JSONB
- StartupSchemaMigrator: 같은 ALTER 를 idempotent 항목으로 추가 (모든 활성 테넌트 DB 부팅 시 동기화)
- batch.xml: getBatchMappingsByConfigId SELECT 에 MAPPING_CONFIG::TEXT cast,
  insertBatchMapping VALUES 에 #{mapping_config,jdbcType=OTHER}::jsonb
- BatchService: ObjectMapper 주입, parseJsonField/stringifyJsonField 유틸,
  syncMappings 는 INSERT 전 직렬화, attachMappings 는 SELECT 후 Map 으로 역직렬화
- RUN_087_MIGRATION.md: 운영용 마이그레이션 runbook (사전 점검/사후 검증/롤백)

conditional 매핑(when/then/default) 룰을 행 단위 저장하는 컬럼.
direct/fixed 는 NULL. Phase 2~3 에서 프런트/엔진이 이 컬럼을 읽고 쓴다.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:24:56 +09:00
hjjeong dce665caea feat(batch): Phase 0 — batch_mappings CRUD path + 실행 로그/관리 SQL 정리
- BatchService: insertBatch/updateBatch 가 body.mappings 받아 replace-all 동기화,
  getBatchInfo 가 batch_mappings 리스트 attach (지금까지는 silently drop)
- batch.xml: getBatchMappingsByConfigId / insertBatchMapping / deleteBatchMappingsByConfigId 신규
- batchExecutionLog.xml / batchManagement.xml: batch_config_id 에 ::varchar 캐스팅,
  오타 'batch_execution_log' → 'batch_execution_logs' 정정
- batchmngList/page.tsx: 같은 batch ID 가 회사 간 중복될 때 React key 충돌 방지
- notes: vexplor_rps → INVYONE 배치 파이프라인 이식 분석 노트

vexplor_rps → INVYONE 파이프라인 이식의 Phase 0.
구체 분해는 notes/hjjeong/2026-05-12-batch-pipeline-current-state.md 참조.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:24:36 +09:00
gbpark c3e04adb23 docs(notes): close input canonical cleanup 2026-05-13 02:50:30 +09:00
gbpark 7bd08dcf9d refactor(components): consolidate canonical input cleanup 2026-05-13 02:38:29 +09:00
DDD1542 4a8413000b Consolidate canonical input migration
Build & Deploy to K8s / build-and-deploy (push) Failing after 11m17s
Remove legacy v2 input/select and file/media runtimes, add canonical option/file loaders, and document Codex handoff.
2026-05-12 18:36:43 +09:00
hjjeong 081feff51f style(rolesList): 다른 메뉴 톤에 맞춰 사이즈/글씨 축소
본문 텍스트 text-sm → text-xs, 헤더 보조 텍스트 text-xs →
text-[11px], 카드 헤더 p-3 → p-2.5, 좌측 권한 목록 그리드
260px → 220px. Input/Select h-8 → h-7 (메인) / h-7 → h-6 (서브),
메뉴 트리 row py-2 → py-1.5, 트리 들여쓰기 level*20+12 →
level*16+10, chevron h-3.5 → h-3. 4분할 직원 카드 영역
clamp(220, 32vh, 320) → clamp(200, 28vh, 280).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:17:39 +09:00
DDD1542 90035dd5c6 feat(numbering): 채번 관리 페이지 통짜 작업대 디자인 적용
Build & Deploy to K8s / build-and-deploy (push) Failing after 11m4s
채번 = 독립 자원 (컬럼과 N:M 연결) 모델로 페이지 통째 리뉴얼.
카드 박스 폐기, 좌측 sidebar + 우측 통짜 main + ⌘K 명령 팔레트 구조.

- frontend/styles/v5-layout.css: v5-nrm-* 섹션 추가 (메뉴관리 v5-mm-* 동일 패턴)
- numberingRuleList/page.tsx: PageHead 안 쓰고 메뉴관리 스타일 단순 헤더 +
  사이드바 (검색·필터·섹션·list) + Hero·파이프라인·2-col split·sticky save bar +
  ⌘K 팔레트 (검색·프리셋·기존 채번)
- 기존 NumberingRuleDesigner/SequenceManagementPanel/CreateDialog 의 로직을
  page.tsx 내부 sub-component (PipelineBlock·PartInspector·UsageList·CommandPalette) 로 재구성
- mockup HTML 산출물 notes/gbpark/ 보관

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 12:06:28 +09:00
gbpark baffd6affb Merge origin/main into gbpark-node
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m45s
부서관리 V1 슬림 스코프 + UX 리디자인, 25개 버그 일괄 수정, admin/부서관리
탭 라벨 fallback, Windows dev HMR 복원 흡수.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:51:34 +09:00
gbpark a5bbd1eb7c refactor(numbering-rule): NumberingRule → Input canonical 흡수 + 채번 관리 페이지 분리
- 옛 registry/numbering-rule, registry/v2-numbering-rule, V2NumberingRuleConfigPanel,
  NumberingRuleTemplate 폐기 — InvFieldConfigPanel + InputComponent 로 통합
- input 에 numbering-picker / select-pickers 추가, autonum 타입 흡수
- 채번 관리 전용 admin 페이지(systemMng/numberingRuleList) + CreateDialog +
  SequenceManagementPanel 신설
- backend NumberingRule controller/service/mapper 갱신 (시퀀스 관리 엔드포인트)
- input canonical 진행 노트 + 채번 관리 mockup 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:42:13 +09:00
580 changed files with 55160 additions and 65442 deletions
+79
View File
@@ -1,3 +1,82 @@
<!-- User customizations -->
# 절대 규칙: 검증 없는 주장 금지
내가 출력하는 모든 발언은 근거가 있어야 한다. 근거가 없으면 그 말을 하지 않는다. 위로·추정·일반론·"보통 그렇다"로 채우지 않는다.
## 위반 사례 (절대 하지 말 것)
- "100명 중 5명도 안 된다" 같은 통계를 출처 없이 만들어내기
- "통과 확률 70~80%" 같은 수치를 추정으로 제시하기
- "보통", "일반적으로", "대부분" 으로 시작하는 일반론
- 본인이 검증 안 한 SDK/API 동작을 단정적으로 설명하기
- 위로·격려를 위해 사실이 아닌 것을 끼워넣기
## 발화 전 자기 검증
한 문장이라도 출력하기 전에 다음을 확인:
1. **출처가 있는가?** — 코드(파일:라인), 명령 결과, 공식 문서, 사용자가 준 정보, 도구 호출 결과 중 하나
2. **출처가 없다면 추정인가?** — 추정이면 명시적으로 "추정이지만…" 또는 "확인 안 됐지만…" 으로 시작
3. **추정도 근거가 없으면?** — 말하지 않는다. "모릅니다" 또는 "확인이 필요합니다" 라고 한다
## 모를 때의 정답
- 검색·문서 조회·코드 읽기로 확인 가능하면 확인부터 한다
- 확인이 불가능하면 "모릅니다" 가 정답. 그럴듯한 답을 만들지 않는다
- 사용자 의사결정에 영향을 주는 사실일수록 더 엄격하게 적용
## 어겼을 때
사용자가 "그 근거 뭐야" 라고 묻거나 잘못된 사실을 지적하면:
- 즉시 인정. "맞습니다. 그 수치 제가 지어냈습니다." 같이 명시적으로 시인
- 변명·재포장 금지
- 무엇이 검증된 사실이고 무엇이 추정/날조였는지 다시 분리해서 제시
# 💬 사용자에게 설명할 때 — 그림으로 (★ 중요)
UI 변경 제안, 디자인 토론, 코드 구조 설명 등을 할 때는 **반드시 변경 전/후를 ASCII 표나 도식으로 그려서** 보여준다. 글로만 설명하면 사용자가 이해 못 한다.
## 원칙
1. **변경 제안은 무조건 Before / After 두 그림**
2. **코드 인용 (file:line, 변수명, CSS class) 최소화** — 결론과 시각적 영향 위주
3. **평어, 한국어, 짧은 문장**
4. **영문/SQL/전문용어 풀어쓰기** — "grid template" 대신 "표 컬럼 배치", "stopPropagation" 대신 "클릭이 위로 새는 거 막기"
5. **3줄 패턴 권장** — 무슨 일 / 사용자한테 보이는 영향 / 어떻게 고치는지
## 나쁜 예시 ❌
> "ColumnGrid.tsx:93-103 의 `grid-cols-[4px_140px_1fr_100px_160px_40px]` 를 5컬럼으로 축소하고, 라벨 셀에 sub-line 을 추가하면 entity/code/numbering 의 메타가 inline 으로..."
(사용자: "뭐라는지 모르겠어")
## 좋은 예시 ⭕
> **지금 모양:**
> ```
> 라벨·컬럼명 │ 참조/설정 │ 타입
> 거래처명 │ — │ 텍스트 ← 빈 칸
> 거래처ID │ customer_mng → ... │ 테이블참조
> ```
>
> **바꿔서:**
> ```
> 라벨·컬럼명 │ 타입
> 거래처명 │ 텍스트
> 거래처ID │ 테이블참조
> → customer_mng.id ← 정보 있을 때만 작게 밑에
> ```
## 옵션 제시할 땐 표로
```
| 옵션 | 핵심 | 단점 |
| A안 | 이름만 바꾸기 | 가장 가벼움 |
| B안 | 그룹을 잘게 쪼개기 | 그룹 수 늘어남 |
```
## 우선 순위
- 첫 시도에 글만 쓰지 말 것. 그림부터 그리고 글은 짧게 보충.
- 사용자가 "무슨 말인지 모르겠어" 하면 → 더 분해해서 다시 그림 그리기. 글 길어지면 더 헷갈림.
---
# INVYONE — Claude 작업 컨벤션
이 파일은 git 에 올라가는 **프로젝트 공용** Claude 가이드입니다. 모든 머신/팀원의 Claude Code 인스턴스가 이 컨벤션을 따라야 합니다.
+5
View File
@@ -33,6 +33,11 @@ dependencies {
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'
implementation 'org.postgresql:postgresql'
// 외부 커넥션 테스트용 JDBC 드라이버 (runtimeOnly — 내부 비즈니스 DB 는 PostgreSQL 만 사용)
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client:3.4.1'
runtimeOnly 'com.mysql:mysql-connector-j:8.4.0'
runtimeOnly 'com.microsoft.sqlserver:mssql-jdbc:12.8.1.jre11'
runtimeOnly 'org.xerial:sqlite-jdbc:3.46.1.0'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
implementation 'org.flywaydb:flyway-core'
@@ -0,0 +1,391 @@
package com.erp.batch;
import com.erp.service.ExternalDbConnectionService;
import com.erp.service.ExternalRestApiConnectionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
import org.springframework.stereotype.Service;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.util.*;
import java.util.regex.Pattern;
/**
* 배치 ETL 실행기 — vexplor_rps batchSchedulerService.executeBatchMappings 의 1:1 이식.
*
* 흐름:
* 1. 매핑을 (fixed | non-fixed) 로 partition
* 2. non-fixed 매핑을 (from_connection_type, from_connection_id, from_table_name) 키로 그룹화
* 3. 그룹별로 FROM 데이터 읽기 → MappingTransformer 로 행 변환 → TO 저장
* 4. (totalRecords, successRecords, failedRecords) 집계
*
* FROM 소스 지원:
* - internal : 현 tenant DB 의 테이블 (JDBC 직접 SELECT, LIMIT 1000)
* - external_db : ExternalDbConnectionService.executeQuery (SELECT-only 보안 정책)
* - restapi : ExternalRestApiConnectionService.fetchData (등록된 연결 + dataArrayPath)
*
* TO 대상 지원:
* - internal : 현 tenant DB INSERT / UPSERT (save_mode + conflict_key)
* - restapi : 행 단위 POST/PUT/DELETE — testConnection 으로 호출
* - external_db : 미지원 (ExternalDbConnectionService 가 SELECT-only 라 의도적으로 차단)
*
* 미지원 (vexplor_rps 대비 단순화):
* - to_api_body 템플릿 기반 일괄 전송
* - URL_PATH_PARAM 컬럼 처리
* - auth_tokens 자동 조회 (inline-mode REST API)
* - row_filter_config
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class BatchExecutor {
private final SqlSession sqlSession;
private final ExternalDbConnectionService externalDb;
private final ExternalRestApiConnectionService externalRest;
/** PostgreSQL 식별자 화이트리스트 (영문/숫자/언더스코어만). SQL injection 방어용. */
private static final Pattern SAFE_IDENT = Pattern.compile("[A-Za-z_][A-Za-z0-9_]*");
private static final int FROM_LIMIT = 1000;
public ExecutionResult execute(Map<String, Object> config) {
ExecutionResult r = new ExecutionResult();
Object mappingsRaw = config.get("batch_mappings");
if (!(mappingsRaw instanceof List)) {
log.warn("배치 매핑이 없습니다: {}", config.get("batch_name"));
return r;
}
@SuppressWarnings("unchecked")
List<Map<String, Object>> mappings = (List<Map<String, Object>>) mappingsRaw;
if (mappings.isEmpty()) {
log.warn("배치 매핑이 없습니다: {}", config.get("batch_name"));
return r;
}
// 1. fixed 분리
MappingTransformer.Partition partition = MappingTransformer.partitionFixed(mappings);
// 2. non-fixed 그룹화 (from_connection 기준)
Map<String, List<Map<String, Object>>> tableGroups = new LinkedHashMap<>();
for (Map<String, Object> m : partition.nonFixed) {
String key = str(m.get("from_connection_type")) + ":"
+ (m.get("from_connection_id") == null ? "internal" : m.get("from_connection_id"))
+ ":" + str(m.get("from_table_name"));
tableGroups.computeIfAbsent(key, k -> new ArrayList<>()).add(m);
}
if (tableGroups.isEmpty() && !partition.fixed.isEmpty()) {
log.warn("일반 매핑이 없고 고정값 매핑만 있어 실행 불가");
return r;
}
String companyCode = str(config.get("company_code"));
String saveMode = strOr(config.get("save_mode"), "INSERT");
String conflictKey = str(config.get("conflict_key"));
String dataArrayPath = str(config.get("data_array_path"));
// 3. 그룹별 처리
for (Map.Entry<String, List<Map<String, Object>>> e : tableGroups.entrySet()) {
String key = e.getKey();
List<Map<String, Object>> groupMappings = e.getValue();
Map<String, Object> first = groupMappings.get(0);
try {
log.info("테이블 처리 시작: {} → {} 컬럼 매핑", key, groupMappings.size());
// FROM 읽기
List<Map<String, Object>> fromData = readFrom(first, groupMappings, dataArrayPath, companyCode);
r.totalRecords += fromData.size();
// Transform
String toConnType = str(first.get("to_connection_type"));
List<Map<String, Object>> mappedRows = new ArrayList<>(fromData.size());
for (Map<String, Object> row : fromData) {
mappedRows.add(MappingTransformer.transformRow(
row, groupMappings, partition.fixed, toConnType, companyCode));
}
// TO 저장
WriteResult wr = writeTo(first, mappedRows, saveMode, conflictKey, companyCode);
r.successRecords += wr.success;
r.failedRecords += wr.failed;
} catch (Exception ex) {
log.error("테이블 처리 중 오류: {} — {}", key, ex.getMessage(), ex);
r.errorMessages.add(key + ": " + ex.getMessage());
}
}
return r;
}
// ── FROM 읽기 ───────────────────────────────────────────────────────────
private List<Map<String, Object>> readFrom(
Map<String, Object> firstMapping,
List<Map<String, Object>> groupMappings,
String dataArrayPath,
String companyCode
) {
String type = str(firstMapping.get("from_connection_type"));
String tableName = str(firstMapping.get("from_table_name"));
List<String> columns = new ArrayList<>();
for (Map<String, Object> m : groupMappings) {
String col = str(m.get("from_column_name"));
if (col != null && !col.isEmpty() && !columns.contains(col)) columns.add(col);
}
if ("restapi".equals(type)) {
return readFromRestApi(firstMapping, dataArrayPath, companyCode);
}
if ("external".equals(type) || "external_db".equals(type)) {
return readFromExternalDb(firstMapping, columns);
}
// internal (기본)
return readFromInternal(tableName, columns);
}
/** Internal DB 의 동적 SELECT. sqlSession 의 현 tenant connection 사용. */
private List<Map<String, Object>> readFromInternal(String tableName, List<String> columns) {
if (tableName == null) throw new IllegalArgumentException("from_table_name 누락");
if (columns.isEmpty()) throw new IllegalArgumentException("from_column_name 매핑 없음");
StringBuilder sql = new StringBuilder("SELECT ");
for (int i = 0; i < columns.size(); i++) {
if (i > 0) sql.append(", ");
sql.append(safeIdent(columns.get(i)));
}
sql.append(" FROM ").append(safeIdent(tableName));
sql.append(" LIMIT ").append(FROM_LIMIT);
try (Connection c = sqlSession.getConnection();
PreparedStatement ps = c.prepareStatement(sql.toString());
ResultSet rs = ps.executeQuery()) {
return materialize(rs);
} catch (SQLException e) {
throw new RuntimeException("internal SELECT 실패: " + e.getMessage(), e);
}
}
/** External DB SELECT — ExternalDbConnectionService.executeQuery 경유 (SELECT-only). */
@SuppressWarnings("unchecked")
private List<Map<String, Object>> readFromExternalDb(Map<String, Object> firstMapping, List<String> columns) {
Object connIdObj = firstMapping.get("from_connection_id");
if (connIdObj == null) throw new IllegalArgumentException("external_db 인데 from_connection_id 가 비어있음");
long connId = Long.parseLong(connIdObj.toString());
String tableName = str(firstMapping.get("from_table_name"));
StringBuilder sql = new StringBuilder("SELECT ");
for (int i = 0; i < columns.size(); i++) {
if (i > 0) sql.append(", ");
sql.append(safeIdent(columns.get(i)));
}
sql.append(" FROM ").append(safeIdent(tableName)).append(" LIMIT ").append(FROM_LIMIT);
Map<String, Object> result = externalDb.executeQuery(connId, sql.toString());
Object data = result.get("data");
return data instanceof List ? (List<Map<String, Object>>) data : List.of();
}
/** REST API → ExternalRestApiConnectionService.fetchData. dataArrayPath 로 배열 추출. */
@SuppressWarnings("unchecked")
private List<Map<String, Object>> readFromRestApi(
Map<String, Object> firstMapping, String dataArrayPath, String companyCode
) {
Object connIdObj = firstMapping.get("from_connection_id");
if (connIdObj == null) {
throw new UnsupportedOperationException(
"REST API 등록 연결 없는 inline-mode (from_api_url 직접 호출) 는 현재 미지원");
}
int connId = Integer.parseInt(connIdObj.toString());
String endpoint = str(firstMapping.get("from_table_name"));
Map<String, Object> params = new HashMap<>();
if (companyCode != null) params.put("company_code", companyCode);
Map<String, Object> result = externalRest.fetchData(connId, endpoint, dataArrayPath, params);
if (!Boolean.TRUE.equals(result.get("success"))) {
throw new RuntimeException("REST API 호출 실패: " + result.getOrDefault("message", ""));
}
Object data = result.get("data");
if (!(data instanceof Map)) return List.of();
Object rows = ((Map<String, Object>) data).get("rows");
if (!(rows instanceof List)) return List.of();
List<Object> raw = (List<Object>) rows;
List<Map<String, Object>> out = new ArrayList<>(raw.size());
for (Object o : raw) if (o instanceof Map) out.add((Map<String, Object>) o);
return out;
}
// ── TO 저장 ────────────────────────────────────────────────────────────
// 트랜잭션은 의도적으로 걸지 않음 — batch 의 정상 동작은 row 단위 독립 commit.
// 일부 row 가 실패해도 다른 row 는 살아야 successCount/failedCount 집계가 의미 있음.
public WriteResult writeTo(
Map<String, Object> firstMapping,
List<Map<String, Object>> rows,
String saveMode,
String conflictKey,
String companyCode
) {
if (rows == null || rows.isEmpty()) return new WriteResult();
String type = str(firstMapping.get("to_connection_type"));
String tableName = str(firstMapping.get("to_table_name"));
if ("restapi".equals(type)) {
return writeToRestApi(firstMapping, rows, companyCode);
}
if ("external".equals(type) || "external_db".equals(type)) {
throw new UnsupportedOperationException(
"external_db TO 쓰기는 현재 미지원 (ExternalDbConnectionService 가 SELECT-only)");
}
return writeToInternal(tableName, rows, saveMode, conflictKey);
}
/** Internal DB INSERT / UPSERT — 행 단위 PreparedStatement. */
private WriteResult writeToInternal(String tableName, List<Map<String, Object>> rows,
String saveMode, String conflictKey) {
WriteResult r = new WriteResult();
if (tableName == null) throw new IllegalArgumentException("to_table_name 누락");
safeIdent(tableName);
try (Connection c = sqlSession.getConnection()) {
for (Map<String, Object> row : rows) {
try {
String sql = buildInsertSql(tableName, row, saveMode, conflictKey);
try (PreparedStatement ps = c.prepareStatement(sql)) {
int idx = 1;
for (Object v : row.values()) {
ps.setObject(idx++, v);
}
ps.executeUpdate();
r.success++;
}
} catch (SQLException e) {
log.error("INSERT 실패 row={} — {}", row, e.getMessage());
r.failed++;
}
}
} catch (SQLException e) {
throw new RuntimeException("internal write 실패: " + e.getMessage(), e);
}
return r;
}
/** INSERT (또는 UPSERT) SQL 생성. row 의 key 순서로 컬럼/플레이스홀더 배열. */
private String buildInsertSql(String tableName, Map<String, Object> row,
String saveMode, String conflictKey) {
List<String> cols = new ArrayList<>(row.keySet());
StringBuilder sql = new StringBuilder("INSERT INTO ").append(safeIdent(tableName)).append(" (");
for (int i = 0; i < cols.size(); i++) {
if (i > 0) sql.append(", ");
sql.append(safeIdent(cols.get(i)));
}
sql.append(") VALUES (");
for (int i = 0; i < cols.size(); i++) {
if (i > 0) sql.append(", ");
sql.append("?");
}
sql.append(")");
if ("UPSERT".equalsIgnoreCase(saveMode) && conflictKey != null && !conflictKey.isEmpty()) {
safeIdent(conflictKey);
List<String> updateCols = new ArrayList<>();
for (String col : cols) if (!col.equalsIgnoreCase(conflictKey)) updateCols.add(col);
sql.append(" ON CONFLICT (").append(conflictKey).append(") ");
if (updateCols.isEmpty()) {
sql.append("DO NOTHING");
} else {
sql.append("DO UPDATE SET ");
for (int i = 0; i < updateCols.size(); i++) {
if (i > 0) sql.append(", ");
String c = safeIdent(updateCols.get(i));
sql.append(c).append(" = EXCLUDED.").append(c);
}
if (cols.stream().anyMatch(c -> c.equalsIgnoreCase("updated_date"))) {
sql.append(", updated_date = NOW()");
}
}
}
return sql.toString();
}
/** REST API TO — 행 단위로 testConnection 호출 (POST/PUT/DELETE). */
private WriteResult writeToRestApi(Map<String, Object> firstMapping,
List<Map<String, Object>> rows, String companyCode) {
WriteResult r = new WriteResult();
String baseUrl = str(firstMapping.get("to_api_url"));
String endpoint = str(firstMapping.get("to_table_name"));
String method = strOr(firstMapping.get("to_api_method"), "POST");
for (Map<String, Object> row : rows) {
try {
Map<String, Object> testReq = new LinkedHashMap<>();
testReq.put("base_url", baseUrl);
testReq.put("endpoint", endpoint);
testReq.put("method", method);
testReq.put("body", row);
testReq.put("auth_type", "none");
testReq.put("timeout", 30000);
Map<String, Object> result = externalRest.testConnection(testReq, companyCode);
if (Boolean.TRUE.equals(result.get("success"))) r.success++; else r.failed++;
} catch (Exception e) {
log.error("REST API 전송 실패 row={} — {}", row, e.getMessage());
r.failed++;
}
}
return r;
}
// ── 유틸 ────────────────────────────────────────────────────────────────
private static List<Map<String, Object>> materialize(ResultSet rs) throws SQLException {
ResultSetMetaData md = rs.getMetaData();
int n = md.getColumnCount();
List<Map<String, Object>> rows = new ArrayList<>();
while (rs.next()) {
Map<String, Object> row = new LinkedHashMap<>();
for (int i = 1; i <= n; i++) row.put(md.getColumnLabel(i), rs.getObject(i));
rows.add(row);
}
return rows;
}
private static String safeIdent(String s) {
if (s == null || !SAFE_IDENT.matcher(s).matches()) {
throw new IllegalArgumentException("Unsafe identifier: " + s);
}
return s;
}
private static String str(Object v) { return v == null ? null : v.toString(); }
private static String strOr(Object v, String fallback) {
String s = str(v);
return (s == null || s.isEmpty()) ? fallback : s;
}
// ── 결과 클래스 ────────────────────────────────────────────────────────
public static final class ExecutionResult {
public int totalRecords = 0;
public int successRecords = 0;
public int failedRecords = 0;
public final List<String> errorMessages = new ArrayList<>();
public Map<String, Object> toMap() {
Map<String, Object> m = new LinkedHashMap<>();
m.put("total_records", totalRecords);
m.put("success_records", successRecords);
m.put("failed_records", failedRecords);
m.put("error_message", errorMessages.isEmpty() ? null : String.join("\n", errorMessages));
return m;
}
}
public static final class WriteResult {
public int success = 0;
public int failed = 0;
}
}
@@ -0,0 +1,179 @@
package com.erp.batch;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* 매핑 변환 유틸리티 — vexplor_rps 의 batchSchedulerService L550~617 .map() 로직 1:1 이식.
*
* BatchExecutor 가 FROM 에서 읽은 row 들을 TO 형태로 변환할 때 사용. 의존성 없는 정적 메서드만.
*
* mapping_type 분기:
* - "direct" : row[from_column_name] → row[to_column_name] 그대로 복사
* from_column_name 은 점 표기법 지원 (예: "response.access_token")
* - "fixed" : from_column_name 자체가 고정값. transformRow 는 fixed 매핑을 건너뛰고,
* 호출측이 partition 한 뒤 mappedRow 에 적용 (vexplor_rps L598-603 패턴).
* - "conditional" : ConditionalConfig.rules 의 when 과 sourceVal 문자열 동등 비교, 매칭 then 반환.
* 매칭 없으면 default. (단순 문자열 lookup. SpEL/JEXL 등 표현식 평가 안 함)
*/
@Slf4j
public final class MappingTransformer {
private static final ObjectMapper OM = new ObjectMapper();
private MappingTransformer() {}
/** 단일 row 를 매핑 룰에 따라 변환. mapping_type 별 분기 처리. */
public static Map<String, Object> transformRow(
Map<String, Object> row,
List<Map<String, Object>> nonFixedMappings,
List<Map<String, Object>> fixedMappings,
String toConnectionType,
String companyCode
) {
Map<String, Object> mappedRow = new LinkedHashMap<>();
for (Map<String, Object> mapping : nonFixedMappings) {
String mt = strOr(mapping.get("mapping_type"), "direct");
String fromCol = str(mapping.get("from_column_name"));
String toCol = str(mapping.get("to_column_name"));
if ("conditional".equals(mt)) {
ConditionalConfig cfg = parseConditionalConfig(mapping.get("mapping_config"));
String sourceVal = String.valueOf(getValueByPath(row, fromCol));
if (sourceVal == null || "null".equals(sourceVal)) sourceVal = "";
mappedRow.put(toCol, evaluateConditional(sourceVal, cfg));
continue;
}
// direct 또는 알 수 없는 type — 그대로 복사
// DB→REST 의 to_api_body 템플릿 처리는 BatchExecutor 측에서 별도 처리 (vexplor_rps L582~595).
// 여기서는 단순 to_column_name 으로 값 흘림.
Object value = getValueByPath(row, fromCol);
mappedRow.put(toCol, value);
}
// 고정값 매핑 적용 — from_column_name 자체가 저장값 (vexplor_rps L598-603)
if (fixedMappings != null) {
for (Map<String, Object> fm : fixedMappings) {
mappedRow.put(str(fm.get("to_column_name")), fm.get("from_column_name"));
}
}
// 멀티테넌시: TO 가 DB 일 때 company_code 자동 주입 (vexplor_rps L605-614)
if (!"restapi".equals(toConnectionType)
&& companyCode != null
&& !mappedRow.containsKey("company_code")) {
mappedRow.put("company_code", companyCode);
}
return mappedRow;
}
/** 점 표기법 path 평가 — "response.access_token" 같은 중첩 키 지원 (vexplor_rps L540-548). */
@SuppressWarnings("unchecked")
public static Object getValueByPath(Map<String, Object> obj, String path) {
if (obj == null || path == null || path.isEmpty()) return null;
if (!path.contains(".")) return obj.get(path);
Object cur = obj;
for (String part : path.split("\\.")) {
if (!(cur instanceof Map)) return null;
cur = ((Map<String, Object>) cur).get(part);
if (cur == null) return null;
}
return cur;
}
/** ConditionalConfig 단일 평가 — when/then lookup + default. */
public static Object evaluateConditional(String sourceVal, ConditionalConfig cfg) {
if (cfg == null || cfg.rules == null) return cfg != null ? cfg.defaultValue : null;
for (ConditionalRule r : cfg.rules) {
String when = r.when == null ? "" : r.when;
if (Objects.equals(when, sourceVal)) return r.then;
}
return cfg.defaultValue;
}
/**
* mapping_config (JSONB) 의 원시 값 → ConditionalConfig.
* - BatchService.attachMappings 가 이미 파싱한 경우 → Map<String,Object>
* - 직접 SELECT 결과 → String(JSON) 가능
* - null → 빈 cfg
*/
@SuppressWarnings("unchecked")
public static ConditionalConfig parseConditionalConfig(Object raw) {
if (raw == null) return ConditionalConfig.empty();
Map<String, Object> map;
try {
if (raw instanceof Map) {
map = (Map<String, Object>) raw;
} else if (raw instanceof String) {
String s = ((String) raw).trim();
if (s.isEmpty()) return ConditionalConfig.empty();
map = OM.readValue(s, Map.class);
} else {
return ConditionalConfig.empty();
}
} catch (Exception e) {
log.warn("[conditional 매핑] JSON 파싱 실패: {}", e.getMessage());
return ConditionalConfig.empty();
}
ConditionalConfig cfg = new ConditionalConfig();
Object rulesRaw = map.get("rules");
if (rulesRaw instanceof List) {
for (Object r : (List<Object>) rulesRaw) {
if (r instanceof Map) {
Map<String, Object> rm = (Map<String, Object>) r;
cfg.rules.add(new ConditionalRule(
rm.get("when") == null ? "" : String.valueOf(rm.get("when")),
rm.get("then") == null ? null : String.valueOf(rm.get("then"))
));
}
}
}
Object def = map.get("default");
cfg.defaultValue = def == null ? null : String.valueOf(def);
return cfg;
}
/** non-fixed / fixed 매핑 분리. vexplor_rps L265~271 partition 패턴. */
public static Partition partitionFixed(List<Map<String, Object>> mappings) {
Partition p = new Partition();
if (mappings == null) return p;
for (Map<String, Object> m : mappings) {
String mt = strOr(m.get("mapping_type"), "direct");
if ("fixed".equals(mt)) p.fixed.add(m); else p.nonFixed.add(m);
}
return p;
}
public static final class Partition {
public final List<Map<String, Object>> nonFixed = new ArrayList<>();
public final List<Map<String, Object>> fixed = new ArrayList<>();
}
public static final class ConditionalConfig {
public List<ConditionalRule> rules = new ArrayList<>();
public String defaultValue;
public static ConditionalConfig empty() { return new ConditionalConfig(); }
}
public static final class ConditionalRule {
public final String when;
public final String then;
public ConditionalRule(String when, String then) { this.when = when; this.then = then; }
}
private static String str(Object v) { return v == null ? null : v.toString(); }
private static String strOr(Object v, String fallback) {
String s = str(v);
return (s == null || s.isEmpty()) ? fallback : s;
}
}
@@ -0,0 +1,19 @@
package com.erp.constants;
import java.util.Set;
public final class InputTypeConstants {
private InputTypeConstants() {}
/**
* INSERT/UPDATE-type 검증용 허용 INPUT_TYPE.
* 신규 표준 8종 + 운영 DB 에 잔존하는 legacy 7종(category/select/textarea/checkbox/radio/datetime/boolean).
* 5/15 common-code 재설계가 화이트리스트를 8종으로 좁히면서도 옛 데이터/프론트 정리를 빠뜨려
* 컬럼 설정 저장 batch 가 일괄 거부됐던 회귀 회복. legacy 정리는 별도 PR 로.
*/
public static final Set<String> USER_SELECTABLE_INPUT_TYPES = Set.of(
"text", "number", "date", "code", "entity",
"numbering", "file", "image",
"category", "select", "textarea", "checkbox", "radio", "datetime", "boolean"
);
}
@@ -0,0 +1,8 @@
package com.erp.constants;
public enum InputTypeContext {
USER_INSERT,
USER_UPDATE_TYPE,
USER_UPDATE_OTHER,
SYSTEM_NORMALIZE
}
@@ -1,7 +1,9 @@
package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.provisioning.SuperAdminGuard;
import com.erp.service.AdminService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
@@ -30,13 +32,17 @@ public class AdminController {
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("role") String role,
@RequestAttribute("user_id") String userId,
@RequestParam Map<String, Object> params) {
@RequestParam Map<String, Object> params,
HttpServletRequest request) {
params.put("company_code", companyCode);
params.put("user_type", role);
params.put("user_id", userId);
params.putIfAbsent("user_lang", "ko");
params.put("is_management_screen",
params.get("menu_type") == null || "true".equals(params.get("include_inactive")));
// 관리 호스트(solution.invyone.com 등) 여부 — 테넌트 호스트이면 IS_SOLUTION_ONLY 메뉴를 SQL 단계에서 제외
String host = request.getHeader("Host");
params.put("is_management_host", !SuperAdminGuard.isTenantHost(host));
return ResponseEntity.ok(ApiResponse.success(adminService.getAdminMenuList(params), "관리자 메뉴 목록 조회 성공"));
}
@@ -49,11 +55,15 @@ public class AdminController {
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("role") String role,
@RequestAttribute("user_id") String userId,
@RequestParam Map<String, Object> params) {
@RequestParam Map<String, Object> params,
HttpServletRequest request) {
params.put("company_code", companyCode);
params.put("user_type", role);
params.put("user_id", userId);
params.putIfAbsent("user_lang", "ko");
// 관리 호스트(solution.invyone.com 등) 여부 — 테넌트 호스트이면 IS_SOLUTION_ONLY 메뉴를 SQL 단계에서 제외
String host = request.getHeader("Host");
params.put("is_management_host", !SuperAdminGuard.isTenantHost(host));
return ResponseEntity.ok(ApiResponse.success(adminService.getUserMenuList(params), "사용자 메뉴 목록 조회 성공"));
}
@@ -136,6 +136,15 @@ public class BatchManagementController {
return ResponseEntity.ok(ApiResponse.success(batchManagementService.getBatchSparkline(params)));
}
/** GET /api/batch-management/sparkline — 회사 전체 배치의 최근 24시간 1시간 단위 실행 집계 (24개 슬롯 고정) */
@GetMapping("/sparkline")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getGlobalSparkline(
@RequestAttribute("company_code") String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(batchManagementService.getGlobalSparkline(params)));
}
/** GET /api/batch-management/batch-configs/:id/recent-logs — 최근 실행 로그 (최대 20건) */
@GetMapping("/batch-configs/{id}/recent-logs")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getBatchRecentLogs(
@@ -1,125 +0,0 @@
package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.service.CascadingAutoFillService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestController
@RequestMapping("/api/cascading-auto-fill")
@RequiredArgsConstructor
@Slf4j
public class CascadingAutoFillController {
private final CascadingAutoFillService cascadingAutoFillService;
// Pipeline api_test compatibility alias
@GetMapping("/list")
public ResponseEntity<ApiResponse<Map<String, Object>>> getGroupListAlias(
@RequestAttribute("company_code") String companyCode,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(cascadingAutoFillService.getCascadingAutoFillGroupList(params)));
}
@GetMapping("/groups")
public ResponseEntity<ApiResponse<Map<String, Object>>> getGroupList(
@RequestAttribute("company_code") String companyCode,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(cascadingAutoFillService.getCascadingAutoFillGroupList(params)));
}
@GetMapping("/groups/{groupCode}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getGroupDetail(
@RequestAttribute("company_code") String companyCode,
@PathVariable String groupCode) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("group_code", groupCode);
Map<String, Object> result = cascadingAutoFillService.getCascadingAutoFillGroupDetail(params);
if (result == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error("자동 입력 그룹을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(result));
}
@PostMapping("/groups")
public ResponseEntity<ApiResponse<Map<String, Object>>> createGroup(
@RequestAttribute("company_code") String companyCode,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success(cascadingAutoFillService.insertCascadingAutoFillGroup(body)));
}
@PutMapping("/groups/{groupCode}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateGroup(
@RequestAttribute("company_code") String companyCode,
@PathVariable String groupCode,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
body.put("group_code", groupCode);
Map<String, Object> result = cascadingAutoFillService.updateCascadingAutoFillGroup(body);
if (result == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error("자동 입력 그룹을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(result));
}
@DeleteMapping("/groups/{groupCode}")
public ResponseEntity<ApiResponse<Void>> deleteGroup(
@RequestAttribute("company_code") String companyCode,
@PathVariable String groupCode) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("group_code", groupCode);
boolean deleted = cascadingAutoFillService.deleteCascadingAutoFillGroup(params);
if (!deleted) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error("자동 입력 그룹을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(null));
}
@GetMapping("/options/{groupCode}")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getMasterOptions(
@RequestAttribute("company_code") String companyCode,
@PathVariable String groupCode) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("group_code", groupCode);
List<Map<String, Object>> result = cascadingAutoFillService.getAutoFillMasterOptions(params);
if (result == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error("자동 입력 그룹을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(result));
}
@GetMapping("/data/{groupCode}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getAutoFillData(
@RequestAttribute("company_code") String companyCode,
@PathVariable String groupCode,
@RequestParam(required = false) String masterValue) {
if (masterValue == null || masterValue.isBlank()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error("masterValue 파라미터가 필요합니다."));
}
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("group_code", groupCode);
params.put("master_value", masterValue);
Map<String, Object> result = cascadingAutoFillService.getAutoFillData(params);
if (result == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error("자동 입력 그룹을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(result));
}
}
@@ -1,81 +0,0 @@
package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.service.CascadingConditionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestController
@RequestMapping("/api/cascading-condition")
@RequiredArgsConstructor
@Slf4j
public class CascadingConditionController {
private final CascadingConditionService cascadingConditionService;
@GetMapping("/list")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingConditionListAlias(
@RequestAttribute("company_code") String companyCode,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(cascadingConditionService.getCascadingConditionList(params)));
}
@GetMapping
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingConditionList(
@RequestAttribute("company_code") String companyCode,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(cascadingConditionService.getCascadingConditionList(params)));
}
@GetMapping("/filtered-options/{relationCode}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getFilteredOptions(
@RequestAttribute("company_code") String companyCode,
@PathVariable String relationCode,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
params.put("relation_code", relationCode);
return ResponseEntity.ok(ApiResponse.success(cascadingConditionService.getFilteredOptions(params)));
}
@GetMapping("/{conditionId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingConditionInfo(
@RequestAttribute("company_code") String companyCode,
@PathVariable Long conditionId) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("condition_id", conditionId);
return ResponseEntity.ok(ApiResponse.success(cascadingConditionService.getCascadingConditionInfo(params)));
}
@PostMapping
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCascadingCondition(
@RequestAttribute("company_code") String companyCode,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(cascadingConditionService.insertCascadingCondition(body)));
}
@PutMapping("/{conditionId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCascadingCondition(
@RequestAttribute("company_code") String companyCode,
@PathVariable Long conditionId,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
body.put("condition_id", conditionId);
return ResponseEntity.ok(ApiResponse.success(cascadingConditionService.updateCascadingCondition(body)));
}
@DeleteMapping("/{conditionId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteCascadingCondition(
@RequestAttribute("company_code") String companyCode,
@PathVariable Long conditionId) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("condition_id", conditionId);
return ResponseEntity.ok(ApiResponse.success(cascadingConditionService.deleteCascadingCondition(params)));
}
}
@@ -1,157 +0,0 @@
package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.service.CascadingHierarchyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestController
@RequestMapping("/api/cascading-hierarchy")
@RequiredArgsConstructor
@Slf4j
public class CascadingHierarchyController {
private final CascadingHierarchyService cascadingHierarchyService;
// Pipeline api_test compatibility alias
@GetMapping("/list")
public ResponseEntity<ApiResponse<Map<String, Object>>> getGroupListAlias(
@RequestAttribute("company_code") String companyCode,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(cascadingHierarchyService.getCascadingHierarchyGroupList(params)));
}
@GetMapping
public ResponseEntity<ApiResponse<Map<String, Object>>> getGroupList(
@RequestAttribute("company_code") String companyCode,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(cascadingHierarchyService.getCascadingHierarchyGroupList(params)));
}
@GetMapping("/{groupCode}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getGroupDetail(
@RequestAttribute("company_code") String companyCode,
@PathVariable String groupCode) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("group_code", groupCode);
Map<String, Object> result = cascadingHierarchyService.getCascadingHierarchyGroupDetail(params);
if (result == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error("계층 그룹을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(result));
}
@PostMapping
public ResponseEntity<ApiResponse<Map<String, Object>>> createGroup(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "user_id", required = false) String userId,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
if (userId != null) body.put("user_id", userId);
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success(cascadingHierarchyService.insertCascadingHierarchyGroup(body)));
}
@PutMapping("/{groupCode}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateGroup(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "user_id", required = false) String userId,
@PathVariable String groupCode,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
body.put("group_code", groupCode);
if (userId != null) body.put("user_id", userId);
Map<String, Object> result = cascadingHierarchyService.updateCascadingHierarchyGroup(body);
if (result == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error("계층 그룹을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(result));
}
@DeleteMapping("/{groupCode}")
public ResponseEntity<ApiResponse<Void>> deleteGroup(
@RequestAttribute("company_code") String companyCode,
@PathVariable String groupCode) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("group_code", groupCode);
boolean deleted = cascadingHierarchyService.deleteCascadingHierarchyGroup(params);
if (!deleted) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error("계층 그룹을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(null));
}
@PostMapping("/{groupCode}/levels")
public ResponseEntity<ApiResponse<Map<String, Object>>> addLevel(
@RequestAttribute("company_code") String companyCode,
@PathVariable String groupCode,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
body.put("group_code", groupCode);
Map<String, Object> result = cascadingHierarchyService.addCascadingHierarchyLevel(body);
if (result == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error("계층 그룹을 찾을 수 없습니다."));
}
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(result));
}
@PutMapping("/levels/{levelId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateLevel(
@RequestAttribute("company_code") String companyCode,
@PathVariable Long levelId,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
body.put("level_id", levelId);
Map<String, Object> result = cascadingHierarchyService.updateCascadingHierarchyLevel(body);
if (result == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error("레벨을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(result));
}
@DeleteMapping("/levels/{levelId}")
public ResponseEntity<ApiResponse<Void>> deleteLevel(
@RequestAttribute("company_code") String companyCode,
@PathVariable Long levelId) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("level_id", levelId);
boolean deleted = cascadingHierarchyService.deleteCascadingHierarchyLevel(params);
if (!deleted) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error("레벨을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(null));
}
@GetMapping("/{groupCode}/options/{levelOrder}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getLevelOptions(
@RequestAttribute("company_code") String companyCode,
@PathVariable String groupCode,
@PathVariable Integer levelOrder,
@RequestParam(required = false) String parentValue) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("group_code", groupCode);
params.put("level_order", levelOrder);
if (parentValue != null) params.put("parent_value", parentValue);
Map<String, Object> result = cascadingHierarchyService.getLevelOptions(params);
if (result == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error("레벨을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(result));
}
}
@@ -1,121 +0,0 @@
package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.service.CascadingMutualExclusionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.*;
/**
* 상호 배제 API
* Node.js: app.use("/api/cascading-mutual-exclusion", cascadingMutualExclusionRoutes)
*/
@RestController
@RequestMapping("/api/cascading-mutual-exclusion")
@RequiredArgsConstructor
@Slf4j
public class CascadingMutualExclusionController {
private final CascadingMutualExclusionService cascadingMutualExclusionService;
/** GET /list — 목록 조회 (alias) */
@GetMapping("/list")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingMutualExclusionListAlias(
@RequestAttribute("company_code") String companyCode,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(
cascadingMutualExclusionService.getCascadingMutualExclusionList(params)));
}
/** GET / — 목록 조회 */
@GetMapping
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingMutualExclusionList(
@RequestAttribute("company_code") String companyCode,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(
cascadingMutualExclusionService.getCascadingMutualExclusionList(params)));
}
/** GET /{exclusionId} — 상세 조회 */
@GetMapping("/{exclusionId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingMutualExclusionInfo(
@RequestAttribute("company_code") String companyCode,
@PathVariable Long exclusionId) {
Map<String, Object> params = new LinkedHashMap<>();
params.put("company_code", companyCode);
params.put("id", exclusionId);
return ResponseEntity.ok(ApiResponse.success(
cascadingMutualExclusionService.getCascadingMutualExclusionInfo(params)));
}
/** POST / — 생성 */
@PostMapping
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCascadingMutualExclusion(
@RequestAttribute("company_code") String companyCode,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(
cascadingMutualExclusionService.insertCascadingMutualExclusion(body)));
}
/** PUT /{exclusionId} — 수정 */
@PutMapping("/{exclusionId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCascadingMutualExclusion(
@RequestAttribute("company_code") String companyCode,
@PathVariable Long exclusionId,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
body.put("id", exclusionId);
return ResponseEntity.ok(ApiResponse.success(
cascadingMutualExclusionService.updateCascadingMutualExclusion(body)));
}
/** DELETE /{exclusionId} — 하드 삭제 */
@DeleteMapping("/{exclusionId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteCascadingMutualExclusion(
@RequestAttribute("company_code") String companyCode,
@PathVariable Long exclusionId) {
Map<String, Object> params = new LinkedHashMap<>();
params.put("company_code", companyCode);
params.put("id", exclusionId);
return ResponseEntity.ok(ApiResponse.success(
cascadingMutualExclusionService.deleteCascadingMutualExclusion(params)));
}
/**
* POST /validate/{exclusionCode} — 상호 배제 검증
* body: { "field_values": { "field_a": "val1", "field_b": "val1" } }
*/
@PostMapping("/validate/{exclusionCode}")
public ResponseEntity<ApiResponse<Map<String, Object>>> validateCascadingMutualExclusion(
@RequestAttribute("company_code") String companyCode,
@PathVariable String exclusionCode,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
body.put("code", exclusionCode);
return ResponseEntity.ok(ApiResponse.success(
cascadingMutualExclusionService.validateCascadingMutualExclusion(body)));
}
/**
* GET /options/{exclusionCode} — 배제 옵션 조회
* query: selectedValues (콤마 구분된 이미 선택된 값들)
*/
@GetMapping("/options/{exclusionCode}")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getExcludedOptions(
@RequestAttribute("company_code") String companyCode,
@PathVariable String exclusionCode,
@RequestParam(required = false) String currentField,
@RequestParam(required = false) String selectedValues) {
Map<String, Object> params = new LinkedHashMap<>();
params.put("company_code", companyCode);
params.put("code", exclusionCode);
params.put("current_field", currentField);
params.put("selected_values", selectedValues);
return ResponseEntity.ok(ApiResponse.success(
cascadingMutualExclusionService.getExcludedOptions(params)));
}
}
@@ -1,139 +0,0 @@
package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.service.CascadingRelationService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.*;
/**
* 연쇄 관계 API
* Node.js: app.use("/api/cascading-relation", cascadingRelationRoutes)
*/
@RestController
@RequestMapping("/api/cascading-relation")
@RequiredArgsConstructor
@Slf4j
public class CascadingRelationController {
private final CascadingRelationService cascadingRelationService;
/** GET /api/cascading-relation/list — 목록 조회 (alias) */
@GetMapping("/list")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingRelationListAlias(
@RequestAttribute("company_code") String companyCode,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(
cascadingRelationService.getCascadingRelationList(params)));
}
/** GET /api/cascading-relation — 목록 조회 */
@GetMapping
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingRelationList(
@RequestAttribute("company_code") String companyCode,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(
cascadingRelationService.getCascadingRelationList(params)));
}
/** GET /api/cascading-relation/{id} — 상세 조회 */
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingRelationInfo(
@RequestAttribute("company_code") String companyCode,
@PathVariable Long id) {
Map<String, Object> params = new LinkedHashMap<>();
params.put("company_code", companyCode);
params.put("id", id);
return ResponseEntity.ok(ApiResponse.success(
cascadingRelationService.getCascadingRelationInfo(params)));
}
/** GET /api/cascading-relation/code/{code} — 코드로 단건 조회 */
@GetMapping("/code/{code}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingRelationByCode(
@RequestAttribute("company_code") String companyCode,
@PathVariable String code) {
Map<String, Object> params = new LinkedHashMap<>();
params.put("company_code", companyCode);
params.put("code", code);
return ResponseEntity.ok(ApiResponse.success(
cascadingRelationService.getCascadingRelationByCode(params)));
}
/**
* GET /api/cascading-relation/parent-options/{code}
* 부모 옵션 조회 (parent_table 동적 쿼리)
*/
@GetMapping("/parent-options/{code}")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getParentOptions(
@RequestAttribute("company_code") String companyCode,
@PathVariable String code) {
Map<String, Object> params = new LinkedHashMap<>();
params.put("company_code", companyCode);
params.put("code", code);
return ResponseEntity.ok(ApiResponse.success(
cascadingRelationService.getParentOptions(params)));
}
/**
* GET /api/cascading-relation/options/{code}?parentValue=&parentValues=
* 연쇄 자식 옵션 조회 (child_table 동적 쿼리)
*/
@GetMapping("/options/{code}")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCascadingOptions(
@RequestAttribute("company_code") String companyCode,
@PathVariable String code,
@RequestParam(required = false) String parentValue,
@RequestParam(required = false) String parentValues) {
Map<String, Object> params = new LinkedHashMap<>();
params.put("company_code", companyCode);
params.put("code", code);
params.put("parent_value", parentValue);
params.put("parent_values", parentValues);
return ResponseEntity.ok(ApiResponse.success(
cascadingRelationService.getCascadingOptions(params)));
}
/** POST /api/cascading-relation — 생성 */
@PostMapping
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCascadingRelation(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "user_id", required = false) String userId,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
body.put("user_id", userId != null ? userId : "system");
return ResponseEntity.ok(ApiResponse.success(
cascadingRelationService.insertCascadingRelation(body)));
}
/** PUT /api/cascading-relation/{id} — 수정 */
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCascadingRelation(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "user_id", required = false) String userId,
@PathVariable Long id,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
body.put("user_id", userId != null ? userId : "system");
body.put("id", id);
return ResponseEntity.ok(ApiResponse.success(
cascadingRelationService.updateCascadingRelation(body)));
}
/** DELETE /api/cascading-relation/{id} — 소프트 삭제 (is_active = 'N') */
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteCascadingRelation(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "user_id", required = false) String userId,
@PathVariable Long id) {
Map<String, Object> params = new LinkedHashMap<>();
params.put("company_code", companyCode);
params.put("user_id", userId != null ? userId : "system");
params.put("id", id);
return ResponseEntity.ok(ApiResponse.success(
cascadingRelationService.deleteCascadingRelation(params)));
}
}
@@ -1,191 +0,0 @@
package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.service.CategoryTreeService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/api/category-tree")
@RequiredArgsConstructor
public class CategoryTreeController {
private final CategoryTreeService categoryTreeService;
/**
* GET /api/category-tree/test/all-category-keys
* 전체 카테고리 키 목록 조회 (모든 테이블.컬럼 조합)
* 주의: /test/{tableName}/{columnName} 보다 먼저 매핑되어야 함
*/
@GetMapping("/test/all-category-keys")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCategoryTreeKeyList(
@RequestAttribute("company_code") String companyCode) {
List<Map<String, Object>> keys = categoryTreeService.getCategoryTreeKeyList(companyCode);
return ResponseEntity.ok(ApiResponse.success(keys));
}
/**
* GET /api/category-tree/test/{tableName}/{columnName}
* 카테고리 트리 조회
*/
@GetMapping("/test/{tableName}/{columnName}")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCategoryTreeList(
@RequestAttribute("company_code") String companyCode,
@PathVariable String tableName,
@PathVariable String columnName) {
List<Map<String, Object>> tree = categoryTreeService.getCategoryTreeList(companyCode, tableName, columnName);
return ResponseEntity.ok(ApiResponse.success(tree));
}
/**
* GET /api/category-tree/test/{tableName}/{columnName}/flat
* 카테고리 플랫 리스트 조회
*/
@GetMapping("/test/{tableName}/{columnName}/flat")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCategoryTreeFlatList(
@RequestAttribute("company_code") String companyCode,
@PathVariable String tableName,
@PathVariable String columnName) {
List<Map<String, Object>> list = categoryTreeService.getCategoryTreeFlatList(companyCode, tableName, columnName);
return ResponseEntity.ok(ApiResponse.success(list));
}
/**
* GET /api/category-tree/test/value/{valueId}
* 카테고리 값 단건 조회
*/
@GetMapping("/test/value/{valueId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryTreeInfo(
@RequestAttribute("company_code") String companyCode,
@PathVariable int valueId) {
Map<String, Object> value = categoryTreeService.getCategoryTreeInfo(companyCode, valueId);
if (value == null) {
return ResponseEntity.status(404).body(ApiResponse.error("카테고리 값을 찾을 수 없습니다"));
}
return ResponseEntity.ok(ApiResponse.success(value));
}
/**
* POST /api/category-tree/test/value
* 카테고리 값 생성
*/
@PostMapping("/test/value")
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCategoryTree(
@RequestAttribute("company_code") String userCompanyCode,
@RequestAttribute("user_id") String userId,
@RequestBody Map<String, Object> body) {
if (body.get("table_name") == null || body.get("column_name") == null
|| body.get("value_code") == null || body.get("value_label") == null) {
return ResponseEntity.badRequest()
.body(ApiResponse.error("tableName, columnName, valueCode, valueLabel은 필수입니다"));
}
// 최고 관리자(*) 는 targetCompanyCode 로 회사 코드 오버라이드 가능
String companyCode = userCompanyCode;
String targetCompanyCode = (String) body.get("target_company_code");
if (targetCompanyCode != null && "*".equals(userCompanyCode)) {
companyCode = targetCompanyCode;
}
try {
Map<String, Object> value = categoryTreeService.insertCategoryTree(body, companyCode, userId);
return ResponseEntity.ok(ApiResponse.success(value));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
} catch (Exception e) {
log.error("카테고리 값 생성 오류", e);
return ResponseEntity.status(500).body(ApiResponse.error(e.getMessage()));
}
}
/**
* PUT /api/category-tree/test/value/{valueId}
* 카테고리 값 수정
*/
@PutMapping("/test/value/{valueId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCategoryTree(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("user_id") String userId,
@PathVariable int valueId,
@RequestBody Map<String, Object> body) {
try {
Map<String, Object> value = categoryTreeService.updateCategoryTree(companyCode, valueId, body, userId);
if (value == null) {
return ResponseEntity.status(404).body(ApiResponse.error("카테고리 값을 찾을 수 없습니다"));
}
return ResponseEntity.ok(ApiResponse.success(value));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
} catch (Exception e) {
log.error("카테고리 값 수정 오류", e);
return ResponseEntity.status(500).body(ApiResponse.error(e.getMessage()));
}
}
/**
* GET /api/category-tree/test/value/{valueId}/can-delete
* 카테고리 값 삭제 가능 여부 사전 확인
*/
@GetMapping("/test/value/{valueId}/can-delete")
public ResponseEntity<ApiResponse<Map<String, Object>>> checkCanDelete(
@RequestAttribute("company_code") String companyCode,
@PathVariable int valueId) {
Map<String, Object> result = categoryTreeService.checkCanDelete(companyCode, valueId);
return ResponseEntity.ok(ApiResponse.success(result));
}
/**
* DELETE /api/category-tree/test/value/{valueId}
* 카테고리 값 삭제
*/
@DeleteMapping("/test/value/{valueId}")
public ResponseEntity<ApiResponse<Void>> deleteCategoryTree(
@RequestAttribute("company_code") String companyCode,
@PathVariable int valueId) {
try {
boolean deleted = categoryTreeService.deleteCategoryTree(companyCode, valueId);
if (!deleted) {
return ResponseEntity.status(404).body(ApiResponse.error("카테고리 값을 찾을 수 없습니다"));
}
return ResponseEntity.ok(ApiResponse.success(null, "삭제되었습니다"));
} catch (IllegalStateException e) {
String msg = e.getMessage();
if (msg != null && msg.startsWith("VALIDATION:")) {
return ResponseEntity.badRequest()
.body(ApiResponse.error(msg.substring("VALIDATION:".length())));
}
log.error("카테고리 값 삭제 오류", e);
return ResponseEntity.status(500).body(ApiResponse.error(msg));
} catch (Exception e) {
log.error("카테고리 값 삭제 오류", e);
return ResponseEntity.status(500).body(ApiResponse.error(e.getMessage()));
}
}
/**
* GET /api/category-tree/test/columns/{tableName}
* 테이블의 카테고리 컬럼 목록 조회
*/
@GetMapping("/test/columns/{tableName}")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCategoryTreeColumnList(
@RequestAttribute("company_code") String companyCode,
@PathVariable String tableName) {
List<Map<String, Object>> columns = categoryTreeService.getCategoryTreeColumnList(companyCode, tableName);
return ResponseEntity.ok(ApiResponse.success(columns));
}
}
@@ -1,142 +0,0 @@
package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.service.CategoryValueCascadingService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestController
@RequestMapping("/api/category-value-cascading")
@RequiredArgsConstructor
@Slf4j
public class CategoryValueCascadingController {
private final CategoryValueCascadingService categoryValueCascadingService;
/** GET /groups → 그룹 목록 조회 */
@GetMapping("/groups")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryValueCascadingGroupList(
@RequestAttribute("company_code") String companyCode,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.getCategoryValueCascadingGroupList(params)));
}
/** GET /groups/{groupId} → 그룹 상세 조회 (매핑 포함) */
@GetMapping("/groups/{groupId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryValueCascadingGroupInfo(
@RequestAttribute("company_code") String companyCode,
@PathVariable Long groupId) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("group_id", groupId);
Map<String, Object> result = categoryValueCascadingService.getCategoryValueCascadingGroupInfo(params);
if (result == null) {
return ResponseEntity.status(404).body(ApiResponse.error("카테고리 값 연쇄관계 그룹을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(result));
}
/** GET /code/{code} → 관계 코드로 조회 */
@GetMapping("/code/{code}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryValueCascadingGroupByCode(
@RequestAttribute("company_code") String companyCode,
@PathVariable String code) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("code", code);
Map<String, Object> result = categoryValueCascadingService.getCategoryValueCascadingGroupByCode(params);
if (result == null) {
return ResponseEntity.status(404).body(ApiResponse.error("카테고리 값 연쇄관계를 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(result));
}
/** POST /groups → 그룹 생성 */
@PostMapping("/groups")
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCategoryValueCascadingGroup(
@RequestAttribute("company_code") String companyCode,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.insertCategoryValueCascadingGroup(body)));
}
/** PUT /groups/{groupId} → 그룹 수정 */
@PutMapping("/groups/{groupId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCategoryValueCascadingGroup(
@RequestAttribute("company_code") String companyCode,
@PathVariable Long groupId,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
body.put("group_id", groupId);
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.updateCategoryValueCascadingGroup(body)));
}
/** DELETE /groups/{groupId} → 그룹 소프트 삭제 */
@DeleteMapping("/groups/{groupId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteCategoryValueCascadingGroup(
@RequestAttribute("company_code") String companyCode,
@PathVariable Long groupId) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("group_id", groupId);
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.deleteCategoryValueCascadingGroup(params)));
}
/** POST /groups/{groupId}/mappings → 매핑 일괄 저장 */
@PostMapping("/groups/{groupId}/mappings")
public ResponseEntity<ApiResponse<Map<String, Object>>> saveCategoryValueCascadingMappings(
@RequestAttribute("company_code") String companyCode,
@PathVariable Long groupId,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
body.put("group_id", groupId);
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.saveCategoryValueCascadingMappings(body)));
}
/** GET /parent-options/{code} → 부모 카테고리 값 목록 */
@GetMapping("/parent-options/{code}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryValueCascadingParentOptions(
@RequestAttribute("company_code") String companyCode,
@PathVariable String code,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
params.put("code", code);
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.getCategoryValueCascadingParentOptions(params)));
}
/** GET /child-options/{code} → 자식 카테고리 값 목록 */
@GetMapping("/child-options/{code}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryValueCascadingChildOptions(
@RequestAttribute("company_code") String companyCode,
@PathVariable String code,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
params.put("code", code);
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.getCategoryValueCascadingChildOptions(params)));
}
/** GET /options/{code} → 연쇄 옵션 조회 (parentValue/parentValues 파라미터) */
@GetMapping("/options/{code}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryValueCascadingOptions(
@RequestAttribute("company_code") String companyCode,
@PathVariable String code,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
params.put("code", code);
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.getCategoryValueCascadingOptions(params)));
}
/** GET /table/{tableName}/mappings → 테이블별 매핑 조회 */
@GetMapping("/table/{tableName}/mappings")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryValueCascadingMappingsByTable(
@RequestAttribute("company_code") String companyCode,
@PathVariable String tableName) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("table_name", tableName);
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.getCategoryValueCascadingMappingsByTable(params)));
}
}
@@ -1,69 +0,0 @@
package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.service.CodeMergeService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestController
@RequestMapping("/api/code-merge")
@RequiredArgsConstructor
@Slf4j
public class CodeMergeController {
private final CodeMergeService codeMergeService;
/** POST /api/code-merge/merge-all-tables — columnName 기준 전체 테이블 코드 병합 */
@PostMapping("/merge-all-tables")
public ResponseEntity<ApiResponse<Map<String, Object>>> mergeAllTables(
@RequestAttribute("company_code") String companyCode,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(
codeMergeService.mergeAllTables(body),
"코드 병합이 완료되었습니다."));
}
/** GET /api/code-merge/tables-with-column/:columnName — 해당 컬럼을 가진 테이블 목록 */
@GetMapping("/tables-with-column/{columnName}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getTablesWithColumn(
@PathVariable String columnName) {
return ResponseEntity.ok(ApiResponse.success(
codeMergeService.getTablesWithColumn(columnName)));
}
/** POST /api/code-merge/preview — columnName + oldValue 기준 영향 미리보기 */
@PostMapping("/preview")
public ResponseEntity<ApiResponse<Map<String, Object>>> previewCodeMerge(
@RequestAttribute("company_code") String companyCode,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(
codeMergeService.previewCodeMerge(body)));
}
/** POST /api/code-merge/merge-by-value — 값 기반 전체 테이블/컬럼 코드 병합 */
@PostMapping("/merge-by-value")
public ResponseEntity<ApiResponse<Map<String, Object>>> mergeByValue(
@RequestAttribute("company_code") String companyCode,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(
codeMergeService.mergeByValue(body),
"코드 병합이 완료되었습니다."));
}
/** POST /api/code-merge/preview-by-value — 값 기반 영향 미리보기 */
@PostMapping("/preview-by-value")
public ResponseEntity<ApiResponse<Map<String, Object>>> previewByValue(
@RequestAttribute("company_code") String companyCode,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(
codeMergeService.previewByValue(body)));
}
}
@@ -7,17 +7,18 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Common Code Controller
* Common Code Controller — 마스터-디테일 패턴.
*
* commonCodeRoutes.ts 포팅 — /api/common-codes 기준 15개 엔드포인트.
* /info : code_info (1레벨 그룹 마스터)
* /detail : code_detail (2레벨~ 트리)
*
* NOTE: Spring MVC 는 리터럴 세그먼트를 경로변수보다 우선하므로
* /check-duplicate, /reorder 는 별도 선언 순서 없이도 정상 동작하나,
* 가독성을 위해 구체적인 경로를 먼저 선언한다.
* /check-duplicate 는 /{codeInfo} / /{id} 보다 먼저 매칭된다.
*/
@RestController
@RequestMapping("/api/common-codes")
@@ -27,151 +28,200 @@ public class CommonCodeController {
private final CommonCodeService service;
// ─────────────────────────────────────────────────────────────
// GET /categories
// Node.js: { success, data: [...], total, message } (flat, not nested)
// ─────────────────────────────────────────────────────────────
// ════════════════════════════════════════════════════════════════
// CODE_INFO — 그룹 마스터
// ════════════════════════════════════════════════════════════════
@GetMapping("/categories")
public ResponseEntity<Map<String, Object>> getCommonCodeCategoryList(
/** 그룹 목록 (페이징/검색) */
@GetMapping("/info")
public ResponseEntity<Map<String, Object>> getCodeInfoList(
@RequestAttribute("company_code") String companyCode,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
Map<String, Object> svcResult = service.getCommonCodeCategoryList(params);
Map<String, Object> svc = service.getCodeInfoList(params);
Map<String, Object> response = new java.util.LinkedHashMap<>();
Map<String, Object> response = new LinkedHashMap<>();
response.put("success", true);
response.put("data", svcResult.get("data"));
response.put("total", svcResult.get("total"));
response.put("message", "카테고리 목록 조회 성공");
response.put("data", svc.get("data"));
response.put("total", svc.get("total"));
response.put("message", "코드 그룹 목록 조회 성공");
return ResponseEntity.ok(response);
}
// ─────────────────────────────────────────────────────────────
// GET /categories/check-duplicate ← /{categoryCode} 보다 먼저
// ─────────────────────────────────────────────────────────────
@GetMapping("/categories/check-duplicate")
public ResponseEntity<ApiResponse<Map<String, Object>>> checkCategoryDuplicate(
/** 그룹 중복 체크 — /{codeInfo} 보다 먼저 선언 */
@GetMapping("/info/check-duplicate")
public ResponseEntity<ApiResponse<Map<String, Object>>> checkCodeInfoDuplicate(
@RequestAttribute("company_code") String companyCode,
@RequestParam(defaultValue = "category_code") String field,
@RequestParam(defaultValue = "code_info") String field,
@RequestParam String value,
@RequestParam(required = false) String excludeCode) {
return ResponseEntity.ok(
ApiResponse.success(service.checkCategoryDuplicate(field, value, excludeCode, companyCode)));
ApiResponse.success(service.checkCodeInfoDuplicate(field, value, excludeCode, companyCode)));
}
// ─────────────────────────────────────────────────────────────
// POST /categories
// ─────────────────────────────────────────────────────────────
/** 그룹 단건 */
@GetMapping("/info/{codeInfo}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCodeInfoInfo(
@RequestAttribute("company_code") String companyCode,
@PathVariable String codeInfo) {
@PostMapping("/categories")
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCommonCodeCategory(
Map<String, Object> info = service.getCodeInfoInfo(codeInfo, companyCode);
if (info == null) {
return ResponseEntity.status(404).body(ApiResponse.error("코드 그룹을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(info));
}
/** 그룹 생성 */
@PostMapping("/info")
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCodeInfo(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("user_id") String userId,
@RequestBody Map<String, Object> body) {
if (body.get("category_code") == null || body.get("category_name") == null) {
if (body.get("code_info") == null || body.get("code_name") == null) {
return ResponseEntity.status(400)
.body(ApiResponse.error("필수 필드가 누락되었습니다. (categoryCode, categoryName)"));
.body(ApiResponse.error("필수 필드가 누락되었습니다. (code_info, code_name)"));
}
try {
Map<String, Object> created = service.insertCommonCodeCategory(body, companyCode, userId);
Map<String, Object> created = service.insertCodeInfo(body, companyCode, userId);
return ResponseEntity.status(201)
.body(ApiResponse.success(created, "카테고리가 성공적으로 생성되었습니다."));
.body(ApiResponse.success(created, "코드 그룹이 성공적으로 생성되었습니다."));
} catch (Exception e) {
log.error("카테고리 생성 실패", e);
log.error("코드 그룹 생성 실패", e);
return ResponseEntity.status(500)
.body(ApiResponse.error("카테고리 생성에 실패했습니다."));
.body(ApiResponse.error("코드 그룹 생성에 실패했습니다."));
}
}
// ─────────────────────────────────────────────────────────────
// PUT /categories/:categoryCode
// ─────────────────────────────────────────────────────────────
@PutMapping("/categories/{categoryCode}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCommonCodeCategory(
/** 그룹 수정 */
@PutMapping("/info/{codeInfo}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCodeInfo(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("user_id") String userId,
@PathVariable String categoryCode,
@PathVariable String codeInfo,
@RequestBody Map<String, Object> body) {
try {
Map<String, Object> updated = service.updateCommonCodeCategory(categoryCode, body, companyCode, userId);
Map<String, Object> updated = service.updateCodeInfo(codeInfo, body, companyCode, userId);
if (updated == null) {
return ResponseEntity.status(404)
.body(ApiResponse.error("카테고리를 찾을 수 없습니다."));
.body(ApiResponse.error("코드 그룹을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(updated, "카테고리가 성공적으로 수정되었습니다."));
return ResponseEntity.ok(ApiResponse.success(updated, "코드 그룹이 성공적으로 수정되었습니다."));
} catch (Exception e) {
log.error("카테고리 수정 실패", e);
log.error("코드 그룹 수정 실패", e);
return ResponseEntity.status(500)
.body(ApiResponse.error("카테고리 수정에 실패했습니다."));
.body(ApiResponse.error("코드 그룹 수정에 실패했습니다."));
}
}
// ─────────────────────────────────────────────────────────────
// DELETE /categories/:categoryCode
// ─────────────────────────────────────────────────────────────
@DeleteMapping("/categories/{categoryCode}")
public ResponseEntity<ApiResponse<Void>> deleteCommonCodeCategory(
/** 그룹 삭제 (CASCADE 로 code_detail 자식 자동 삭제) */
@DeleteMapping("/info/{codeInfo}")
public ResponseEntity<ApiResponse<Void>> deleteCodeInfo(
@RequestAttribute("company_code") String companyCode,
@PathVariable String categoryCode) {
@PathVariable String codeInfo) {
try {
service.deleteCommonCodeCategory(categoryCode, companyCode);
return ResponseEntity.ok(ApiResponse.success(null, "카테고리가 성공적으로 삭제되었습니다."));
service.deleteCodeInfo(codeInfo, companyCode);
return ResponseEntity.ok(ApiResponse.success(null, "코드 그룹이 성공적으로 삭제되었습니다."));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(404).body(ApiResponse.error(e.getMessage()));
} catch (Exception e) {
log.error("카테고리 삭제 실패", e);
log.error("코드 그룹 삭제 실패", e);
return ResponseEntity.status(500)
.body(ApiResponse.error("카테고리 삭제에 실패했습니다."));
.body(ApiResponse.error("코드 그룹 삭제에 실패했습니다."));
}
}
// ─────────────────────────────────────────────────────────────
// GET /categories/:categoryCode/codes
// ─────────────────────────────────────────────────────────────
// ════════════════════════════════════════════════════════════════
// CODE_DETAIL — 디테일 트리
// ════════════════════════════════════════════════════════════════
@GetMapping("/categories/{categoryCode}/codes")
public ResponseEntity<Map<String, Object>> getCommonCodeList(
/**
* 디테일 트리.
* - code_info 필수 (어느 그룹)
* - parent_detail_id (optional): 지정 시 해당 부모의 자식만, 미지정 시 그룹 전체 트리 (재귀 CTE)
* - flat=true 인 경우 동일 (트리는 평탄화된 depth+sort_order 순)
*/
@GetMapping("/detail")
public ResponseEntity<Map<String, Object>> getCodeDetail(
@RequestAttribute("company_code") String companyCode,
@PathVariable String categoryCode,
@RequestParam("code_info") String codeInfo,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
Map<String, Object> svcResult = service.getCommonCodeList(categoryCode, params);
Map<String, Object> response = new java.util.LinkedHashMap<>();
Object parentRaw = params.get("parent_detail_id");
Map<String, Object> response = new LinkedHashMap<>();
response.put("success", true);
response.put("data", svcResult.get("data"));
response.put("total", svcResult.get("total"));
response.put("message", "코드 목록 조회 성공");
if (parentRaw != null && !parentRaw.toString().isEmpty()) {
// 특정 부모 직속 자식만
Map<String, Object> svc = service.getCodeDetailList(codeInfo, params);
response.put("data", svc.get("data"));
response.put("total", svc.get("total"));
} else {
// 그룹 전체 트리 (재귀 CTE 로 평탄화)
List<Map<String, Object>> tree = service.getCodeDetailTree(codeInfo, companyCode);
response.put("data", tree);
response.put("total", tree.size());
}
response.put("message", "코드 디테일 조회 성공");
return ResponseEntity.ok(response);
}
// ─────────────────────────────────────────────────────────────
// POST /categories/:categoryCode/codes
// ─────────────────────────────────────────────────────────────
/** 디테일 중복 체크 — /{id} 보다 먼저 선언 */
@GetMapping("/detail/check-duplicate")
public ResponseEntity<ApiResponse<Map<String, Object>>> checkCodeDetailDuplicate(
@RequestAttribute("company_code") String companyCode,
@RequestParam("code_info") String codeInfo,
@RequestParam("code_value") String codeValue,
@RequestParam(value = "exclude_id", required = false) Long excludeId) {
@PostMapping("/categories/{categoryCode}/codes")
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCommonCode(
return ResponseEntity.ok(
ApiResponse.success(service.checkCodeDetailDuplicate(codeInfo, codeValue, excludeId, companyCode)));
}
/** 디테일 단건 */
@GetMapping("/detail/{id}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCodeDetailInfo(
@RequestAttribute("company_code") String companyCode,
@PathVariable("id") Long codeDetailId) {
Map<String, Object> info = service.getCodeDetailInfo(codeDetailId, companyCode);
if (info == null) {
return ResponseEntity.status(404).body(ApiResponse.error("코드를 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(info));
}
/** 디테일 자식 존재 여부 */
@GetMapping("/detail/{id}/has-children")
public ResponseEntity<ApiResponse<Map<String, Object>>> hasCodeDetailChildren(
@RequestAttribute("company_code") String companyCode,
@PathVariable("id") Long codeDetailId) {
return ResponseEntity.ok(
ApiResponse.success(service.hasCodeDetailChildren(codeDetailId, companyCode)));
}
/** 디테일 생성 */
@PostMapping("/detail")
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCodeDetail(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("user_id") String userId,
@PathVariable String categoryCode,
@RequestBody Map<String, Object> body) {
if (body.get("code_value") == null || body.get("code_name") == null) {
Object codeInfoRaw = body.get("code_info");
if (codeInfoRaw == null || body.get("code_value") == null || body.get("code_name") == null) {
return ResponseEntity.status(400)
.body(ApiResponse.error("필수 필드가 누락되었습니다. (codeValue, codeName)"));
.body(ApiResponse.error("필수 필드가 누락되었습니다. (code_info, code_value, code_name)"));
}
try {
Map<String, Object> created = service.insertCommonCode(categoryCode, body, companyCode, userId);
Map<String, Object> created = service.insertCodeDetail(codeInfoRaw.toString(), body, companyCode, userId);
return ResponseEntity.status(201)
.body(ApiResponse.success(created, "코드가 성공적으로 생성되었습니다."));
} catch (Exception e) {
@@ -181,122 +231,18 @@ public class CommonCodeController {
}
}
// ─────────────────────────────────────────────────────────────
// GET /categories/:categoryCode/codes/check-duplicate ← /{codeValue} 보다 먼저
// ─────────────────────────────────────────────────────────────
@GetMapping("/categories/{categoryCode}/codes/check-duplicate")
public ResponseEntity<ApiResponse<Map<String, Object>>> checkCodeDuplicate(
@RequestAttribute("company_code") String companyCode,
@PathVariable String categoryCode,
@RequestParam(defaultValue = "code_value") String field,
@RequestParam String value,
@RequestParam(required = false) String excludeCode) {
return ResponseEntity.ok(
ApiResponse.success(service.checkCodeDuplicate(categoryCode, field, value, excludeCode, companyCode)));
}
// ─────────────────────────────────────────────────────────────
// PUT /categories/:categoryCode/codes/reorder ← /{codeValue} 보다 먼저
// ─────────────────────────────────────────────────────────────
@SuppressWarnings("unchecked")
@PutMapping("/categories/{categoryCode}/codes/reorder")
public ResponseEntity<ApiResponse<Void>> updateCommonCodeOrder(
@RequestAttribute("company_code") String companyCode,
@PathVariable String categoryCode,
@RequestBody Map<String, Object> body) {
Object codesRaw = body.get("codes");
if (!(codesRaw instanceof List)) {
return ResponseEntity.status(400)
.body(ApiResponse.error("codes 배열이 필요합니다."));
}
try {
service.updateCommonCodeOrder(categoryCode, (List<Map<String, Object>>) codesRaw, companyCode);
return ResponseEntity.ok(ApiResponse.success(null, "정렬 순서가 변경되었습니다."));
} catch (Exception e) {
log.error("코드 정렬 변경 실패", e);
return ResponseEntity.status(500)
.body(ApiResponse.error("정렬 순서 변경에 실패했습니다."));
}
}
// ─────────────────────────────────────────────────────────────
// GET /categories/:categoryCode/hierarchy
// ─────────────────────────────────────────────────────────────
@GetMapping("/categories/{categoryCode}/hierarchy")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCommonCodeHierarchicalList(
@RequestAttribute("company_code") String companyCode,
@PathVariable String categoryCode,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
return ResponseEntity.ok(
ApiResponse.success(service.getCommonCodeHierarchicalList(categoryCode, params)));
}
// ─────────────────────────────────────────────────────────────
// GET /categories/:categoryCode/tree
// ─────────────────────────────────────────────────────────────
@GetMapping("/categories/{categoryCode}/tree")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCommonCodeTree(
@RequestAttribute("company_code") String companyCode,
@PathVariable String categoryCode) {
return ResponseEntity.ok(
ApiResponse.success(service.getCommonCodeTree(categoryCode, companyCode)));
}
// ─────────────────────────────────────────────────────────────
// GET /categories/:categoryCode/options
// ─────────────────────────────────────────────────────────────
@GetMapping("/categories/{categoryCode}/options")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCommonCodeOptionList(
@RequestAttribute("company_code") String companyCode,
@PathVariable String categoryCode,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
return ResponseEntity.ok(
ApiResponse.success(service.getCommonCodeOptionList(categoryCode, params)));
}
// ─────────────────────────────────────────────────────────────
// GET /categories/:categoryCode/codes/:codeValue/has-children
// ─────────────────────────────────────────────────────────────
@GetMapping("/categories/{categoryCode}/codes/{codeValue}/has-children")
public ResponseEntity<ApiResponse<Map<String, Object>>> hasChildren(
@RequestAttribute("company_code") String companyCode,
@PathVariable String categoryCode,
@PathVariable String codeValue) {
return ResponseEntity.ok(
ApiResponse.success(service.hasChildren(categoryCode, codeValue, companyCode)));
}
// ─────────────────────────────────────────────────────────────
// PUT /categories/:categoryCode/codes/:codeValue
// ─────────────────────────────────────────────────────────────
@PutMapping("/categories/{categoryCode}/codes/{codeValue}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCommonCode(
/** 디테일 수정 */
@PutMapping("/detail/{id}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCodeDetail(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("user_id") String userId,
@PathVariable String categoryCode,
@PathVariable String codeValue,
@PathVariable("id") Long codeDetailId,
@RequestBody Map<String, Object> body) {
try {
Map<String, Object> updated = service.updateCommonCode(categoryCode, codeValue, body, companyCode, userId);
Map<String, Object> updated = service.updateCodeDetail(codeDetailId, body, companyCode, userId);
if (updated == null) {
return ResponseEntity.status(404)
.body(ApiResponse.error("코드를 찾을 수 없습니다."));
return ResponseEntity.status(404).body(ApiResponse.error("코드를 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(updated, "코드가 성공적으로 수정되었습니다."));
} catch (Exception e) {
@@ -306,18 +252,14 @@ public class CommonCodeController {
}
}
// ─────────────────────────────────────────────────────────────
// DELETE /categories/:categoryCode/codes/:codeValue
// ─────────────────────────────────────────────────────────────
@DeleteMapping("/categories/{categoryCode}/codes/{codeValue}")
public ResponseEntity<ApiResponse<Void>> deleteCommonCode(
/** 디테일 삭제 (CASCADE 로 자식 자동 삭제) */
@DeleteMapping("/detail/{id}")
public ResponseEntity<ApiResponse<Void>> deleteCodeDetail(
@RequestAttribute("company_code") String companyCode,
@PathVariable String categoryCode,
@PathVariable String codeValue) {
@PathVariable("id") Long codeDetailId) {
try {
service.deleteCommonCode(categoryCode, codeValue, companyCode);
service.deleteCodeDetail(codeDetailId, companyCode);
return ResponseEntity.ok(ApiResponse.success(null, "코드가 성공적으로 삭제되었습니다."));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(404).body(ApiResponse.error(e.getMessage()));
@@ -1,7 +1,9 @@
package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.provisioning.SuperAdminGuard;
import com.erp.service.CompanyManagementService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
@@ -16,6 +18,7 @@ import java.util.Map;
@Slf4j
public class CompanyManagementController {
private final SuperAdminGuard guard;
private final CompanyManagementService companyManagementService;
/**
@@ -24,9 +27,12 @@ public class CompanyManagementController {
*/
@DeleteMapping("/{companyCode}")
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteCompany(
HttpServletRequest request,
@PathVariable String companyCode,
@RequestBody(required = false) Map<String, Object> body) {
guard.enforce(request);
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
if (body != null) {
@@ -52,7 +58,11 @@ public class CompanyManagementController {
* ※ /{companyCode}/disk-usage 보다 먼저 정의 (경로 특이성으로 충돌 없음)
*/
@GetMapping("/disk-usage/all")
public ResponseEntity<ApiResponse<Map<String, Object>>> getAllCompaniesDiskUsage() {
public ResponseEntity<ApiResponse<Map<String, Object>>> getAllCompaniesDiskUsage(
HttpServletRequest request) {
guard.enforce(request);
try {
Map<String, Object> data = companyManagementService.getAllCompaniesDiskUsage();
return ResponseEntity.ok(ApiResponse.success(data));
@@ -68,7 +78,11 @@ public class CompanyManagementController {
*/
@GetMapping("/{companyCode}/disk-usage")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCompanyDiskUsage(
HttpServletRequest request,
@PathVariable String companyCode) {
guard.enforce(request);
try {
Map<String, Object> data = companyManagementService.getCompanyDiskUsage(companyCode);
return ResponseEntity.ok(ApiResponse.success(data));
@@ -29,11 +29,12 @@ public class DdlController {
@PostMapping("/tables")
public ResponseEntity<ApiResponse<?>> createTable(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role,
@RequestAttribute("user_id") String userId,
@RequestBody Map<String, Object> body) {
if (!isSuperAdmin(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
}
String tableName = (String) body.get("table_name");
@@ -65,11 +66,12 @@ public class DdlController {
public ResponseEntity<ApiResponse<?>> addColumn(
@PathVariable String tableName,
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role,
@RequestAttribute("user_id") String userId,
@RequestBody Map<String, Object> body) {
if (!isSuperAdmin(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
}
@SuppressWarnings("unchecked")
@@ -91,6 +93,33 @@ public class DdlController {
return ResponseEntity.status(400).body(ApiResponse.error((String) result.get("message")));
}
/**
* DELETE /api/ddl/tables/{tableName}/columns/{columnName} - 컬럼 삭제
*/
@DeleteMapping("/tables/{tableName}/columns/{columnName}")
public ResponseEntity<ApiResponse<?>> dropColumn(
@PathVariable String tableName,
@PathVariable String columnName,
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role,
@RequestAttribute("user_id") String userId) {
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
}
Map<String, Object> result = ddlService.dropColumn(tableName, columnName, companyCode, userId);
if (Boolean.TRUE.equals(result.get("success"))) {
return ResponseEntity.ok(ApiResponse.success(Map.of(
"table_name", result.get("table_name"),
"column_name", result.get("column_name"),
"executed_query", result.get("executed_query")
), (String) result.get("message")));
}
return ResponseEntity.status(400).body(ApiResponse.error((String) result.get("message")));
}
/**
* DELETE /api/ddl/tables/{tableName} - 테이블 삭제
*/
@@ -98,10 +127,11 @@ public class DdlController {
public ResponseEntity<ApiResponse<?>> dropTable(
@PathVariable String tableName,
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role,
@RequestAttribute("user_id") String userId) {
if (!isSuperAdmin(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
}
Map<String, Object> result = ddlService.dropTable(tableName, companyCode, userId);
@@ -121,10 +151,11 @@ public class DdlController {
@PostMapping("/validate/table")
public ResponseEntity<ApiResponse<?>> validateTableCreation(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role,
@RequestBody Map<String, Object> body) {
if (!isSuperAdmin(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
}
String tableName = (String) body.get("table_name");
@@ -150,12 +181,13 @@ public class DdlController {
@GetMapping("/logs")
public ResponseEntity<ApiResponse<?>> getDdlLogs(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role,
@RequestParam(required = false, defaultValue = "50") int limit,
@RequestParam(required = false) String userId,
@RequestParam(required = false) String ddlType) {
if (!isSuperAdmin(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
}
List<Map<String, Object>> logs = ddlService.getDdlLogs(limit, userId, ddlType);
@@ -169,11 +201,12 @@ public class DdlController {
@GetMapping("/statistics")
public ResponseEntity<ApiResponse<?>> getDdlStatistics(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role,
@RequestParam(required = false) String fromDate,
@RequestParam(required = false) String toDate) {
if (!isSuperAdmin(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
}
Map<String, Object> statistics = ddlService.getDdlStatistics(fromDate, toDate);
@@ -186,10 +219,11 @@ public class DdlController {
@GetMapping("/tables/{tableName}/history")
public ResponseEntity<ApiResponse<?>> getTableDdlHistory(
@PathVariable String tableName,
@RequestAttribute("company_code") String companyCode) {
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role) {
if (!isSuperAdmin(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
}
List<Map<String, Object>> history = ddlService.getTableDdlHistory(tableName);
@@ -204,10 +238,11 @@ public class DdlController {
@GetMapping("/tables/{tableName}/info")
public ResponseEntity<ApiResponse<?>> getTableInfo(
@PathVariable String tableName,
@RequestAttribute("company_code") String companyCode) {
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role) {
if (!isSuperAdmin(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
}
Map<String, Object> tableInfo = ddlService.getTableInfo(tableName);
@@ -229,10 +264,11 @@ public class DdlController {
@DeleteMapping("/logs/cleanup")
public ResponseEntity<ApiResponse<?>> cleanupOldLogs(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role,
@RequestParam(required = false, defaultValue = "90") int retentionDays) {
if (!isSuperAdmin(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
}
int deletedCount = ddlService.cleanupOldLogs(retentionDays);
@@ -246,10 +282,11 @@ public class DdlController {
*/
@GetMapping("/info")
public ResponseEntity<ApiResponse<?>> getInfo(
@RequestAttribute("company_code") String companyCode) {
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role) {
if (!isSuperAdmin(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
}
return ResponseEntity.ok(ApiResponse.success(Map.of(
@@ -292,7 +329,9 @@ public class DdlController {
// 내부 유틸
// ─────────────────────────────────────────────────────────────────────────
private boolean isSuperAdmin(String companyCode) {
return "*".equals(companyCode);
private boolean isSuperAdmin(String companyCode, String role) {
// company_code 가 '*' 이고 role 이 SUPER_ADMIN 둘 다 충족해야 통과 (이중 체크).
// 토큰 변조 또는 회사코드만으로 super 권한이 발급되는 사고 방지.
return "*".equals(companyCode) && "SUPER_ADMIN".equals(role);
}
}
@@ -18,23 +18,32 @@ public class DepartmentController {
private final DepartmentService departmentService;
private static final java.util.regex.Pattern ISO_DATE_PATTERN =
java.util.regex.Pattern.compile("\\d{4}-\\d{2}-\\d{2}");
/**
* 부서 목록 조회 (회사별).
* 기본은 active 부서만. ?include_deleted=true 시 soft-delete 된 부서도 포함.
* GET /api/departments/companies/{companyCode}/departments[?include_deleted=true]
* ?base_date=YYYY-MM-DD 시 해당 시점에 active 했던 부서만 반환.
* GET /api/departments/companies/{companyCode}/departments[?include_deleted=true][&base_date=YYYY-MM-DD]
*/
@GetMapping("/companies/{companyCode}/departments")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getDepartments(
@PathVariable String companyCode,
@RequestAttribute("company_code") String userCompanyCode,
@RequestParam(value = "include_deleted", required = false, defaultValue = "false") boolean includeDeleted) {
@RequestParam(value = "include_deleted", required = false, defaultValue = "false") boolean includeDeleted,
@RequestParam(value = "base_date", required = false) String baseDate) {
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
return ResponseEntity.status(403)
.body(ApiResponse.error("해당 회사의 부서를 조회할 권한이 없습니다."));
}
if (baseDate != null && !baseDate.isBlank() && !ISO_DATE_PATTERN.matcher(baseDate).matches()) {
return ResponseEntity.status(400)
.body(ApiResponse.error("base_date 는 YYYY-MM-DD 형식이어야 합니다."));
}
List<Map<String, Object>> departments = departmentService.getDepartments(companyCode, includeDeleted);
List<Map<String, Object>> departments = departmentService.getDepartments(companyCode, includeDeleted, baseDate);
return ResponseEntity.ok(ApiResponse.success(departments, "부서 목록 조회 성공"));
}
@@ -66,6 +75,7 @@ public class DepartmentController {
/**
* 부서 생성
* POST /api/departments/companies/{companyCode}/departments
* body 에 approval_managers/dept_managers/org_leaders 배열 (각 element {user_id: 'xxx'}) 포함 가능. 최대 10명.
*/
@PostMapping("/companies/{companyCode}/departments")
public ResponseEntity<ApiResponse<Map<String, Object>>> createDepartment(
@@ -94,6 +104,7 @@ public class DepartmentController {
/**
* 부서 수정
* PUT /api/departments/{deptCode}
* body 에 approval_managers/dept_managers/org_leaders 배열 (각 element {user_id: 'xxx'}) 포함 가능. 최대 10명.
*/
@PutMapping("/{deptCode}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateDepartment(
@@ -131,6 +142,135 @@ public class DepartmentController {
}
}
/**
* 일괄 미리보기 (read-only validation).
* POST /api/departments/companies/{companyCode}/departments/bulk/preview
* body: { action: "create"|"update_department"|"update_manager", rows: List<Map> }
* response: { rows: [...with row_index/result/error_detail], ok_count, error_count }
*/
@PostMapping("/companies/{companyCode}/departments/bulk/preview")
public ResponseEntity<ApiResponse<Map<String, Object>>> bulkPreview(
@PathVariable String companyCode,
@RequestAttribute("company_code") String userCompanyCode,
@RequestAttribute("role") String role,
@RequestBody Map<String, Object> body) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 부서를 처리할 권한이 없습니다."));
}
String action = body.get("action") != null ? body.get("action").toString() : "";
@SuppressWarnings("unchecked")
List<Map<String, Object>> rows = body.get("rows") instanceof List
? (List<Map<String, Object>>) body.get("rows") : null;
if (rows == null) {
return ResponseEntity.status(400).body(ApiResponse.error("rows 가 없습니다."));
}
try {
List<Map<String, Object>> result;
switch (action) {
case "create":
result = departmentService.bulkPreviewCreate(companyCode, rows);
break;
case "update_department":
result = departmentService.bulkPreviewUpdate(companyCode, "department", rows);
break;
case "update_manager":
result = departmentService.bulkPreviewUpdate(companyCode, "manager", rows);
break;
default:
return ResponseEntity.status(400)
.body(ApiResponse.error("action 은 create|update_department|update_manager 중 하나."));
}
int ok = 0, err = 0;
for (Map<String, Object> r : result) {
if ("ok".equals(r.get("result"))) ok++; else err++;
}
Map<String, Object> data = new java.util.HashMap<>();
data.put("rows", result);
data.put("ok_count", ok);
data.put("error_count", err);
return ResponseEntity.ok(ApiResponse.success(data, "미리보기 완료"));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
}
/**
* 일괄등록 적용 (@Transactional, all-or-nothing).
* POST /api/departments/companies/{companyCode}/departments/bulk/create
* body: { rows: List<Map> } — 클라이언트가 미리보기 결과 중 ok 인 row 만 보내야 함.
*/
@PostMapping("/companies/{companyCode}/departments/bulk/create")
public ResponseEntity<ApiResponse<Map<String, Object>>> bulkCreate(
@PathVariable String companyCode,
@RequestAttribute("company_code") String userCompanyCode,
@RequestAttribute("role") String role,
@RequestBody Map<String, Object> body) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 부서를 등록할 권한이 없습니다."));
}
@SuppressWarnings("unchecked")
List<Map<String, Object>> rows = body.get("rows") instanceof List
? (List<Map<String, Object>>) body.get("rows") : null;
if (rows == null || rows.isEmpty()) {
return ResponseEntity.status(400).body(ApiResponse.error("등록할 데이터가 없습니다."));
}
try {
int inserted = departmentService.bulkSaveCreate(companyCode, rows);
Map<String, Object> data = new java.util.HashMap<>();
data.put("inserted", inserted);
return ResponseEntity.status(201).body(ApiResponse.success(data, inserted + "건이 등록되었습니다."));
} catch (DepartmentService.DuplicateDeptNameException e) {
return ResponseEntity.status(409).body(ApiResponse.error(e.getMessage()));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
}
/**
* 일괄업데이트 적용 (@Transactional). mode = department | manager.
* POST /api/departments/companies/{companyCode}/departments/bulk/update
* body: { mode: "department"|"manager", rows: List<Map> } — 각 row 에 dept_code 필수.
*/
@PostMapping("/companies/{companyCode}/departments/bulk/update")
public ResponseEntity<ApiResponse<Map<String, Object>>> bulkUpdate(
@PathVariable String companyCode,
@RequestAttribute("company_code") String userCompanyCode,
@RequestAttribute("role") String role,
@RequestBody Map<String, Object> body) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 부서를 수정할 권한이 없습니다."));
}
String mode = body.get("mode") != null ? body.get("mode").toString() : "";
@SuppressWarnings("unchecked")
List<Map<String, Object>> rows = body.get("rows") instanceof List
? (List<Map<String, Object>>) body.get("rows") : null;
if (rows == null || rows.isEmpty()) {
return ResponseEntity.status(400).body(ApiResponse.error("수정할 데이터가 없습니다."));
}
try {
int updated = departmentService.bulkUpdate(companyCode, mode, rows);
Map<String, Object> data = new java.util.HashMap<>();
data.put("updated", updated);
return ResponseEntity.ok(ApiResponse.success(data, updated + "건이 수정되었습니다."));
} catch (DepartmentService.DuplicateDeptNameException e) {
return ResponseEntity.status(409).body(ApiResponse.error(e.getMessage()));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
}
/**
* 부서 삭제 (soft-delete, V1 slim scope).
* - 기존 hard-delete → DELETED_AT = NOW() 마킹으로 변경
@@ -19,21 +19,21 @@ public class EntityReferenceController {
private final EntityReferenceService entityReferenceService;
/**
* GET /api/entity-reference/code/:codeCategory
* GET /api/entity-reference/code/:codeInfo
* 공통 코드 데이터 조회
*
* NOTE: Spring MVC는 리터럴 경로 세그먼트("code")를 변수 경로({tableName})보다 우선하므로
* /code/{codeCategory} 가 /{tableName}/{columnName} 보다 먼저 매핑됨.
* /code/{codeInfo} 가 /{tableName}/{columnName} 보다 먼저 매핑됨.
*/
@GetMapping("/code/{codeCategory}")
@GetMapping("/code/{codeInfo}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCodeData(
@PathVariable String codeCategory,
@PathVariable String codeInfo,
@RequestParam(required = false, defaultValue = "100") Integer limit,
@RequestParam(required = false) String search,
@RequestAttribute("company_code") String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("code_category", codeCategory);
params.put("code_info", codeInfo);
params.put("company_code", companyCode);
params.put("limit", limit);
if (search != null) params.put("search", search);
@@ -41,7 +41,7 @@ public class EntityReferenceController {
try {
return ResponseEntity.ok(ApiResponse.success(entityReferenceService.getCodeData(params)));
} catch (Exception e) {
log.error("공통 코드 데이터 조회 실패: codeCategory={}", codeCategory, e);
log.error("공통 코드 데이터 조회 실패: codeInfo={}", codeInfo, e);
return ResponseEntity.status(500).body(ApiResponse.error("공통 코드 데이터 조회 중 오류가 발생했습니다."));
}
}
@@ -11,7 +11,7 @@ import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/numbering-rule")
@RequestMapping("/api/numbering-rules")
@RequiredArgsConstructor
@Slf4j
public class NumberingRuleController {
@@ -136,7 +136,7 @@ public class NumberingRuleController {
Map<String, Object> formData = body != null ? (Map<String, Object>) body.get("form_data") : null;
String manualInputValue = body != null ? (String) body.get("manual_input_value") : null;
String code = numberingRuleService.previewCode(ruleId, companyCode, formData, manualInputValue);
return ResponseEntity.ok(ApiResponse.success(Map.of("code", code), "미리보기 생성이 완료되었습니다."));
return ResponseEntity.ok(ApiResponse.success(Map.of("generatedCode", code), "미리보기 생성이 완료되었습니다."));
}
// ================================================================
@@ -202,7 +202,7 @@ public class NumberingRuleController {
Map<String, Object> formData = body != null ? (Map<String, Object>) body.get("form_data") : null;
String manualInputValue = body != null ? (String) body.get("manual_input_value") : null;
String code = numberingRuleService.previewCode(ruleId, companyCode, formData, manualInputValue);
return ResponseEntity.ok(ApiResponse.success(Map.of("code", code), "미리보기 생성이 완료되었습니다."));
return ResponseEntity.ok(ApiResponse.success(Map.of("generatedCode", code), "미리보기 생성이 완료되었습니다."));
}
/** POST /{ruleId}/allocate → 코드 할당 (순번 증가) */
@@ -215,7 +215,7 @@ public class NumberingRuleController {
Map<String, Object> formData = body != null ? (Map<String, Object>) body.get("form_data") : null;
String userInputCode = body != null ? (String) body.get("user_input_code") : null;
String code = numberingRuleService.allocateCode(ruleId, companyCode, formData, userInputCode);
return ResponseEntity.ok(ApiResponse.success(Map.of("code", code), "코드 할당이 완료되었습니다."));
return ResponseEntity.ok(ApiResponse.success(Map.of("generatedCode", code), "코드 할당이 완료되었습니다."));
}
/** POST /{ruleId}/generate (deprecated) → allocateCode 위임 */
@@ -224,18 +224,63 @@ public class NumberingRuleController {
@RequestAttribute("company_code") String companyCode,
@PathVariable String ruleId) {
String code = numberingRuleService.generateCode(ruleId, companyCode);
return ResponseEntity.ok(ApiResponse.success(Map.of("code", code), "코드 생성이 완료되었습니다."));
return ResponseEntity.ok(ApiResponse.success(Map.of("generatedCode", code), "코드 생성이 완료되었습니다."));
}
/** POST /{ruleId}/reset → 순번 초기화 */
/** admin 권한 (SUPER_ADMIN / ADMIN / COMPANY_ADMIN) 만 시퀀스 직접 조작 가능 */
private boolean isAdminRole(String role) {
return "SUPER_ADMIN".equals(role)
|| "ADMIN".equals(role)
|| "COMPANY_ADMIN".equals(role);
}
/** POST /{ruleId}/reset → 순번 초기화 (admin 전용) */
@PostMapping("/{ruleId}/reset")
public ResponseEntity<ApiResponse<Void>> resetSequence(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("role") String role,
@PathVariable String ruleId) {
if (!isAdminRole(role)) {
return ResponseEntity.status(403)
.body(ApiResponse.error("관리자 권한이 필요합니다."));
}
numberingRuleService.resetSequence(ruleId, companyCode);
return ResponseEntity.ok(ApiResponse.success(null, "시퀀스가 초기화되었습니다."));
}
/** PUT /{ruleId}/sequence → 현재 시퀀스 임의 값으로 수정 (admin 전용) */
@PutMapping("/{ruleId}/sequence")
public ResponseEntity<ApiResponse<Void>> updateRuleSequence(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("role") String role,
@PathVariable String ruleId,
@RequestBody Map<String, Object> body) {
if (!isAdminRole(role)) {
return ResponseEntity.status(403)
.body(ApiResponse.error("관리자 권한이 필요합니다."));
}
Object seqObj = body.get("sequence");
if (seqObj == null) {
return ResponseEntity.badRequest()
.body(ApiResponse.error("sequence 값이 필요합니다."));
}
Integer newSequence;
try {
newSequence = (seqObj instanceof Number)
? ((Number) seqObj).intValue()
: Integer.parseInt(seqObj.toString());
} catch (NumberFormatException e) {
return ResponseEntity.badRequest()
.body(ApiResponse.error("sequence 는 정수여야 합니다."));
}
if (newSequence < 0) {
return ResponseEntity.badRequest()
.body(ApiResponse.error("sequence 는 0 이상이어야 합니다."));
}
numberingRuleService.updateRuleSequence(ruleId, newSequence, companyCode);
return ResponseEntity.ok(ApiResponse.success(null, "시퀀스가 수정되었습니다."));
}
// ================================================================
// ■ Admin
// ================================================================
@@ -593,10 +593,10 @@ public class ScreenManagementController {
}
@PostMapping("/copy-code-category")
public ResponseEntity<ApiResponse<Map<String, Object>>> copyCodeCategoryAndCodes(
public ResponseEntity<ApiResponse<Map<String, Object>>> copyCodeInfoAndCodes(
@RequestBody Map<String, Object> body) {
try {
int count = service.copyCodeCategoryAndCodes(body);
int count = service.copyCodeInfoAndCodes(body);
return ResponseEntity.ok(ApiResponse.success(Map.of("count", count)));
} catch (Exception e) {
log.error("코드 카테고리 복제 실패", e);
@@ -1,373 +0,0 @@
package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.service.TableCategoryValueService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/table-categories")
@RequiredArgsConstructor
@Slf4j
public class TableCategoryValueController {
private final TableCategoryValueService service;
// ══════════════════════════════════════════════════════════════
// Category Columns
// ══════════════════════════════════════════════════════════════
/** GET /api/table-categories/all-columns */
@GetMapping("/all-columns")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getAllCategoryColumns(
@RequestAttribute("company_code") String companyCode) {
try {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(service.getAllCategoryColumns(params)));
} catch (Exception e) {
log.error("전체 카테고리 컬럼 조회 실패", e);
return ResponseEntity.status(500)
.body(ApiResponse.error("전체 카테고리 컬럼 조회 중 오류가 발생했습니다"));
}
}
/** GET /api/table-categories/{tableName}/columns */
@GetMapping("/{tableName}/columns")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCategoryColumns(
@PathVariable String tableName,
@RequestAttribute("company_code") String companyCode) {
try {
Map<String, Object> params = new HashMap<>();
params.put("table_name", tableName);
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(service.getCategoryColumns(params)));
} catch (Exception e) {
log.error("카테고리 컬럼 조회 실패: tableName={}", tableName, e);
return ResponseEntity.status(500)
.body(ApiResponse.error("카테고리 컬럼 조회 중 오류가 발생했습니다"));
}
}
// ══════════════════════════════════════════════════════════════
// Category Values — Read
// ══════════════════════════════════════════════════════════════
/** GET /api/table-categories/{tableName}/{columnName}/values */
@GetMapping("/{tableName}/{columnName}/values")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCategoryValues(
@PathVariable String tableName,
@PathVariable String columnName,
@RequestParam(required = false) String menuObjid,
@RequestParam(required = false, defaultValue = "false") boolean includeInactive,
@RequestParam(required = false) String filterCompanyCode,
@RequestAttribute("company_code") String companyCode) {
try {
// SUPER_ADMIN 이 특정 회사 기준 필터링 요청 시 해당 companyCode 사용
String effectiveCompanyCode = ("*".equals(companyCode) && filterCompanyCode != null)
? filterCompanyCode : companyCode;
Map<String, Object> params = new HashMap<>();
params.put("table_name", tableName);
params.put("column_name", columnName);
params.put("company_code", effectiveCompanyCode);
params.put("include_inactive", includeInactive);
if (menuObjid != null) params.put("menu_objid", Long.parseLong(menuObjid));
return ResponseEntity.ok(ApiResponse.success(service.getCategoryValues(params)));
} catch (Exception e) {
log.error("카테고리 값 조회 실패: tableName={}, columnName={}", tableName, columnName, e);
return ResponseEntity.status(500)
.body(ApiResponse.error("카테고리 값 조회 중 오류가 발생했습니다"));
}
}
// ══════════════════════════════════════════════════════════════
// Category Values — Write
// ══════════════════════════════════════════════════════════════
/** POST /api/table-categories/values */
@PostMapping("/values")
public ResponseEntity<ApiResponse<Map<String, Object>>> addCategoryValue(
@RequestBody Map<String, Object> body,
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("user_id") String userId) {
if (body.get("menu_objid") == null) {
return ResponseEntity.status(400).body(ApiResponse.error("menuObjid는 필수입니다"));
}
body.put("company_code", companyCode);
body.put("user_id", userId);
try {
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success(service.addCategoryValue(body)));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(500).body(ApiResponse.error(
e.getMessage() != null ? e.getMessage() : "카테고리 값 추가 중 오류가 발생했습니다"));
} catch (Exception e) {
log.error("카테고리 값 추가 실패", e);
return ResponseEntity.status(500).body(ApiResponse.error("카테고리 값 추가 중 오류가 발생했습니다"));
}
}
/** PUT /api/table-categories/values/{valueId} */
@PutMapping("/values/{valueId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCategoryValue(
@PathVariable Long valueId,
@RequestBody Map<String, Object> body,
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("user_id") String userId) {
body.put("value_id", valueId);
body.put("company_code", companyCode);
body.put("user_id", userId);
try {
return ResponseEntity.ok(ApiResponse.success(service.updateCategoryValue(body)));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(500).body(ApiResponse.error("카테고리 값 수정 중 오류가 발생했습니다"));
} catch (Exception e) {
log.error("카테고리 값 수정 실패: valueId={}", valueId, e);
return ResponseEntity.status(500).body(ApiResponse.error("카테고리 값 수정 중 오류가 발생했습니다"));
}
}
/** DELETE /api/table-categories/values/{valueId} */
@DeleteMapping("/values/{valueId}")
public ResponseEntity<ApiResponse<Void>> deleteCategoryValue(
@PathVariable Long valueId,
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("user_id") String userId) {
Map<String, Object> params = new HashMap<>();
params.put("value_id", valueId);
params.put("company_code", companyCode);
params.put("user_id", userId);
try {
service.deleteCategoryValue(params);
return ResponseEntity.ok(ApiResponse.success(null, "카테고리 값이 삭제되었습니다"));
} catch (IllegalArgumentException e) {
// 사용 중인 경우 400
if (e.getMessage() != null && e.getMessage().contains("삭제할 수 없습니다")) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
return ResponseEntity.status(500).body(ApiResponse.error(
e.getMessage() != null ? e.getMessage() : "카테고리 값 삭제 중 오류가 발생했습니다"));
} catch (Exception e) {
log.error("카테고리 값 삭제 실패: valueId={}", valueId, e);
return ResponseEntity.status(500).body(ApiResponse.error("카테고리 값 삭제 중 오류가 발생했습니다"));
}
}
/** POST /api/table-categories/values/bulk-delete */
@PostMapping("/values/bulk-delete")
public ResponseEntity<ApiResponse<Void>> bulkDeleteCategoryValues(
@RequestBody Map<String, Object> body,
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("user_id") String userId) {
Object rawIds = body.get("value_ids");
if (!(rawIds instanceof List) || ((List<?>) rawIds).isEmpty()) {
return ResponseEntity.status(400).body(ApiResponse.error("삭제할 값 ID 목록이 필요합니다"));
}
body.put("company_code", companyCode);
body.put("user_id", userId);
try {
service.bulkDeleteCategoryValues(body);
int count = ((List<?>) rawIds).size();
return ResponseEntity.ok(
ApiResponse.success(null, count + "개의 카테고리 값이 삭제되었습니다"));
} catch (Exception e) {
log.error("카테고리 값 일괄 삭제 실패", e);
return ResponseEntity.status(500).body(ApiResponse.error("카테고리 값 일괄 삭제 중 오류가 발생했습니다"));
}
}
/** POST /api/table-categories/values/reorder */
@PostMapping("/values/reorder")
public ResponseEntity<ApiResponse<Void>> reorderCategoryValues(
@RequestBody Map<String, Object> body,
@RequestAttribute("company_code") String companyCode) {
Object rawIds = body.get("ordered_value_ids");
if (!(rawIds instanceof List) || ((List<?>) rawIds).isEmpty()) {
return ResponseEntity.status(400).body(ApiResponse.error("순서 정보가 필요합니다"));
}
body.put("company_code", companyCode);
try {
service.reorderCategoryValues(body);
return ResponseEntity.ok(ApiResponse.success(null, "카테고리 값 순서가 변경되었습니다"));
} catch (Exception e) {
log.error("카테고리 값 순서 변경 실패", e);
return ResponseEntity.status(500).body(ApiResponse.error("카테고리 값 순서 변경 중 오류가 발생했습니다"));
}
}
// ══════════════════════════════════════════════════════════════
// Labels by Codes
// ══════════════════════════════════════════════════════════════
/** POST /api/table-categories/labels-by-codes */
@PostMapping("/labels-by-codes")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryLabelsByCodes(
@RequestBody Map<String, Object> body,
@RequestAttribute("company_code") String companyCode) {
Object rawCodes = body.get("value_codes");
if (!(rawCodes instanceof List) || ((List<?>) rawCodes).isEmpty()) {
return ResponseEntity.ok(ApiResponse.success(new java.util.LinkedHashMap<>()));
}
body.put("company_code", companyCode);
try {
return ResponseEntity.ok(ApiResponse.success(service.getCategoryLabelsByCodes(body)));
} catch (Exception e) {
log.error("카테고리 라벨 조회 실패", e);
return ResponseEntity.status(500).body(ApiResponse.error("카테고리 라벨 조회 중 오류가 발생했습니다"));
}
}
// ══════════════════════════════════════════════════════════════
// Second-Level Menus (NOTE: 리터럴 경로이므로 variable 경로보다 우선)
// ══════════════════════════════════════════════════════════════
/** GET /api/table-categories/second-level-menus */
@GetMapping("/second-level-menus")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getSecondLevelMenus(
@RequestAttribute("company_code") String companyCode) {
try {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(service.getSecondLevelMenus(params)));
} catch (Exception e) {
log.error("2레벨 메뉴 목록 조회 실패", e);
return ResponseEntity.status(500).body(ApiResponse.error("2레벨 메뉴 목록 조회 중 오류가 발생했습니다"));
}
}
// ══════════════════════════════════════════════════════════════
// Column Mapping
// ══════════════════════════════════════════════════════════════
/** GET /api/table-categories/column-mapping/{tableName}/{menuObjid} */
@GetMapping("/column-mapping/{tableName}/{menuObjid}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getColumnMapping(
@PathVariable String tableName,
@PathVariable Long menuObjid,
@RequestAttribute("company_code") String companyCode) {
try {
Map<String, Object> params = new HashMap<>();
params.put("table_name", tableName);
params.put("menu_objid", menuObjid);
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(service.getColumnMapping(params)));
} catch (Exception e) {
log.error("컬럼 매핑 조회 실패: tableName={}, menuObjid={}", tableName, menuObjid, e);
return ResponseEntity.status(500).body(ApiResponse.error("컬럼 매핑 조회 중 오류가 발생했습니다"));
}
}
/** GET /api/table-categories/logical-columns/{tableName}/{menuObjid} */
@GetMapping("/logical-columns/{tableName}/{menuObjid}")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getLogicalColumns(
@PathVariable String tableName,
@PathVariable Long menuObjid,
@RequestAttribute("company_code") String companyCode) {
try {
Map<String, Object> params = new HashMap<>();
params.put("table_name", tableName);
params.put("menu_objid", menuObjid);
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(service.getLogicalColumns(params)));
} catch (Exception e) {
log.error("논리적 컬럼 목록 조회 실패: tableName={}, menuObjid={}", tableName, menuObjid, e);
return ResponseEntity.status(500).body(ApiResponse.error("논리적 컬럼 목록 조회 중 오류가 발생했습니다"));
}
}
/** POST /api/table-categories/column-mapping */
@PostMapping("/column-mapping")
public ResponseEntity<ApiResponse<Map<String, Object>>> createColumnMapping(
@RequestBody Map<String, Object> body,
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("user_id") String userId) {
String tableName = (String) body.get("table_name");
String logicalColumnName = (String) body.get("logical_column_name");
String physicalColumnName = (String) body.get("physical_column_name");
Object menuObjid = body.get("menu_objid");
if (tableName == null || logicalColumnName == null
|| physicalColumnName == null || menuObjid == null) {
return ResponseEntity.status(400).body(ApiResponse.error(
"tableName, logicalColumnName, physicalColumnName, menuObjid는 필수입니다"));
}
body.put("company_code", companyCode);
body.put("user_id", userId);
// menuObjid를 Long으로 보장
body.put("menu_objid", toLong(menuObjid));
try {
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success(service.createColumnMapping(body), "컬럼 매핑이 생성되었습니다"));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(500).body(ApiResponse.error(
e.getMessage() != null ? e.getMessage() : "컬럼 매핑 생성 중 오류가 발생했습니다"));
} catch (Exception e) {
log.error("컬럼 매핑 생성 실패", e);
return ResponseEntity.status(500).body(ApiResponse.error("컬럼 매핑 생성 중 오류가 발생했습니다"));
}
}
/**
* DELETE /api/table-categories/column-mapping/{tableName}/{columnName}/all
* NOTE: 3-segment 경로이므로 /{mappingId} 1-segment 경로보다 Spring이 우선 매핑.
*/
@DeleteMapping("/column-mapping/{tableName}/{columnName}/all")
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteColumnMappingsByColumn(
@PathVariable String tableName,
@PathVariable String columnName,
@RequestAttribute("company_code") String companyCode) {
try {
Map<String, Object> params = new HashMap<>();
params.put("table_name", tableName);
params.put("column_name", columnName);
params.put("company_code", companyCode);
int deleted = service.deleteColumnMappingsByColumn(params);
Map<String, Object> data = new HashMap<>();
data.put("deleted_count", deleted);
return ResponseEntity.ok(ApiResponse.success(data,
deleted + "개의 컬럼 매핑이 삭제되었습니다"));
} catch (Exception e) {
log.error("테이블+컬럼 기준 매핑 삭제 실패: tableName={}, columnName={}", tableName, columnName, e);
return ResponseEntity.status(500).body(ApiResponse.error("컬럼 매핑 삭제 중 오류가 발생했습니다"));
}
}
/** DELETE /api/table-categories/column-mapping/{mappingId} */
@DeleteMapping("/column-mapping/{mappingId}")
public ResponseEntity<ApiResponse<Void>> deleteColumnMapping(
@PathVariable Long mappingId,
@RequestAttribute("company_code") String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("mapping_id", mappingId);
params.put("company_code", companyCode);
try {
service.deleteColumnMapping(params);
return ResponseEntity.ok(ApiResponse.success(null, "컬럼 매핑이 삭제되었습니다"));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(500).body(ApiResponse.error(
e.getMessage() != null ? e.getMessage() : "컬럼 매핑 삭제 중 오류가 발생했습니다"));
} catch (Exception e) {
log.error("컬럼 매핑 삭제 실패: mappingId={}", mappingId, e);
return ResponseEntity.status(500).body(ApiResponse.error("컬럼 매핑 삭제 중 오류가 발생했습니다"));
}
}
// ── private util ───────────────────────────────────────────────
private long toLong(Object val) {
if (val == null) return 0L;
if (val instanceof Number) return ((Number) val).longValue();
try { return Long.parseLong(val.toString()); } catch (NumberFormatException e) { return 0L; }
}
}
@@ -75,7 +75,11 @@ public class TableManagementController {
@PutMapping("/tables/{tableName}/label")
public ResponseEntity<ApiResponse<Void>> updateTableLabel(
@PathVariable String tableName,
@RequestBody Map<String, Object> body) {
@RequestBody Map<String, Object> body,
@RequestAttribute("role") String role) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
String displayName = (String) body.get("display_name");
String description = (String) body.get("description");
if (displayName == null || displayName.isBlank()) {
@@ -105,7 +109,11 @@ public class TableManagementController {
@PathVariable String tableName,
@PathVariable String columnName,
@RequestBody Map<String, Object> settings,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
return doUpdateColumnSettings(tableName, columnName, settings, companyCode);
}
@@ -115,7 +123,11 @@ public class TableManagementController {
@PathVariable String tableName,
@PathVariable String columnName,
@RequestBody Map<String, Object> settings,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
return doUpdateColumnSettings(tableName, columnName, settings, companyCode);
}
@@ -136,7 +148,11 @@ public class TableManagementController {
public ResponseEntity<ApiResponse<Void>> updateAllColumnSettingsPost(
@PathVariable String tableName,
@RequestBody List<Map<String, Object>> columnSettings,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
return doUpdateAllColumnSettings(tableName, columnSettings, companyCode);
}
@@ -145,7 +161,11 @@ public class TableManagementController {
public ResponseEntity<ApiResponse<Void>> updateAllColumnSettingsBatch(
@PathVariable String tableName,
@RequestBody List<Map<String, Object>> columnSettings,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
return doUpdateAllColumnSettings(tableName, columnSettings, companyCode);
}
@@ -166,14 +186,20 @@ public class TableManagementController {
public ResponseEntity<ApiResponse<Void>> updateColumnWebType(
@PathVariable String tableName,
@PathVariable String columnName,
@RequestBody Map<String, Object> body) {
@RequestBody Map<String, Object> body,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
String webType = (String) body.get("web_type");
if (webType == null || webType.isBlank()) {
return ResponseEntity.status(400).body(ApiResponse.error("웹 타입이 필요합니다."));
}
@SuppressWarnings("unchecked")
Map<String, Object> detailSettings = (Map<String, Object>) body.get("detail_settings");
tableManagementService.updateColumnWebType(tableName, columnName, webType, detailSettings);
// 멀티테넌트 격리: SUPER_ADMIN(company_code='*') 가 아니면 자기 회사 코드로 저장
tableManagementService.updateColumnWebType(tableName, columnName, webType, detailSettings, companyCode);
return ResponseEntity.ok(ApiResponse.success(null, "컬럼 웹타입이 설정되었습니다."));
}
@@ -183,7 +209,11 @@ public class TableManagementController {
@PathVariable String tableName,
@PathVariable String columnName,
@RequestBody Map<String, Object> body,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
String inputType = (String) body.get("input_type");
if (tableName == null || columnName == null || inputType == null || inputType.isBlank()) {
return ResponseEntity.status(400).body(ApiResponse.error("테이블명, 컬럼명, 입력 타입이 모두 필요합니다."));
@@ -241,7 +271,11 @@ public class TableManagementController {
@PutMapping("/tables/{tableName}/primary-key")
public ResponseEntity<ApiResponse<Void>> setTablePrimaryKey(
@PathVariable String tableName,
@RequestBody Map<String, Object> body) {
@RequestBody Map<String, Object> body,
@RequestAttribute("role") String role) {
if (!isSuperAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
}
@SuppressWarnings("unchecked")
List<String> columns = (List<String>) body.get("columns");
if (tableName == null || columns == null || columns.isEmpty()) {
@@ -256,7 +290,11 @@ public class TableManagementController {
@PostMapping("/tables/{tableName}/indexes")
public ResponseEntity<ApiResponse<Void>> toggleTableIndex(
@PathVariable String tableName,
@RequestBody Map<String, Object> body) {
@RequestBody Map<String, Object> body,
@RequestAttribute("role") String role) {
if (!isSuperAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
}
String columnName = (String) body.get("column_name");
String indexType = (String) body.get("index_type");
String action = (String) body.get("action");
@@ -281,7 +319,11 @@ public class TableManagementController {
@PathVariable String tableName,
@PathVariable String columnName,
@RequestBody Map<String, Object> body,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) {
if (!isSuperAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
}
Object nullableObj = body.get("nullable");
if (tableName == null || columnName == null || !(nullableObj instanceof Boolean)) {
return ResponseEntity.status(400).body(ApiResponse.error("tableName, columnName, nullable(boolean)이 필요합니다."));
@@ -299,7 +341,11 @@ public class TableManagementController {
@PathVariable String tableName,
@PathVariable String columnName,
@RequestBody Map<String, Object> body,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) {
if (!isSuperAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
}
Object uniqueObj = body.get("unique");
if (tableName == null || columnName == null || !(uniqueObj instanceof Boolean)) {
return ResponseEntity.status(400).body(ApiResponse.error("tableName, columnName, unique(boolean)이 필요합니다."));
@@ -325,6 +371,57 @@ public class TableManagementController {
"테이블 데이터를 성공적으로 조회했습니다."));
}
/** POST /api/table-management/tables/:tableName/aggregate
* body: { aggregation: "count"|"sum"|..., columnName?: string, filters?: [...] }
* → { value: number }
*/
@PostMapping("/tables/{tableName}/aggregate")
public ResponseEntity<ApiResponse<Map<String, Object>>> aggregateTableData(
@PathVariable String tableName,
@RequestBody Map<String, Object> options) {
try {
return ResponseEntity.ok(ApiResponse.success(
tableManagementService.aggregateTableData(tableName, options == null ? Map.of() : options),
"테이블 집계를 성공적으로 조회했습니다."));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
}
/** POST /api/table-management/tables/:tableName/aggregate-group
* body: { aggregation, groupBy, valueColumn?, filters?, limit?, orderDir? }
* → { rows: [{ group, value }, ...] }
*/
@PostMapping("/tables/{tableName}/aggregate-group")
public ResponseEntity<ApiResponse<Map<String, Object>>> aggregateTableGroup(
@PathVariable String tableName,
@RequestBody Map<String, Object> options) {
try {
return ResponseEntity.ok(ApiResponse.success(
tableManagementService.aggregateTableGroup(tableName, options == null ? Map.of() : options),
"테이블 그룹 집계를 성공적으로 조회했습니다."));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
}
/** POST /api/table-management/tables/:tableName/select-rows
* body: { columns?, filters?, orderBy?, limit?, offset? }
* → { rows: [{...}, ...] }
*/
@PostMapping("/tables/{tableName}/select-rows")
public ResponseEntity<ApiResponse<Map<String, Object>>> selectTableRows(
@PathVariable String tableName,
@RequestBody Map<String, Object> options) {
try {
return ResponseEntity.ok(ApiResponse.success(
tableManagementService.selectTableRows(tableName, options == null ? Map.of() : options),
"테이블 row 를 성공적으로 조회했습니다."));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
}
/** POST /api/table-management/tables/:tableName/record (단일 레코드) */
@PostMapping("/tables/{tableName}/record")
public ResponseEntity<ApiResponse<Map<String, Object>>> getTableRecord(
@@ -366,7 +463,11 @@ public class TableManagementController {
public ResponseEntity<ApiResponse<Map<String, Object>>> addTableData(
@PathVariable String tableName,
@RequestBody Map<String, Object> data,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
if (data == null || data.isEmpty()) {
return ResponseEntity.status(400).body(ApiResponse.error("추가할 데이터가 필요합니다."));
}
@@ -399,7 +500,11 @@ public class TableManagementController {
public ResponseEntity<ApiResponse<Void>> editTableData(
@PathVariable String tableName,
@RequestBody Map<String, Object> body,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
@SuppressWarnings("unchecked")
Map<String, Object> originalData = (Map<String, Object>) body.get("original_data");
@SuppressWarnings("unchecked")
@@ -433,7 +538,11 @@ public class TableManagementController {
@DeleteMapping("/tables/{tableName}/delete")
public ResponseEntity<ApiResponse<Void>> deleteTableData(
@PathVariable String tableName,
@RequestBody Object body) {
@RequestBody Object body,
@RequestAttribute("role") String role) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
List<Map<String, Object>> dataList;
if (body instanceof List) {
@SuppressWarnings("unchecked")
@@ -457,7 +566,11 @@ public class TableManagementController {
@PostMapping("/tables/{tableName}/log")
public ResponseEntity<ApiResponse<Void>> createLogTable(
@PathVariable String tableName,
@RequestBody Map<String, Object> body) {
@RequestBody Map<String, Object> body,
@RequestAttribute("role") String role) {
if (!isSuperAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
}
@SuppressWarnings("unchecked")
List<String> logColumns = (List<String>) body.get("log_columns");
boolean isActive = Boolean.TRUE.equals(body.get("is_active"));
@@ -487,7 +600,11 @@ public class TableManagementController {
@PostMapping("/tables/{tableName}/log/toggle")
public ResponseEntity<ApiResponse<Void>> toggleLogTable(
@PathVariable String tableName,
@RequestBody Map<String, Object> body) {
@RequestBody Map<String, Object> body,
@RequestAttribute("role") String role) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
boolean isActive = Boolean.TRUE.equals(body.get("is_active"));
tableManagementService.toggleLogTable(tableName, isActive);
return ResponseEntity.ok(ApiResponse.success(null,
@@ -544,7 +661,11 @@ public class TableManagementController {
@PostMapping("/multi-table-save")
public ResponseEntity<ApiResponse<Map<String, Object>>> multiTableSave(
@RequestBody Map<String, Object> payload,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
return ResponseEntity.ok(ApiResponse.success(
tableManagementService.multiTableSave(payload, companyCode),
"다중 테이블 저장이 완료되었습니다."));
@@ -575,4 +696,16 @@ public class TableManagementController {
return ResponseEntity.ok(ApiResponse.success(
tableManagementService.checkDatabaseConnection(), "데이터베이스 연결 상태를 확인했습니다."));
}
// ──────────────────────────────────────────────────────────
// 권한 헬퍼
// ──────────────────────────────────────────────────────────
private boolean isAdmin(String role) {
return isSuperAdmin(role) || "COMPANY_ADMIN".equals(role);
}
private boolean isSuperAdmin(String roleOrCode) {
return "*".equals(roleOrCode) || "SUPER_ADMIN".equals(roleOrCode);
}
}
@@ -2,6 +2,8 @@ package com.erp.crosstenant;
import com.erp.tenant.DbContextHolder;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
/**
* Cross-tenant 어드민 엔드포인트 진입 가드.
@@ -42,4 +44,16 @@ public final class CrossTenantContext {
public static boolean isMetaContext() {
return DbContextHolder.isMeta();
}
/**
* 관리 호스트(solution.invyone.com / admin.invyone.com / localhost / 베이스 도메인) 외엔 거절.
* cross-tenant 작업은 plane 격리상 관리 호스트에서만 허용. SuperAdminGuard.isTenantHost 와 동일 규칙.
*/
public static void requireManagementHost(HttpServletRequest request) {
String host = request.getHeader("Host");
if (com.erp.provisioning.SuperAdminGuard.isTenantHost(host)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
"Cross-tenant operations are only available on the management site");
}
}
}
@@ -59,6 +59,12 @@ public class CrossTenantController {
*/
@GetMapping("/_active-companies")
public ResponseEntity<ApiResponse<Map<String, Object>>> activeCompaniesSmoke(HttpServletRequest request) {
try {
CrossTenantContext.requireManagementHost(request);
} catch (org.springframework.web.server.ResponseStatusException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
}
if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -92,6 +98,12 @@ public class CrossTenantController {
public ResponseEntity<ApiResponse<Map<String, Object>>> listUsers(
HttpServletRequest request,
@RequestParam Map<String, Object> queryParams) {
try {
CrossTenantContext.requireManagementHost(request);
} catch (org.springframework.web.server.ResponseStatusException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
}
if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -173,6 +185,12 @@ public class CrossTenantController {
Map<String, Object> queryParams,
String mapperId,
boolean wrapSearchWithPercent) {
try {
CrossTenantContext.requireManagementHost(request);
} catch (org.springframework.web.server.ResponseStatusException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
}
if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -39,6 +39,12 @@ public class CrossTenantDeptController {
public ResponseEntity<Map<String, Object>> listDepartments(
HttpServletRequest request,
@RequestParam("company_code") String companyCode) {
try {
CrossTenantContext.requireManagementHost(request);
} catch (org.springframework.web.server.ResponseStatusException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(errorBody(e.getReason(), request.getRequestURI()));
}
if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(errorBody("super_admin_required", request.getRequestURI()));
@@ -1,6 +1,7 @@
package com.erp.crosstenant;
import com.erp.dto.ApiResponse;
import com.erp.provisioning.CompanyAuditLogService;
import com.erp.service.RoleService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
@@ -33,6 +34,7 @@ public class CrossTenantRoleController {
private final CrossTenantExecutor executor;
private final RoleService roleService;
private final CompanyAuditLogService auditLogService;
// ── 권한 그룹 CRUD ──────────────────────────────────────────────
@@ -49,6 +51,7 @@ public class CrossTenantRoleController {
if (g != null) return g;
String targetCompany = (String) body.get("company_code");
String actorId = (String) request.getAttribute("user_id");
try {
Map<String, Object> result = executor.runInCompany(targetCompany, () -> {
Map<String, Object> params = new HashMap<>(body);
@@ -62,6 +65,10 @@ public class CrossTenantRoleController {
}
return roleService.createRoleGroup(params);
});
auditLogService.log(targetCompany, actorId,
CompanyAuditLogService.ACTION_CT_ROLE_UPDATE,
(String) body.get("auth_code"),
auditDetails(request, null));
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(result, "권한 그룹 생성 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
@@ -81,6 +88,7 @@ public class CrossTenantRoleController {
if (g != null) return g;
String targetCompany = (String) body.get("company_code");
String actorId = (String) request.getAttribute("user_id");
try {
Map<String, Object> result = executor.runInCompany(targetCompany, () -> {
Map<String, Object> params = new HashMap<>(body);
@@ -94,6 +102,10 @@ public class CrossTenantRoleController {
}
return roleService.updateRoleGroup(params);
});
auditLogService.log(targetCompany, actorId,
CompanyAuditLogService.ACTION_CT_ROLE_UPDATE,
id,
auditDetails(request, id));
return ResponseEntity.ok(ApiResponse.success(result, "권한 그룹 수정 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
@@ -111,12 +123,17 @@ public class CrossTenantRoleController {
ResponseEntity<ApiResponse<Void>> g = guardVoid(request);
if (g != null) return g;
String actorId = (String) request.getAttribute("user_id");
try {
executor.runInCompany(companyCode, () -> {
Map<String, Object> p = new HashMap<>();
p.put("objid", id);
roleService.deleteRoleGroup(p);
});
auditLogService.log(companyCode, actorId,
CompanyAuditLogService.ACTION_CT_ROLE_UPDATE,
id,
auditDetails(request, id));
return ResponseEntity.ok(ApiResponse.success(null, "권한 그룹 삭제 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
@@ -266,6 +283,12 @@ public class CrossTenantRoleController {
// ── 가드 헬퍼 (응답 타입별로 3가지 — Map/Void/List) ────────
private ResponseEntity<ApiResponse<Map<String, Object>>> guardMap(HttpServletRequest request) {
try {
CrossTenantContext.requireManagementHost(request);
} catch (org.springframework.web.server.ResponseStatusException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
}
if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -278,6 +301,12 @@ public class CrossTenantRoleController {
}
private ResponseEntity<ApiResponse<Void>> guardVoid(HttpServletRequest request) {
try {
CrossTenantContext.requireManagementHost(request);
} catch (org.springframework.web.server.ResponseStatusException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
}
if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -290,6 +319,12 @@ public class CrossTenantRoleController {
}
private ResponseEntity<ApiResponse<List<Map<String, Object>>>> guardList(HttpServletRequest request) {
try {
CrossTenantContext.requireManagementHost(request);
} catch (org.springframework.web.server.ResponseStatusException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
}
if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -301,6 +336,14 @@ public class CrossTenantRoleController {
return null;
}
/** audit log details 기본 맵 생성 헬퍼. */
private Map<String, Object> auditDetails(HttpServletRequest request, String roleId) {
Map<String, Object> d = new HashMap<>();
d.put("host", request.getHeader("Host"));
if (roleId != null) d.put("role_id", roleId);
return d;
}
/** "Y"/"N"/null 정규화 — RoleController 의 동일 헬퍼 미러. */
private String asYn(Object raw) {
if (raw == null) return null;
@@ -1,6 +1,7 @@
package com.erp.crosstenant;
import com.erp.dto.ApiResponse;
import com.erp.provisioning.CompanyAuditLogService;
import com.erp.service.AdminService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
@@ -36,6 +37,7 @@ public class CrossTenantUserController {
private final CrossTenantExecutor executor;
private final AdminService adminService;
private final CompanyAuditLogService auditLogService;
// ── 등록 / 수정 ─────────────────────────────────────────────────────
@@ -51,9 +53,14 @@ public class CrossTenantUserController {
if (guard != null) return guard;
String targetCompanyCode = (String) body.get("company_code");
String actorId = (String) request.getAttribute("user_id");
try {
Map<String, Object> result = executor.runInCompany(targetCompanyCode,
() -> adminService.saveUser(body));
auditLogService.log(targetCompanyCode, actorId,
CompanyAuditLogService.ACTION_CT_USER_CREATE,
(String) body.get("user_id"),
auditDetails(request, (String) body.get("user_id")));
return ResponseEntity.ok(ApiResponse.success(result, "사용자 저장 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
@@ -116,6 +123,7 @@ public class CrossTenantUserController {
ResponseEntity<ApiResponse<Void>> guard = guardVoid(request);
if (guard != null) return guard;
String actorId = (String) request.getAttribute("user_id");
try {
executor.runInCompany(companyCode, () -> {
Map<String, Object> existing = adminService.getUserInfo(userId);
@@ -124,6 +132,10 @@ public class CrossTenantUserController {
}
adminService.changeUserStatus(userId, "inactive");
});
auditLogService.log(companyCode, actorId,
CompanyAuditLogService.ACTION_CT_USER_DELETE,
userId,
auditDetails(request, userId));
return ResponseEntity.ok(ApiResponse.success(null, "사용자 삭제 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage()));
@@ -166,9 +178,14 @@ public class CrossTenantUserController {
String targetCompanyCode = (String) body.get("company_code");
String userId = (String) body.get("user_id");
String actorId = (String) request.getAttribute("user_id");
try {
executor.runInCompany(targetCompanyCode, () ->
adminService.resetUserPassword(userId));
auditLogService.log(targetCompanyCode, actorId,
CompanyAuditLogService.ACTION_CT_PW_RESET,
userId,
auditDetails(request, userId));
return ResponseEntity.ok(ApiResponse.success(null, "비밀번호 초기화 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage()));
@@ -260,6 +277,12 @@ public class CrossTenantUserController {
/** Map<String,Object> 응답용 가드 — null 이면 통과, 아니면 그대로 반환. */
private ResponseEntity<ApiResponse<Map<String, Object>>> guard(HttpServletRequest request) {
try {
CrossTenantContext.requireManagementHost(request);
} catch (org.springframework.web.server.ResponseStatusException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
}
if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -273,6 +296,12 @@ public class CrossTenantUserController {
/** Void 응답용 가드 (제네릭만 다름). */
private ResponseEntity<ApiResponse<Void>> guardVoid(HttpServletRequest request) {
try {
CrossTenantContext.requireManagementHost(request);
} catch (org.springframework.web.server.ResponseStatusException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
}
if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -283,4 +312,12 @@ public class CrossTenantUserController {
}
return null;
}
/** audit log details 기본 맵 생성 헬퍼. */
private Map<String, Object> auditDetails(HttpServletRequest request, String targetUserId) {
Map<String, Object> d = new HashMap<>();
d.put("host", request.getHeader("Host"));
if (targetUserId != null) d.put("target_user_id", targetUserId);
return d;
}
}
@@ -177,6 +177,135 @@ public class StartupSchemaMigrator {
AND s.START_DATE IS NOT DISTINCT FROM CAST(NULLIF(p.START_DATE, '') AS DATE)
AND s.END_DATE = CAST(NULLIF(p.END_DATE, '') AS DATE)
)
""",
// V021 / RUN_087: BATCH_MAPPINGS 에 MAPPING_CONFIG JSONB 컬럼 추가.
// conditional 매핑(when/then/default) 규칙 저장용.
// direct/fixed 매핑은 NULL. 메타 DB 는 Flyway V021 로도 적용되지만
// 프로비저닝된 테넌트 DB 는 부팅 때 동기화.
"ALTER TABLE BATCH_MAPPINGS ADD COLUMN IF NOT EXISTS MAPPING_CONFIG JSONB",
// V022 / RUN_088: 부서별 다중 관리자(결재/부서/조직장) 매핑 테이블.
// 기존 DEPT_INFO.APPROVAL_MANAGER/DEPT_MANAGER 단일 컬럼 → 매핑 테이블로 다중화.
// role: 'approval' | 'dept' | 'org_leader'. 부서 hard-delete 시 CASCADE 로 정리.
// 메타 DB 는 Flyway V022 로도 적용되지만 프로비저닝된 테넌트 DB 는 부팅 때 동기화.
"""
CREATE TABLE IF NOT EXISTS DEPT_MANAGERS (
DEPT_CODE VARCHAR(1024) NOT NULL,
USER_ID VARCHAR(50) NOT NULL,
ROLE VARCHAR(20) NOT NULL,
SORT_ORDER INTEGER NOT NULL DEFAULT 1,
CREATED_AT TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (DEPT_CODE, USER_ID, ROLE),
CONSTRAINT chk_dept_managers_role
CHECK (ROLE IN ('approval', 'dept', 'org_leader')),
CONSTRAINT fk_dept_managers_dept
FOREIGN KEY (DEPT_CODE) REFERENCES DEPT_INFO(DEPT_CODE) ON DELETE CASCADE
)
""",
"CREATE INDEX IF NOT EXISTS idx_dept_managers_role ON DEPT_MANAGERS (DEPT_CODE, ROLE, SORT_ORDER)",
// V023 / RUN_089: MENU_INFO 에 IS_SOLUTION_ONLY 컬럼 추가.
// 솔루션 관리 호스트(solution.invyone.com 등) 에서만 노출되는 메뉴 플래그.
// 테넌트 사이트에선 mapper SQL 단계에서 제외. 메타 DB 는 Flyway V023 으로도 적용되지만
// 프로비저닝된 테넌트 DB 는 부팅 때 동기화.
"ALTER TABLE MENU_INFO ADD COLUMN IF NOT EXISTS IS_SOLUTION_ONLY BOOLEAN DEFAULT FALSE NOT NULL",
// V023 데이터 동기화: 솔루션 전용 메뉴 마킹.
// 회사관리 / 회사 프로비저닝 / 감사로그는 관리 호스트에서만 노출돼야 함.
// 이미 TRUE 인 행은 그대로 두기 위해 false 인 행만 갱신.
"""
UPDATE MENU_INFO
SET IS_SOLUTION_ONLY = TRUE
WHERE IS_SOLUTION_ONLY = FALSE
AND MENU_URL IN (
'/admin/sysMng/subdomainList',
'/admin/userMng/companyList',
'/admin/audit-log'
)
""",
// V024 / RUN_089: TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO rename.
// 5/15 common-code 재설계(commit 2348800e) 가 mapper SQL 의 컬럼 참조명만
// 바꾸고 DB rename 을 빠뜨려, 테이블타입관리 컬럼 조회 API 가 500 반환.
// PostgreSQL 은 RENAME COLUMN 에 IF EXISTS 가 없어서 DO 블록으로 멱등 처리:
// - CODE_CATEGORY 만 있는 기존 테넌트: rename 수행
// - 이미 CODE_INFO 인 신규 테넌트: no-op
// - 둘 다 있거나 둘 다 없는 비정상 상태: no-op (방어적)
"""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'table_type_columns'
AND column_name = 'code_category'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'table_type_columns'
AND column_name = 'code_info'
) THEN
ALTER TABLE TABLE_TYPE_COLUMNS
RENAME COLUMN CODE_CATEGORY TO CODE_INFO;
END IF;
END $$
""",
// V025 / RUN_090 (1) TABLE_TYPE_COLUMNS 중복 행 정리.
// PK 가 id 단일 (varchar) 인데 (TABLE_NAME, COLUMN_NAME, COMPANY_CODE) 에는
// UNIQUE 가 없어서 같은 키로 row 가 여러 개 INSERT 된 이력이 있음.
// 메타 DB 실측: 35K rows 중 2 그룹 4 row 가 중복. 그 그룹들은 동일 데이터를
// updated_date NULL 짜리 옛 row 와 2026-03-16 마지막 갱신 row 가 공존하는 형태.
// 가장 최근 (updated_date DESC NULLS LAST, id::bigint DESC) 행만 남기고 제거.
// 테넌트 DB 들은 실측상 중복 없음 → DELETE 0건. 멱등 (재실행해도 변화 없음).
"""
DELETE FROM TABLE_TYPE_COLUMNS
WHERE id IN (
SELECT id FROM (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY TABLE_NAME, COLUMN_NAME, COMPANY_CODE
ORDER BY UPDATED_DATE DESC NULLS LAST,
id::bigint DESC
) AS rn
FROM TABLE_TYPE_COLUMNS
) r
WHERE r.rn > 1
)
""",
// V025 / RUN_090 (2) ON CONFLICT 매칭용 UNIQUE INDEX 추가.
// mapper 의 upsertColumnSettings / upsertNullable / upsertUnique /
// upsertColumnInputType 모두 ON CONFLICT (TABLE_NAME, COLUMN_NAME, COMPANY_CODE)
// 를 쓰는데 DB 엔 매칭 unique 제약이 없어서 모든 쓰기 API 가 500.
// 인덱스 형태로 등록하면 ON CONFLICT 가 인식하고 ADD CONSTRAINT 식의
// IF NOT EXISTS 누락 문제도 회피.
"CREATE UNIQUE INDEX IF NOT EXISTS UX_TABLE_TYPE_COLUMNS_TCC ON TABLE_TYPE_COLUMNS (TABLE_NAME, COLUMN_NAME, COMPANY_CODE)",
// V026 / RUN_091: TABLE_TYPE_COLUMNS.INPUT_TYPE legacy → 표준 8종 정리.
// 5/15 common-code 재설계가 화이트리스트를 8종으로 좁혔지만 운영 DB 의
// 옛 값(category 886, select 149, textarea 102, checkbox 55, radio 12,
// datetime 2, boolean 1) 을 정리하는 마이그레이션을 빠뜨림.
// 매핑:
// category / select / radio / checkbox / boolean → code (commonCode 통합 의도)
// textarea → text (single/multi line 구분 손실 — UI 동작 가벼움)
// datetime → date
// 메타 DB 1,207 row 갱신. 테넌트 DB 들은 비어있어 영향 0.
// WHERE 절로 멱등 (재실행 시 0 row).
"""
UPDATE TABLE_TYPE_COLUMNS
SET INPUT_TYPE = CASE INPUT_TYPE
WHEN 'category' THEN 'code'
WHEN 'select' THEN 'code'
WHEN 'radio' THEN 'code'
WHEN 'checkbox' THEN 'code'
WHEN 'boolean' THEN 'code'
WHEN 'textarea' THEN 'text'
WHEN 'datetime' THEN 'date'
END,
UPDATED_DATE = NOW()
WHERE INPUT_TYPE IN ('category','select','radio','checkbox','boolean','textarea','datetime')
"""
);
@@ -200,9 +329,18 @@ public class StartupSchemaMigrator {
}
int ok = 0, fail = 0;
List<String> failedDbs = new java.util.ArrayList<>();
for (String db : tenantDbs) {
if (db == null || db.isBlank() || db.equalsIgnoreCase(metaDb)) continue;
if (applyTo(db, "tenant")) ok++; else fail++;
if (applyTo(db, "tenant")) {
ok++;
} else {
fail++;
failedDbs.add(db);
}
}
if (!failedDbs.isEmpty()) {
log.error("[SchemaMigrator] 마이그레이션 실패 테넌트 DB ({}건): {}", failedDbs.size(), failedDbs);
}
log.info("[SchemaMigrator] done — meta=done, tenants ok={}, fail={}", ok, fail);
}
@@ -40,6 +40,12 @@ public class CompanyAuditLogService {
public static final String ACTION_PW_RESET = "ADMIN_PASSWORD_RESET";
public static final String ACTION_RECOPY = "TEMPLATES_RECOPY";
// cross-tenant write 감사 액션
public static final String ACTION_CT_USER_CREATE = "CROSS_TENANT_USER_CREATE";
public static final String ACTION_CT_USER_DELETE = "CROSS_TENANT_USER_DELETE";
public static final String ACTION_CT_PW_RESET = "CROSS_TENANT_PASSWORD_RESET";
public static final String ACTION_CT_ROLE_UPDATE = "CROSS_TENANT_ROLE_UPDATE";
private final SqlSession sqlSession;
private final ObjectMapper objectMapper;
@@ -100,13 +100,22 @@ public class DataCopier {
try (Statement us = dst.createStatement()) {
for (String[] r : rows) {
String seq = r[0], tbl = r[1], col = r[2], coltype = r[3];
if (!isIntegerLike(coltype)) {
String sql;
if (isIntegerLike(coltype)) {
sql = String.format(
"SELECT setval('%s', GREATEST(COALESCE((SELECT MAX(\"%s\") FROM \"%s\"), 0), 1))",
seq.replace("'", "''"), col, tbl);
} else if (isVarcharLike(coltype)) {
// V001 마이그레이션으로 INT → VARCHAR 로 바뀐 PK 컬럼도 시퀀스가 연결되어 있고,
// INSERT 시 DEFAULT nextval 이 호출되므로 max 재설정 필요. 비숫자 PK(UUID 등) 가
// 섞여 있어도 정규식으로 거르고 숫자 PK 만 max 계산.
sql = String.format(
"SELECT setval('%s', GREATEST(COALESCE((SELECT MAX(\"%s\"::bigint) FROM \"%s\" WHERE \"%s\" ~ '^[0-9]+$'), 0), 1))",
seq.replace("'", "''"), col, tbl, col);
} else {
skippedType++;
continue;
}
String sql = String.format(
"SELECT setval('%s', GREATEST(COALESCE((SELECT MAX(\"%s\") FROM \"%s\"), 0), 1))",
seq.replace("'", "''"), col, tbl);
try {
us.execute(sql);
updated++;
@@ -117,14 +126,8 @@ public class DataCopier {
}
}
}
// invyone 은 대다수 PK 가 VARCHAR (문자열 PK). 시퀀스가 연결되어 있어도 실제 INSERT 때
// nextval 을 사용하지 않으므로 setval 은 no-op. skipped_non_integer 값이 높아도 정상.
if (updated == 0 && skippedErr == 0) {
log.info("[Provisioning] RESET SEQUENCES: skipped all {} (string-PK schema, no-op)", rows.size());
} else {
log.info("[Provisioning] RESET SEQUENCES: updated={} skipped_non_integer={} skipped_error={} total={}",
updated, skippedType, skippedErr, rows.size());
}
log.info("[Provisioning] RESET SEQUENCES: updated={} skipped_non_numeric={} skipped_error={} total={}",
updated, skippedType, skippedErr, rows.size());
return updated;
}
@@ -135,6 +138,12 @@ public class DataCopier {
|| t.startsWith("int4") || t.startsWith("int8") || t.startsWith("int2");
}
private static boolean isVarcharLike(String coltype) {
if (coltype == null) return false;
String t = coltype.toLowerCase();
return t.startsWith("character varying") || t.startsWith("varchar") || t.startsWith("text");
}
private List<String> listColumns(Connection conn, String table) throws SQLException {
List<String> cols = new ArrayList<>();
try (PreparedStatement ps = conn.prepareStatement(
@@ -5,12 +5,9 @@ import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.security.SecureRandom;
import java.util.Arrays;
@@ -40,13 +37,7 @@ public class ProvisioningController {
private final ProvisioningRegistry registry;
private final SqlSession sqlSession;
private final CompanyStatsService statsService;
/**
* 프로덕션 배포 시엔 반드시 true 로. 개발 중엔 JWT 없는 curl 테스트를 허용하기 위해 false 기본.
* 환경변수: TENANT_PROVISIONING_REQUIRE_SUPER_ADMIN=true
*/
@Value("${tenant.provisioning.require-super-admin:false}")
private boolean requireSuperAdmin;
private final SuperAdminGuard guard;
@GetMapping("/table-groups")
public ResponseEntity<List<Map<String, Object>>> tableGroups(HttpServletRequest request) {
@@ -208,23 +199,11 @@ public class ProvisioningController {
}
// ------------------------------------------------------------------
// 권한 체크
//
// 현재 `/api/**` 가 permitAll 이라 Controller 레벨에서 수동 검증.
// JWT 가 있으면 JwtAuthenticationFilter 가 request.getAttribute("user_type") 세팅.
// 개발 모드(requireSuperAdmin=false): JWT 없이도 통과 (curl 테스트용). 단 다른 role 은 차단.
// 프로덕션 모드(requireSuperAdmin=true): SUPER_ADMIN 아니면 모두 403.
// 권한 체크 — SuperAdminGuard 로 위임 (호스트 격리 + role 검증).
// CompanyMgmtController 와 동일한 가드를 공유.
// ------------------------------------------------------------------
private void enforceSuperAdmin(HttpServletRequest request) {
String userType = (String) request.getAttribute("user_type");
if ("SUPER_ADMIN".equals(userType)) return;
if (!requireSuperAdmin && userType == null) {
log.warn("[Provisioning] anonymous access allowed in dev mode (set " +
"tenant.provisioning.require-super-admin=true in production)");
return;
}
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "SUPER_ADMIN only");
guard.enforce(request);
}
// --- Validation helpers ---
@@ -1,5 +1,6 @@
package com.erp.provisioning;
import com.erp.tenant.ReservedSubdomains;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
@@ -7,9 +8,14 @@ import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import java.util.regex.Pattern;
/**
* `/api/admin/provisioning/*` 계열 엔드포인트 공통 권한 가드.
*
* - 호스트 격리: 테넌트 서브도메인(qnc.invyone.com 등)에서 호출하면 무조건 403.
* 프로비저닝 plane 은 solution/admin/localhost/베이스 도메인 같은 "관리 호스트" 에서만 동작.
* (한 번 SUPER_ADMIN 토큰이 새도 임의의 테넌트 사이트에서는 회사를 만들 수 없게 막음)
* - 프로덕션 (tenant.provisioning.require-super-admin=true): SUPER_ADMIN 만 통과
* - 개발 (기본값 false): JWT 없어도 통과 (curl 테스트). 다른 role 은 여전히 차단
*
@@ -19,10 +25,22 @@ import org.springframework.web.server.ResponseStatusException;
@Slf4j
public class SuperAdminGuard {
private static final Pattern IPV4 = Pattern.compile("^\\d{1,3}(\\.\\d{1,3}){3}$");
@Value("${tenant.provisioning.require-super-admin:false}")
private boolean requireSuperAdmin;
public void enforce(HttpServletRequest request) {
// 1) 호스트 격리 — role 보다 먼저 체크. 어떤 role 도 테넌트 사이트에서는 통과 못 함.
String host = request.getHeader("Host");
if (isTenantHost(host)) {
log.warn("[ProvisioningGuard] blocked tenant-host call: host={} path={} userType={}",
host, request.getRequestURI(), request.getAttribute("user_type"));
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
"Provisioning is only available on the management site");
}
// 2) role 체크
String userType = (String) request.getAttribute("user_type");
if ("SUPER_ADMIN".equals(userType)) return;
if (!requireSuperAdmin && userType == null) {
@@ -37,4 +55,40 @@ public class SuperAdminGuard {
String userId = (String) request.getAttribute("user_id");
return userId == null ? "dev-anonymous" : userId;
}
/** 감사 로그에 기록할 호스트 (Host 헤더 그대로, 포트 포함). null safe. */
public String requestHost(HttpServletRequest request) {
String host = request.getHeader("Host");
return host == null ? "" : host;
}
/**
* "테넌트 사이트에서 온 요청인지" 판단. SubdomainResolverFilter.extractSubdomain 와 같은 규칙.
* - localhost / IP / 베이스 도메인 → false (관리 호스트)
* - solution.invyone.com 등 예약어 prefix → false (관리 호스트)
* - qnc.invyone.com / test02.invyone.com 같은 실제 테넌트 → true
*/
public static boolean isTenantHost(String host) {
if (host == null || host.isBlank()) return false;
int colon = host.indexOf(':');
if (colon != -1) host = host.substring(0, colon);
host = host.toLowerCase();
if ("localhost".equals(host)) return false;
if (IPV4.matcher(host).matches()) return false;
String[] parts = host.split("\\.");
if (parts.length == 2) {
// {sub}.localhost (dev)
if (!"localhost".equals(parts[1])) return false;
String first = parts[0];
if (first.isEmpty()) return false;
return !ReservedSubdomains.VALUES.contains(first);
}
if (parts.length < 3) return false;
String first = parts[0];
return !ReservedSubdomains.VALUES.contains(first);
}
}
@@ -57,6 +57,13 @@ public class SubstituteContextFilter extends OncePerRequestFilter {
return;
}
// 대무자 컨텍스트가 의미 없는 경로 skip — 초기 페이지 로드 latency 의 큰 부분.
// ApprovalController 만 effective_user_ids 를 참조하므로 결재 외 경로는 DB 조회 불필요.
if (isSkippablePath(path)) {
chain.doFilter(request, response);
return;
}
String userId = (String) request.getAttribute("user_id");
String companyCode = (String) request.getAttribute("company_code");
@@ -85,4 +92,11 @@ public class SubstituteContextFilter extends OncePerRequestFilter {
chain.doFilter(request, response);
}
private static boolean isSkippablePath(String path) {
return path.startsWith("/api/auth/")
|| path.equals("/api/admin/menus")
|| path.equals("/api/admin/user-menus")
|| path.equals("/api/admin/user-locale");
}
}
@@ -1,5 +1,6 @@
package com.erp.service;
import com.erp.batch.BatchExecutor;
import com.erp.common.BaseService;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
@@ -24,8 +25,11 @@ public class BatchManagementService extends BaseService {
private CommonService commonService;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private BatchExecutor batchExecutor;
private static final String NS = "batchManagement.";
private static final String EXEC_LOG_NS = "batchExecutionLog.";
// ── Stats ─────────────────────────────────────────────────────────────────
@@ -113,24 +117,102 @@ public class BatchManagementService extends BaseService {
Map<String, Object> batchConfig = batchService.getBatchInfo(params);
if (batchConfig == null) throw new RuntimeException("배치 설정을 찾을 수 없습니다.");
long startTime = System.currentTimeMillis();
long startMs = System.currentTimeMillis();
String batchName = str(batchConfig.get("batch_name"));
String companyCode = str(batchConfig.get("company_code"));
log.info("배치 수동 실행: id={}, name={}", id, batchName);
long duration = System.currentTimeMillis() - startTime;
// 1. 실행 로그 INSERT — RUNNING 상태로 먼저 박아두면 도중 비정상 종료해도 추적 가능
Map<String, Object> logRow = new LinkedHashMap<>();
logRow.put("batch_config_id", id);
logRow.put("company_code", companyCode);
logRow.put("execution_status", "RUNNING");
logRow.put("server_name", safeHostName());
logRow.put("process_id", String.valueOf(ProcessHandle.current().pid()));
try {
sqlSession.insert(EXEC_LOG_NS + "insertBatchExecutionLog", logRow);
} catch (Exception e) {
log.warn("실행 로그 INSERT 실패 (실행은 계속 진행): {}", e.getMessage());
}
Object logId = logRow.get("id");
// 2. 실제 ETL 실행 — 예외는 로그에 기록 후 다시 throw (controller 의 에러 응답 위해)
BatchExecutor.ExecutionResult execResult = null;
String status = "SUCCESS";
String errorMessage = null;
try {
execResult = batchExecutor.execute(batchConfig);
if (execResult.failedRecords > 0) {
status = execResult.successRecords > 0 ? "PARTIAL" : "FAILED";
}
if (!execResult.errorMessages.isEmpty()) {
errorMessage = String.join("\n", execResult.errorMessages);
}
} catch (Exception e) {
status = "FAILED";
errorMessage = e.getMessage();
log.error("배치 실행 중 예외: id={} — {}", id, e.getMessage(), e);
}
long duration = System.currentTimeMillis() - startMs;
// 3. 실행 로그 UPDATE — 최종 상태/카운트/duration 마무리
// 주의: batch_execution_logs 의 duration_ms / *_records 컬럼은 운영 DB 에서 VARCHAR
// (V001 legacy 마이그레이션 흔적). PgJDBC 가 Long/Integer 를 VARCHAR 로 자동 변환하지 못할 수 있어
// 명시적으로 String 으로 보낸다. mapper 의 COALESCE default 도 '0' (문자열) 이라 일관됨.
if (logId != null) {
Map<String, Object> updateLog = new LinkedHashMap<>();
updateLog.put("id", logId);
updateLog.put("execution_status", status);
updateLog.put("end_time", new java.sql.Timestamp(System.currentTimeMillis()));
updateLog.put("duration_ms", String.valueOf(duration));
updateLog.put("total_records", String.valueOf(execResult != null ? execResult.totalRecords : 0));
updateLog.put("success_records", String.valueOf(execResult != null ? execResult.successRecords : 0));
updateLog.put("failed_records", String.valueOf(execResult != null ? execResult.failedRecords : 0));
if (errorMessage != null) updateLog.put("error_message", errorMessage);
try {
sqlSession.update(EXEC_LOG_NS + "updateBatchExecutionLog", updateLog);
} catch (Exception e) {
log.warn("실행 로그 UPDATE 실패: {}", e.getMessage());
}
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("batch_name", batchName);
result.put("total_records", 0);
result.put("success_records", 0);
result.put("failed_records", 0);
result.put("execution_status", status);
result.put("total_records", execResult != null ? execResult.totalRecords : 0);
result.put("success_records", execResult != null ? execResult.successRecords : 0);
result.put("failed_records", execResult != null ? execResult.failedRecords : 0);
result.put("execution_time", duration);
if (errorMessage != null) result.put("error_message", errorMessage);
return result;
}
/** 실행 로그 server_name 컬럼용 — hostname resolve 실패 시 "unknown". */
private static String safeHostName() {
try {
return java.net.InetAddress.getLocalHost().getHostName();
} catch (Exception e) {
return "unknown";
}
}
// ── REST API Preview / Save ───────────────────────────────────────────────
public Map<String, Object> previewRestApiData(Map<String, Object> body) {
// 프론트(batchManagement.ts)는 camelCase 로 키를 보내고 백엔드는 snake_case 로 읽음.
// 기존 convertCamelToSnake() 는 batch_configs 전용 remap 이라 여기엔 효과 없음.
// → previewRestApiData 전용으로 사용하는 키만 직접 remap.
remap(body, "apiUrl", "api_url");
remap(body, "apiKey", "api_key");
remap(body, "requestBody", "request_body");
remap(body, "dataArrayPath", "data_array_path");
remap(body, "paramType", "param_type");
remap(body, "paramName", "param_name");
remap(body, "paramValue", "param_value");
remap(body, "paramSource", "param_source");
remap(body, "authServiceName", "auth_service_name");
String apiUrl = str(body.get("api_url"));
String endpoint = str(body.get("endpoint"));
String method = body.get("method") != null ? str(body.get("method")) : "GET";
@@ -214,6 +296,11 @@ public class BatchManagementService extends BaseService {
return sqlSession.selectList(NS + "getBatchManagementSparklineData", params);
}
public List<Map<String, Object>> getGlobalSparkline(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
return sqlSession.selectList(NS + "getBatchManagementGlobalSparklineData", params);
}
public List<Map<String, Object>> getBatchRecentLogs(Map<String, Object> params) {
return sqlSession.selectList(NS + "getBatchManagementRecentLogList", params);
}
@@ -1,6 +1,7 @@
package com.erp.service;
import com.erp.common.BaseService;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -15,6 +16,9 @@ public class BatchService extends BaseService {
@Autowired
private CommonService commonService;
@Autowired
private ObjectMapper objectMapper;
private static final String NS = "batch.";
private static final String EXT_NS = "externalDbConnection.";
@@ -29,7 +33,11 @@ public class BatchService extends BaseService {
public Map<String, Object> getBatchInfo(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
return sqlSession.selectOne(NS + "getBatchInfo", params);
Map<String, Object> batch = sqlSession.selectOne(NS + "getBatchInfo", params);
if (batch != null) {
attachMappings(batch);
}
return batch;
}
@Transactional
@@ -37,9 +45,18 @@ public class BatchService extends BaseService {
sqlSession.insert(NS + "insertBatch", params);
Long id = params.get("id") != null ? Long.parseLong(params.get("id").toString()) : null;
if (id != null) {
// batch_configs INSERT 직후 mappings 동기화 (params 에 mappings 키가 있을 때만)
if (params.containsKey("mappings")) {
syncMappings(id,
toStr(params.get("company_code")),
toMappingList(params.get("mappings")),
toStr(params.get("created_by")));
}
Map<String, Object> infoParams = new HashMap<>();
infoParams.put("id", id);
return sqlSession.selectOne(NS + "getBatchInfo", infoParams);
Map<String, Object> result = sqlSession.selectOne(NS + "getBatchInfo", infoParams);
if (result != null) attachMappings(result);
return result;
}
return params;
}
@@ -48,9 +65,89 @@ public class BatchService extends BaseService {
public Map<String, Object> updateBatch(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
sqlSession.update(NS + "updateBatch", params);
Long id = params.get("id") != null ? Long.parseLong(params.get("id").toString()) : null;
// replace-all: body 에 mappings 키가 들어왔으면 (빈 배열 포함) 매핑 전체 교체
if (id != null && params.containsKey("mappings")) {
syncMappings(id,
toStr(params.get("company_code")),
toMappingList(params.get("mappings")),
toStr(params.get("updated_by") != null ? params.get("updated_by") : params.get("created_by")));
}
Map<String, Object> infoParams = new HashMap<>();
infoParams.put("id", params.get("id"));
return sqlSession.selectOne(NS + "getBatchInfo", infoParams);
Map<String, Object> result = sqlSession.selectOne(NS + "getBatchInfo", infoParams);
if (result != null) attachMappings(result);
return result;
}
// ── batch_mappings replace-all 동기화 ─────────────────────────────────────
/** batch_config_id 의 매핑을 전부 지우고 mappings 리스트로 다시 채운다. */
private void syncMappings(Long batchConfigId, String companyCode,
List<Map<String, Object>> mappings, String userId) {
Map<String, Object> delParams = new HashMap<>();
delParams.put("batch_config_id", batchConfigId);
sqlSession.delete(NS + "deleteBatchMappingsByConfigId", delParams);
if (mappings == null || mappings.isEmpty()) return;
for (int i = 0; i < mappings.size(); i++) {
Map<String, Object> row = new HashMap<>(mappings.get(i));
row.put("batch_config_id", batchConfigId);
if (row.get("company_code") == null) row.put("company_code", companyCode);
if (row.get("created_by") == null) row.put("created_by", userId);
if (row.get("mapping_order") == null) row.put("mapping_order", i + 1);
stringifyJsonField(row, "mapping_config");
sqlSession.insert(NS + "insertBatchMapping", row);
}
}
/** getBatchInfo 결과에 batch_mappings 리스트 attach. */
private void attachMappings(Map<String, Object> batch) {
Object idObj = batch.get("id");
if (idObj == null) return;
Map<String, Object> params = new HashMap<>();
params.put("batch_config_id", idObj);
List<Map<String, Object>> mappings = sqlSession.selectList(NS + "getBatchMappingsByConfigId", params);
if (mappings != null) {
for (Map<String, Object> row : mappings) parseJsonField(row, "mapping_config");
}
batch.put("batch_mappings", mappings != null ? mappings : new ArrayList<>());
}
/** JSONB → 객체. SELECT 결과의 TEXT cast 값을 파싱해 Map/List 로 되돌린다. */
private void parseJsonField(Map<String, Object> row, String key) {
Object val = row.get(key);
if (val instanceof String && !((String) val).isEmpty()) {
try {
row.put(key, objectMapper.readValue((String) val, Object.class));
} catch (Exception e) {
log.warn("Failed to parse JSONB field '{}': {}", key, e.getMessage());
}
}
}
/** 객체 → JSON 문자열. INSERT 전 ::jsonb 캐스팅을 위해 직렬화한다. null 은 그대로 둠. */
private void stringifyJsonField(Map<String, Object> params, String key) {
Object val = params.get(key);
if (val == null || val instanceof String) return;
try {
params.put(key, objectMapper.writeValueAsString(val));
} catch (Exception e) {
log.warn("Failed to stringify field '{}': {}", key, e.getMessage());
params.put(key, null);
}
}
@SuppressWarnings("unchecked")
private List<Map<String, Object>> toMappingList(Object raw) {
if (raw == null) return new ArrayList<>();
if (raw instanceof List) return (List<Map<String, Object>>) raw;
return new ArrayList<>();
}
private String toStr(Object v) {
return v != null ? v.toString() : null;
}
@Transactional
@@ -1,270 +0,0 @@
package com.erp.service;
import com.erp.common.BaseService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.stream.Collectors;
@Service
@Slf4j
public class CascadingAutoFillService extends BaseService {
private static final String NS = "cascadingAutoFill.";
@Autowired
private CommonService commonService;
@Autowired
private JdbcTemplate jdbcTemplate;
public Map<String, Object> getCascadingAutoFillGroupList(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
commonService.applyPagination(params);
int totalCount = sqlSession.selectOne(NS + "getCascadingAutoFillGroupListCnt", params);
List<Map<String, Object>> list = sqlSession.selectList(NS + "getCascadingAutoFillGroupList", params);
return commonService.buildListResponse(list, totalCount, params);
}
public Map<String, Object> getCascadingAutoFillGroupDetail(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
Map<String, Object> group = sqlSession.selectOne(NS + "getCascadingAutoFillGroupByCode", params);
if (group == null) return null;
Map<String, Object> mappingParams = new HashMap<>();
mappingParams.put("group_code", params.get("group_code"));
mappingParams.put("company_code", group.get("company_code"));
List<Map<String, Object>> mappings = sqlSession.selectList(NS + "getCascadingAutoFillMappingList", mappingParams);
Map<String, Object> result = new HashMap<>(group);
result.put("mappings", mappings);
return result;
}
@Transactional
public Map<String, Object> insertCascadingAutoFillGroup(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
String companyCode = (String) params.get("company_code");
// Generate group code: AF_{timestamp_base36}_{count:03d}
Map<String, Object> countParams = new HashMap<>();
countParams.put("company_code", companyCode);
Number cntNum = sqlSession.selectOne(NS + "getCascadingAutoFillGroupCount", countParams);
int count = (cntNum != null ? cntNum.intValue() : 0) + 1;
String timestamp = Long.toString(System.currentTimeMillis(), 36).toUpperCase();
String suffix = timestamp.substring(Math.max(0, timestamp.length() - 4));
String groupCode = "AF_" + suffix + "_" + String.format("%03d", count);
params.put("group_code", groupCode);
if (params.get("is_active") == null) {
params.put("is_active", "Y");
}
sqlSession.insert(NS + "insertCascadingAutoFillGroup", params);
// Insert mappings
Object mappingsObj = params.get("mappings");
if (mappingsObj instanceof List) {
List<?> mappings = (List<?>) mappingsObj;
for (int i = 0; i < mappings.size(); i++) {
Object m = mappings.get(i);
if (m instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> mapping = (Map<String, Object>) m;
Map<String, Object> mp = new HashMap<>(mapping);
mp.put("group_code", groupCode);
mp.put("company_code", companyCode);
if (mp.get("is_editable") == null) mp.put("is_editable", "Y");
if (mp.get("is_required") == null) mp.put("is_required", "N");
if (mp.get("sort_order") == null) mp.put("sort_order", i + 1);
sqlSession.insert(NS + "insertCascadingAutoFillMapping", mp);
}
}
}
return params;
}
@Transactional
public Map<String, Object> updateCascadingAutoFillGroup(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
String groupCode = (String) params.get("group_code");
Map<String, Object> existing = sqlSession.selectOne(NS + "getCascadingAutoFillGroupByCode", params);
if (existing == null) return null;
String actualCompanyCode = (String) existing.get("company_code");
params.put("company_code", actualCompanyCode);
sqlSession.update(NS + "updateCascadingAutoFillGroup", params);
// Replace mappings if provided
if (params.containsKey("mappings")) {
Map<String, Object> delParams = new HashMap<>();
delParams.put("group_code", groupCode);
delParams.put("company_code", actualCompanyCode);
sqlSession.delete(NS + "deleteCascadingAutoFillMappings", delParams);
Object mappingsObj = params.get("mappings");
if (mappingsObj instanceof List) {
List<?> mappings = (List<?>) mappingsObj;
for (int i = 0; i < mappings.size(); i++) {
Object m = mappings.get(i);
if (m instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> mapping = (Map<String, Object>) m;
Map<String, Object> mp = new HashMap<>(mapping);
mp.put("group_code", groupCode);
mp.put("company_code", actualCompanyCode);
if (mp.get("is_editable") == null) mp.put("is_editable", "Y");
if (mp.get("is_required") == null) mp.put("is_required", "N");
if (mp.get("sort_order") == null) mp.put("sort_order", i + 1);
sqlSession.insert(NS + "insertCascadingAutoFillMapping", mp);
}
}
}
}
return params;
}
@Transactional
public boolean deleteCascadingAutoFillGroup(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
Map<String, Object> existing = sqlSession.selectOne(NS + "getCascadingAutoFillGroupByCode", params);
if (existing == null) return false;
String groupCode = (String) params.get("group_code");
String companyCode = (String) existing.get("company_code");
Map<String, Object> delParams = new HashMap<>();
delParams.put("group_code", groupCode);
delParams.put("company_code", companyCode);
sqlSession.delete(NS + "deleteCascadingAutoFillMappings", delParams);
sqlSession.delete(NS + "deleteCascadingAutoFillGroup", delParams);
return true;
}
public List<Map<String, Object>> getAutoFillMasterOptions(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
String companyCode = (String) params.get("company_code");
Map<String, Object> groupParams = new HashMap<>(params);
groupParams.put("is_active", "Y");
Map<String, Object> group = sqlSession.selectOne(NS + "getCascadingAutoFillGroupByCode", groupParams);
if (group == null) return null;
String masterTable = sanitizeIdentifier((String) group.get("master_table"));
String masterValueColumn = sanitizeIdentifier((String) group.get("master_value_column"));
Object labelColObj = group.get("master_label_column");
String labelColumn = (labelColObj != null && !labelColObj.toString().isEmpty())
? sanitizeIdentifier(labelColObj.toString()) : masterValueColumn;
StringBuilder sql = new StringBuilder();
sql.append("SELECT ").append(masterValueColumn).append(" AS value, ")
.append(labelColumn).append(" AS label")
.append(" FROM ").append(masterTable)
.append(" WHERE 1=1");
List<Object> sqlParams = new ArrayList<>();
if (!"*".equals(companyCode) && hasColumn(masterTable, "company_code")) {
sql.append(" AND company_code = ?");
sqlParams.add(companyCode);
}
sql.append(" ORDER BY ").append(labelColumn);
return jdbcTemplate.queryForList(sql.toString(), sqlParams.toArray());
}
public Map<String, Object> getAutoFillData(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
String groupCode = (String) params.get("group_code");
String masterValue = (String) params.get("master_value");
String companyCode = (String) params.get("company_code");
Map<String, Object> groupParams = new HashMap<>(params);
groupParams.put("is_active", "Y");
Map<String, Object> group = sqlSession.selectOne(NS + "getCascadingAutoFillGroupByCode", groupParams);
if (group == null) return null;
String actualCompanyCode = (String) group.get("company_code");
Map<String, Object> mappingParams = new HashMap<>();
mappingParams.put("group_code", groupCode);
mappingParams.put("company_code", actualCompanyCode);
List<Map<String, Object>> mappings = sqlSession.selectList(NS + "getCascadingAutoFillMappingList", mappingParams);
if (mappings.isEmpty()) {
Map<String, Object> empty = new HashMap<>();
empty.put("data", new HashMap<>());
empty.put("mappings", new ArrayList<>());
return empty;
}
String masterTable = sanitizeIdentifier((String) group.get("master_table"));
String masterValueColumn = sanitizeIdentifier((String) group.get("master_value_column"));
String sourceColumns = mappings.stream()
.map(m -> sanitizeIdentifier((String) m.get("source_column")))
.collect(Collectors.joining(", "));
StringBuilder sql = new StringBuilder();
sql.append("SELECT ").append(sourceColumns)
.append(" FROM ").append(masterTable)
.append(" WHERE ").append(masterValueColumn).append(" = ?");
List<Object> sqlParams = new ArrayList<>();
sqlParams.add(masterValue);
if (!"*".equals(companyCode) && hasColumn(masterTable, "company_code")) {
sql.append(" AND company_code = ?");
sqlParams.add(companyCode);
}
List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString(), sqlParams.toArray());
Map<String, Object> dataRow = rows.isEmpty() ? null : rows.get(0);
Map<String, Object> autoFillData = new LinkedHashMap<>();
List<Map<String, Object>> mappingInfo = new ArrayList<>();
for (Map<String, Object> mapping : mappings) {
String sourceColumn = (String) mapping.get("source_column");
String targetField = (String) mapping.get("target_field");
Object sourceValue = (dataRow != null) ? dataRow.get(sourceColumn) : null;
Object defaultVal = mapping.get("default_value");
Object finalValue = (sourceValue != null) ? sourceValue : defaultVal;
autoFillData.put(targetField, finalValue);
Map<String, Object> info = new LinkedHashMap<>();
info.put("target_field", targetField);
info.put("target_label", mapping.get("target_label"));
info.put("value", finalValue);
info.put("is_editable", "Y".equals(mapping.get("is_editable")));
info.put("is_required", "Y".equals(mapping.get("is_required")));
mappingInfo.add(info);
}
Map<String, Object> result = new HashMap<>();
result.put("data", autoFillData);
result.put("mappings", mappingInfo);
return result;
}
private String sanitizeIdentifier(String identifier) {
if (identifier == null || !identifier.matches("[a-zA-Z0-9_.]+")) {
throw new IllegalArgumentException("Invalid SQL identifier: " + identifier);
}
return identifier;
}
private boolean hasColumn(String tableName, String columnName) {
try {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM information_schema.columns WHERE table_name = ? AND column_name = ?",
Integer.class, tableName, columnName);
return count != null && count > 0;
} catch (Exception e) {
return false;
}
}
}
@@ -1,198 +0,0 @@
package com.erp.service;
import com.erp.common.BaseService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.stream.Collectors;
@Service
@Slf4j
public class CascadingConditionService extends BaseService {
private static final String NS = "cascadingCondition.";
private static final String NS_RELATION = "cascadingRelation.";
@Autowired
private CommonService commonService;
@Autowired
private JdbcTemplate jdbcTemplate;
public Map<String, Object> getCascadingConditionList(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
commonService.applyPagination(params);
int totalCount = sqlSession.selectOne(NS + "getCascadingConditionListCnt", params);
List<Map<String, Object>> list = sqlSession.selectList(NS + "getCascadingConditionList", params);
return commonService.buildListResponse(list, totalCount, params);
}
public Map<String, Object> getCascadingConditionInfo(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
return sqlSession.selectOne(NS + "getCascadingConditionInfo", params);
}
@Transactional
public Map<String, Object> insertCascadingCondition(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
sqlSession.insert(NS + "insertCascadingCondition", params);
return params;
}
@Transactional
public Map<String, Object> updateCascadingCondition(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
sqlSession.update(NS + "updateCascadingCondition", params);
return params;
}
@Transactional
public Map<String, Object> deleteCascadingCondition(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
sqlSession.delete(NS + "deleteCascadingCondition", params);
return params;
}
public Map<String, Object> getFilteredOptions(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
String companyCode = (String) params.get("company_code");
String conditionFieldValue = params.get("condition_field_value") != null
? String.valueOf(params.get("condition_field_value")) : null;
String parentValue = params.get("parent_value") != null
? String.valueOf(params.get("parent_value")) : null;
// 1. 연쇄 관계 조회
Map<String, Object> relation = sqlSession.selectOne(NS_RELATION + "get_cascading_relation_by_code", params);
if (relation == null) {
Map<String, Object> empty = new LinkedHashMap<>();
empty.put("data", Collections.emptyList());
empty.put("applied_condition", null);
return empty;
}
// 2. 조건 규칙 조회 (우선순위 내림차순)
List<Map<String, Object>> conditions = sqlSession.selectList(NS + "getCascadingConditionsByRelationCode", params);
// 3. 매칭 조건 탐색
Map<String, Object> matchedCondition = null;
if (conditionFieldValue != null) {
for (Map<String, Object> cond : conditions) {
String operator = (String) cond.get("condition_operator");
String expectedValue = (String) cond.get("condition_value");
if (evaluateCondition(conditionFieldValue, operator, expectedValue)) {
matchedCondition = cond;
break;
}
}
}
// 4. 동적 옵션 쿼리 생성
String childTable = String.valueOf(relation.get("child_table"));
String valueCol = String.valueOf(relation.get("child_value_column"));
String labelCol = String.valueOf(relation.get("child_label_column"));
Object filterColObj = relation.get("child_filter_column");
Object orderColObj = relation.get("child_order_column");
String filterCol = filterColObj != null ? String.valueOf(filterColObj) : null;
String orderCol = orderColObj != null ? String.valueOf(orderColObj) : null;
String orderDir = relation.get("child_order_direction") != null
? String.valueOf(relation.get("child_order_direction")) : "ASC";
StringBuilder sql = new StringBuilder("SELECT ")
.append(valueCol).append(" as value, ")
.append(labelCol).append(" as label FROM ")
.append(childTable).append(" WHERE 1=1");
List<Object> sqlParams = new ArrayList<>();
if (parentValue != null && filterCol != null && !filterCol.isEmpty()) {
sql.append(" AND ").append(filterCol).append(" = ?");
sqlParams.add(parentValue);
}
if (matchedCondition != null) {
String condFilterColumn = (String) matchedCondition.get("filter_column");
String condFilterValues = (String) matchedCondition.get("filter_values");
if (condFilterColumn != null && condFilterValues != null) {
String[] values = condFilterValues.split(",");
String placeholders = Arrays.stream(values).map(v -> "?").collect(Collectors.joining(","));
sql.append(" AND ").append(condFilterColumn).append(" IN (").append(placeholders).append(")");
for (String v : values) sqlParams.add(v.trim());
}
}
// 멀티테넌시 필터 (child_table에 company_code 컬럼이 있는 경우만)
if (companyCode != null && !"*".equals(companyCode)) {
try {
Integer cnt = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM information_schema.columns WHERE table_name = ? AND column_name = 'company_code'",
Integer.class, childTable);
if (cnt != null && cnt > 0) {
sql.append(" AND company_code = ?");
sqlParams.add(companyCode);
}
} catch (Exception e) {
log.warn("company_code 컬럼 확인 실패: {}", e.getMessage());
}
}
sql.append(" ORDER BY ");
if (orderCol != null && !orderCol.isEmpty()) {
sql.append(orderCol).append(" ").append(orderDir);
} else {
sql.append(labelCol);
}
List<Map<String, Object>> options = jdbcTemplate.queryForList(sql.toString(), sqlParams.toArray());
Map<String, Object> result = new LinkedHashMap<>();
result.put("data", options);
if (matchedCondition != null) {
Map<String, Object> applied = new HashMap<>();
applied.put("condition_id", matchedCondition.get("condition_id"));
applied.put("condition_name", matchedCondition.get("condition_name"));
result.put("applied_condition", applied);
} else {
result.put("applied_condition", null);
}
return result;
}
private boolean evaluateCondition(String actualValue, String operator, String expectedValue) {
if (operator == null || actualValue == null || expectedValue == null) return false;
String actual = actualValue.toLowerCase().trim();
String expected = expectedValue.toLowerCase().trim();
return switch (operator.toUpperCase()) {
case "EQ", "=", "EQUALS" -> actual.equals(expected);
case "NEQ", "!=", "<>", "NOT_EQUALS" -> !actual.equals(expected);
case "CONTAINS", "LIKE" -> actual.contains(expected);
case "NOT_CONTAINS", "NOT_LIKE" -> !actual.contains(expected);
case "STARTS_WITH" -> actual.startsWith(expected);
case "ENDS_WITH" -> actual.endsWith(expected);
case "IN" -> Arrays.stream(expected.split(",")).map(String::trim).anyMatch(v -> v.equals(actual));
case "NOT_IN" -> Arrays.stream(expected.split(",")).map(String::trim).noneMatch(v -> v.equals(actual));
case "GT", ">" -> {
try { yield Double.parseDouble(actual) > Double.parseDouble(expected); }
catch (NumberFormatException e) { yield false; }
}
case "GTE", ">=" -> {
try { yield Double.parseDouble(actual) >= Double.parseDouble(expected); }
catch (NumberFormatException e) { yield false; }
}
case "LT", "<" -> {
try { yield Double.parseDouble(actual) < Double.parseDouble(expected); }
catch (NumberFormatException e) { yield false; }
}
case "LTE", "<=" -> {
try { yield Double.parseDouble(actual) <= Double.parseDouble(expected); }
catch (NumberFormatException e) { yield false; }
}
case "IS_NULL", "NULL" -> actual.isEmpty() || "null".equals(actual) || "undefined".equals(actual);
case "IS_NOT_NULL", "NOT_NULL" -> !actual.isEmpty() && !"null".equals(actual) && !"undefined".equals(actual);
default -> {
log.warn("알 수 없는 연산자: {}", operator);
yield false;
}
};
}
}
@@ -1,251 +0,0 @@
package com.erp.service;
import com.erp.common.BaseService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
@Service
@RequiredArgsConstructor
@Slf4j
public class CascadingHierarchyService extends BaseService {
private static final String NS = "cascadingHierarchy.";
private final CommonService commonService;
private final JdbcTemplate jdbcTemplate;
public Map<String, Object> getCascadingHierarchyGroupList(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
commonService.applyPagination(params);
int totalCount = sqlSession.selectOne(NS + "getCascadingHierarchyGroupListCnt", params);
List<Map<String, Object>> list = sqlSession.selectList(NS + "getCascadingHierarchyGroupList", params);
return commonService.buildListResponse(list, totalCount, params);
}
public Map<String, Object> getCascadingHierarchyGroupDetail(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
Map<String, Object> group = sqlSession.selectOne(NS + "getCascadingHierarchyGroupByCode", params);
if (group == null) return null;
Map<String, Object> levelParams = new HashMap<>();
levelParams.put("group_code", params.get("group_code"));
levelParams.put("company_code", group.get("company_code"));
List<Map<String, Object>> levels = sqlSession.selectList(NS + "getCascadingHierarchyLevelList", levelParams);
Map<String, Object> result = new HashMap<>(group);
result.put("levels", levels);
return result;
}
@Transactional
public Map<String, Object> insertCascadingHierarchyGroup(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
String companyCode = (String) params.get("company_code");
String userId = (String) params.getOrDefault("user_id", "system");
// Generate group code: HG_{timestamp_base36}_{count:03d}
Map<String, Object> countParams = new HashMap<>();
countParams.put("company_code", companyCode);
Number cntNum = sqlSession.selectOne(NS + "getCascadingHierarchyGroupCount", countParams);
int count = (cntNum != null ? cntNum.intValue() : 0) + 1;
String timestamp = Long.toString(System.currentTimeMillis(), 36).toUpperCase();
String suffix = timestamp.substring(Math.max(0, timestamp.length() - 4));
String groupCode = "HG_" + suffix + "_" + String.format("%03d", count);
params.put("group_code", groupCode);
params.put("created_by", userId);
if (params.get("hierarchy_type") == null) params.put("hierarchy_type", "MULTI_TABLE");
if (params.get("is_fixed_levels") == null) params.put("is_fixed_levels", "Y");
if (params.get("empty_message") == null) params.put("empty_message", "선택해주세요");
if (params.get("no_options_message") == null) params.put("no_options_message", "옵션이 없습니다");
if (params.get("loading_message") == null) params.put("loading_message", "로딩 중...");
sqlSession.insert(NS + "insertCascadingHierarchyGroup", params);
// Insert levels for MULTI_TABLE type
Object levelsObj = params.get("levels");
if ("MULTI_TABLE".equals(params.get("hierarchy_type")) && levelsObj instanceof List) {
List<?> levels = (List<?>) levelsObj;
for (Object l : levels) {
if (l instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> level = (Map<String, Object>) l;
Map<String, Object> lp = new HashMap<>(level);
lp.put("group_code", groupCode);
lp.put("company_code", companyCode);
if (lp.get("order_direction") == null) lp.put("order_direction", "ASC");
if (lp.get("is_required") == null) lp.put("is_required", "Y");
if (lp.get("is_searchable") == null) lp.put("is_searchable", "N");
if (lp.get("placeholder") == null && lp.get("level_name") != null) {
lp.put("placeholder", lp.get("level_name") + " 선택");
}
sqlSession.insert(NS + "insertCascadingHierarchyLevel", lp);
}
}
}
return params;
}
@Transactional
public Map<String, Object> updateCascadingHierarchyGroup(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
params.put("updated_by", params.getOrDefault("user_id", "system"));
Map<String, Object> existing = sqlSession.selectOne(NS + "getCascadingHierarchyGroupByCode", params);
if (existing == null) return null;
params.put("company_code", existing.get("company_code"));
sqlSession.update(NS + "updateCascadingHierarchyGroup", params);
return params;
}
@Transactional
public boolean deleteCascadingHierarchyGroup(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
Map<String, Object> existing = sqlSession.selectOne(NS + "getCascadingHierarchyGroupByCode", params);
if (existing == null) return false;
String groupCode = (String) params.get("group_code");
String companyCode = (String) existing.get("company_code");
Map<String, Object> delParams = new HashMap<>();
delParams.put("group_code", groupCode);
delParams.put("company_code", companyCode);
sqlSession.delete(NS + "deleteCascadingHierarchyLevels", delParams);
sqlSession.delete(NS + "deleteCascadingHierarchyGroup", delParams);
return true;
}
@Transactional
public Map<String, Object> addCascadingHierarchyLevel(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
String groupCode = (String) params.get("group_code");
Map<String, Object> groupParams = new HashMap<>();
groupParams.put("group_code", groupCode);
groupParams.put("company_code", params.get("company_code"));
Map<String, Object> group = sqlSession.selectOne(NS + "getCascadingHierarchyGroupByCode", groupParams);
if (group == null) return null;
params.put("company_code", group.get("company_code"));
if (params.get("order_direction") == null) params.put("order_direction", "ASC");
if (params.get("is_required") == null) params.put("is_required", "Y");
if (params.get("is_searchable") == null) params.put("is_searchable", "N");
if (params.get("placeholder") == null && params.get("level_name") != null) {
params.put("placeholder", params.get("level_name") + " 선택");
}
sqlSession.insert(NS + "insertCascadingHierarchyLevel", params);
return params;
}
@Transactional
public Map<String, Object> updateCascadingHierarchyLevel(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
Map<String, Object> existing = sqlSession.selectOne(NS + "getCascadingHierarchyLevelInfo", params);
if (existing == null) return null;
sqlSession.update(NS + "updateCascadingHierarchyLevel", params);
return params;
}
@Transactional
public boolean deleteCascadingHierarchyLevel(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
Map<String, Object> existing = sqlSession.selectOne(NS + "getCascadingHierarchyLevelInfo", params);
if (existing == null) return false;
sqlSession.delete(NS + "deleteCascadingHierarchyLevel", params);
return true;
}
public Map<String, Object> getLevelOptions(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
String companyCode = (String) params.get("company_code");
Map<String, Object> level = sqlSession.selectOne(NS + "getCascadingHierarchyLevelForOptions", params);
if (level == null) return null;
String tableName = sanitizeIdentifier((String) level.get("table_name"));
String valueColumn = sanitizeIdentifier((String) level.get("value_column"));
String labelColumn = sanitizeIdentifier((String) level.get("label_column"));
StringBuilder sql = new StringBuilder();
sql.append("SELECT ").append(valueColumn).append(" AS value, ")
.append(labelColumn).append(" AS label")
.append(" FROM ").append(tableName)
.append(" WHERE 1=1");
List<Object> sqlParams = new ArrayList<>();
// Parent value filter (level 2+)
Object parentValue = params.get("parent_value");
Object parentKeyColumn = level.get("parent_key_column");
if (parentKeyColumn != null && !parentKeyColumn.toString().isEmpty() && parentValue != null) {
sql.append(" AND ").append(sanitizeIdentifier(parentKeyColumn.toString())).append(" = ?");
sqlParams.add(parentValue);
}
// Fixed filter
Object filterColumn = level.get("filter_column");
Object filterValue = level.get("filter_value");
if (filterColumn != null && !filterColumn.toString().isEmpty()
&& filterValue != null && !filterValue.toString().isEmpty()) {
sql.append(" AND ").append(sanitizeIdentifier(filterColumn.toString())).append(" = ?");
sqlParams.add(filterValue.toString());
}
// Multi-tenancy
if (!"*".equals(companyCode) && hasColumn(tableName, "company_code")) {
sql.append(" AND company_code = ?");
sqlParams.add(companyCode);
}
// Order
Object orderColumn = level.get("order_column");
if (orderColumn != null && !orderColumn.toString().isEmpty()) {
Object orderDir = level.get("order_direction");
String dir = (orderDir != null && "DESC".equalsIgnoreCase(orderDir.toString())) ? "DESC" : "ASC";
sql.append(" ORDER BY ").append(sanitizeIdentifier(orderColumn.toString())).append(" ").append(dir);
} else {
sql.append(" ORDER BY ").append(labelColumn);
}
List<Map<String, Object>> options = jdbcTemplate.queryForList(sql.toString(), sqlParams.toArray());
Map<String, Object> levelInfo = new LinkedHashMap<>();
levelInfo.put("level_id", level.get("level_id"));
levelInfo.put("level_name", level.get("level_name"));
levelInfo.put("placeholder", level.get("placeholder"));
levelInfo.put("is_required", level.get("is_required"));
levelInfo.put("is_searchable", level.get("is_searchable"));
Map<String, Object> result = new HashMap<>();
result.put("data", options);
result.put("level_info", levelInfo);
return result;
}
private String sanitizeIdentifier(String identifier) {
if (identifier == null || !identifier.matches("[a-zA-Z0-9_.]+")) {
throw new IllegalArgumentException("Invalid SQL identifier: " + identifier);
}
return identifier;
}
private boolean hasColumn(String tableName, String columnName) {
try {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM information_schema.columns WHERE table_name = ? AND column_name = ?",
Integer.class, tableName, columnName);
return count != null && count > 0;
} catch (Exception e) {
return false;
}
}
}
@@ -1,175 +0,0 @@
package com.erp.service;
import com.erp.common.BaseService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
@Service
@RequiredArgsConstructor
@Slf4j
public class CascadingMutualExclusionService extends BaseService {
private static final String NS = "cascadingMutualExclusion.";
private final CommonService commonService;
private final JdbcTemplate jdbcTemplate;
public Map<String, Object> getCascadingMutualExclusionList(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
commonService.applyPagination(params);
int totalCount = sqlSession.selectOne(NS + "getCascadingMutualExclusionListCnt", params);
List<Map<String, Object>> list = sqlSession.selectList(NS + "getCascadingMutualExclusionList", params);
return commonService.buildListResponse(list, totalCount, params);
}
public Map<String, Object> getCascadingMutualExclusionInfo(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
return sqlSession.selectOne(NS + "getCascadingMutualExclusionInfo", params);
}
@Transactional
public Map<String, Object> insertCascadingMutualExclusion(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
if (params.get("exclusion_type") == null) params.put("exclusion_type", "SAME_VALUE");
if (params.get("error_message") == null) params.put("error_message", "동일한 값을 선택할 수 없습니다");
// 배제 코드 자동 생성: EX_XXXX_NNN
String companyCode = (String) params.get("company_code");
Map<String, Object> countParams = new LinkedHashMap<>();
countParams.put("company_code", companyCode);
int count = sqlSession.selectOne(NS + "getCascadingMutualExclusionCount", countParams);
String ts = Long.toString(System.currentTimeMillis(), 36).toUpperCase();
ts = ts.substring(Math.max(0, ts.length() - 4));
params.put("exclusion_code", String.format("EX_%s_%03d", ts, count + 1));
sqlSession.insert(NS + "insertCascadingMutualExclusion", params);
return params;
}
@Transactional
public Map<String, Object> updateCascadingMutualExclusion(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
sqlSession.update(NS + "updateCascadingMutualExclusion", params);
return params;
}
@Transactional
public Map<String, Object> deleteCascadingMutualExclusion(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
sqlSession.delete(NS + "deleteCascadingMutualExclusion", params);
return params;
}
/**
* 상호 배제 검증: 선택한 값들 간 충돌 여부 확인 (SAME_VALUE 타입)
*/
public Map<String, Object> validateCascadingMutualExclusion(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
Map<String, Object> exclusion = sqlSession.selectOne(NS + "getCascadingMutualExclusionByCode", params);
if (exclusion == null) throw new NoSuchElementException("상호 배제 규칙을 찾을 수 없습니다.");
@SuppressWarnings("unchecked")
Map<String, Object> fieldValues = (Map<String, Object>) params.getOrDefault("field_values", Collections.emptyMap());
String fieldNamesStr = (String) exclusion.get("field_names");
String[] fields = fieldNamesStr != null ? fieldNamesStr.split(",") : new String[0];
List<String> values = new ArrayList<>();
for (String field : fields) {
Object v = fieldValues.get(field.trim());
if (v != null) values.add(v.toString());
}
boolean isValid = true;
String errorMessage = null;
List<String> conflictingFields = new ArrayList<>();
String exclusionType = (String) exclusion.getOrDefault("exclusion_type", "SAME_VALUE");
if ("SAME_VALUE".equals(exclusionType)) {
Set<String> seen = new LinkedHashSet<>();
boolean hasDuplicate = false;
for (String v : values) {
if (!seen.add(v)) { hasDuplicate = true; break; }
}
if (hasDuplicate) {
isValid = false;
errorMessage = (String) exclusion.get("error_message");
Map<String, List<String>> valueCounts = new LinkedHashMap<>();
for (String field : fields) {
Object v = fieldValues.get(field.trim());
if (v != null) {
valueCounts.computeIfAbsent(v.toString(), k -> new ArrayList<>()).add(field.trim());
}
}
for (List<String> fl : valueCounts.values()) {
if (fl.size() > 1) { conflictingFields = fl; break; }
}
}
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("is_valid", isValid);
result.put("error_message", isValid ? null : errorMessage);
result.put("conflicting_fields", conflictingFields);
return result;
}
/**
* 배제 옵션 조회: source_table에서 이미 선택된 값을 제외한 목록 반환
*/
public List<Map<String, Object>> getExcludedOptions(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
String companyCode = (String) params.get("company_code");
Map<String, Object> exclusion = sqlSession.selectOne(NS + "getCascadingMutualExclusionByCode", params);
if (exclusion == null) throw new NoSuchElementException("상호 배제 규칙을 찾을 수 없습니다.");
String sourceTable = (String) exclusion.get("source_table");
String valueColumn = (String) exclusion.get("value_column");
String labelColumn = (String) exclusion.get("label_column");
if (labelColumn == null || labelColumn.isEmpty()) labelColumn = valueColumn;
boolean hasCompanyCode = hasColumn(sourceTable, "company_code");
List<Object> queryParams = new ArrayList<>();
StringBuilder sql = new StringBuilder();
sql.append("SELECT ")
.append(valueColumn).append(" AS value, ")
.append(labelColumn).append(" AS label")
.append(" FROM ").append(sourceTable)
.append(" WHERE 1=1");
if (hasCompanyCode && !"*".equals(companyCode)) {
sql.append(" AND company_code = ?");
queryParams.add(companyCode);
}
Object selectedValuesParam = params.get("selected_values");
if (selectedValuesParam != null) {
List<String> excludeValues = new ArrayList<>();
for (String v : selectedValuesParam.toString().split(",")) {
String trimmed = v.trim();
if (!trimmed.isEmpty()) excludeValues.add(trimmed);
}
if (!excludeValues.isEmpty()) {
String placeholders = String.join(", ", Collections.nCopies(excludeValues.size(), "?"));
sql.append(" AND ").append(valueColumn).append(" NOT IN (").append(placeholders).append(")");
queryParams.addAll(excludeValues);
}
}
sql.append(" ORDER BY ").append(labelColumn);
return jdbcTemplate.queryForList(sql.toString(), queryParams.toArray());
}
private boolean hasColumn(String tableName, String columnName) {
String sql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = ? AND column_name = ?";
Integer count = jdbcTemplate.queryForObject(sql, Integer.class, tableName, columnName);
return count != null && count > 0;
}
}
@@ -1,193 +0,0 @@
package com.erp.service;
import com.erp.common.BaseService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
@Service
@Slf4j
public class CascadingRelationService extends BaseService {
private static final String NS = "cascadingRelation.";
@Autowired
private CommonService commonService;
@Autowired
private JdbcTemplate jdbcTemplate;
public Map<String, Object> getCascadingRelationList(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
commonService.applyPagination(params);
int totalCount = sqlSession.selectOne(NS + "getCascadingRelationListCnt", params);
List<Map<String, Object>> list = sqlSession.selectList(NS + "getCascadingRelationList", params);
return commonService.buildListResponse(list, totalCount, params);
}
public Map<String, Object> getCascadingRelationInfo(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
return sqlSession.selectOne(NS + "getCascadingRelationInfo", params);
}
public Map<String, Object> getCascadingRelationByCode(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
return sqlSession.selectOne(NS + "getCascadingRelationByCode", params);
}
@Transactional
public Map<String, Object> insertCascadingRelation(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
if (params.get("empty_parent_message") == null)
params.put("empty_parent_message", "상위 항목을 먼저 선택하세요");
if (params.get("no_options_message") == null)
params.put("no_options_message", "선택 가능한 항목이 없습니다");
if (params.get("loading_message") == null)
params.put("loading_message", "로딩 중...");
if (params.get("child_order_direction") == null)
params.put("child_order_direction", "ASC");
Object clearOnParentChange = params.get("clear_on_parent_change");
if (clearOnParentChange == null) {
params.put("clear_on_parent_change", "Y");
} else if (clearOnParentChange instanceof Boolean) {
params.put("clear_on_parent_change", Boolean.TRUE.equals(clearOnParentChange) ? "Y" : "N");
}
params.put("is_active", "Y");
sqlSession.insert(NS + "insertCascadingRelation", params);
return params;
}
@Transactional
public Map<String, Object> updateCascadingRelation(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
Object isActive = params.get("is_active");
if (isActive instanceof Boolean) {
params.put("is_active", Boolean.TRUE.equals(isActive) ? "Y" : "N");
}
Object clearOnParentChange = params.get("clear_on_parent_change");
if (clearOnParentChange instanceof Boolean) {
params.put("clear_on_parent_change", Boolean.TRUE.equals(clearOnParentChange) ? "Y" : "N");
}
sqlSession.update(NS + "updateCascadingRelation", params);
return params;
}
@Transactional
public Map<String, Object> deleteCascadingRelation(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
sqlSession.update(NS + "deleteCascadingRelation", params);
return params;
}
/**
* 부모 옵션 조회: relation_code로 관계 조회 후 parent_table에서 동적 쿼리
*/
public List<Map<String, Object>> getParentOptions(Map<String, Object> params) {
String companyCode = (String) params.get("company_code");
Map<String, Object> relation = sqlSession.selectOne(NS + "getCascadingRelationByCode", params);
if (relation == null) {
throw new NoSuchElementException("연쇄 관계를 찾을 수 없습니다.");
}
String parentTable = (String) relation.get("parent_table");
String parentValueColumn = (String) relation.get("parent_value_column");
String parentLabelColumn = (String) relation.get("parent_label_column");
if (parentLabelColumn == null || parentLabelColumn.isEmpty()) {
parentLabelColumn = parentValueColumn;
}
boolean hasCompanyCode = hasColumn(parentTable, "company_code");
boolean hasStatus = hasColumn(parentTable, "status");
List<Object> queryParams = new ArrayList<>();
StringBuilder sql = new StringBuilder();
sql.append("SELECT ")
.append(parentValueColumn).append(" AS value, ")
.append(parentLabelColumn).append(" AS label")
.append(" FROM ").append(parentTable)
.append(" WHERE 1=1");
if (hasCompanyCode && !"*".equals(companyCode)) {
sql.append(" AND company_code = ?");
queryParams.add(companyCode);
}
if (hasStatus) {
sql.append(" AND (status IS NULL OR status != 'N')");
}
sql.append(" ORDER BY ").append(parentLabelColumn).append(" ASC");
return jdbcTemplate.queryForList(sql.toString(), queryParams.toArray());
}
/**
* 연쇄 옵션 조회: relation_code로 관계 조회 후 child_table에서 동적 쿼리
* parentValue(단일) 또는 parentValues(콤마 구분 다중) 지원
*/
public List<Map<String, Object>> getCascadingOptions(Map<String, Object> params) {
String companyCode = (String) params.get("company_code");
Object parentValueParam = params.get("parent_value");
Object parentValuesParam = params.get("parent_values");
List<String> parentValueArray = new ArrayList<>();
if (parentValuesParam != null) {
for (String v : parentValuesParam.toString().split(",")) {
String trimmed = v.trim();
if (!trimmed.isEmpty()) parentValueArray.add(trimmed);
}
} else if (parentValueParam != null) {
parentValueArray.add(parentValueParam.toString());
}
if (parentValueArray.isEmpty()) {
return Collections.emptyList();
}
Map<String, Object> relation = sqlSession.selectOne(NS + "getCascadingRelationByCode", params);
if (relation == null) {
throw new NoSuchElementException("연쇄 관계를 찾을 수 없습니다.");
}
String childTable = (String) relation.get("child_table");
String childFilterColumn = (String) relation.get("child_filter_column");
String childValueColumn = (String) relation.get("child_value_column");
String childLabelColumn = (String) relation.get("child_label_column");
String childOrderColumn = (String) relation.get("child_order_column");
String childOrderDir = (String) relation.get("child_order_direction");
if (childOrderDir == null || childOrderDir.isEmpty()) childOrderDir = "ASC";
boolean hasCompanyCode = hasColumn(childTable, "company_code");
List<Object> queryParams = new ArrayList<>(parentValueArray);
String placeholders = String.join(", ", Collections.nCopies(parentValueArray.size(), "?"));
StringBuilder sql = new StringBuilder();
sql.append("SELECT DISTINCT ")
.append(childValueColumn).append(" AS value, ")
.append(childLabelColumn).append(" AS label, ")
.append(childFilterColumn).append(" AS parent_value")
.append(" FROM ").append(childTable)
.append(" WHERE ").append(childFilterColumn).append(" IN (").append(placeholders).append(")");
if (hasCompanyCode && !"*".equals(companyCode)) {
sql.append(" AND company_code = ?");
queryParams.add(companyCode);
}
if (childOrderColumn != null && !childOrderColumn.isEmpty()) {
sql.append(" ORDER BY ").append(childOrderColumn).append(" ").append(childOrderDir);
} else {
sql.append(" ORDER BY ").append(childLabelColumn).append(" ASC");
}
return jdbcTemplate.queryForList(sql.toString(), queryParams.toArray());
}
private boolean hasColumn(String tableName, String columnName) {
String sql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = ? AND column_name = ?";
Integer count = jdbcTemplate.queryForObject(sql, Integer.class, tableName, columnName);
return count != null && count > 0;
}
}
@@ -1,415 +0,0 @@
package com.erp.service;
import com.erp.common.BaseService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
@Service
@Slf4j
public class CategoryTreeService extends BaseService {
private static final String NS = "categoryTree.";
private Long toLong(Object val) {
if (val == null) return null;
if (val instanceof Number n) return n.longValue();
try { return Long.parseLong(val.toString()); } catch (Exception e) { return null; }
}
/**
* 카테고리 트리 조회 (플랫 리스트 → 트리 변환)
*/
public List<Map<String, Object>> getCategoryTreeList(String companyCode, String tableName, String columnName) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("table_name", tableName);
params.put("column_name", columnName);
List<Map<String, Object>> flatList = sqlSession.selectList(NS + "getCategoryTreeList", params);
return buildTree(flatList);
}
/**
* 카테고리 플랫 리스트 조회
*/
public List<Map<String, Object>> getCategoryTreeFlatList(String companyCode, String tableName, String columnName) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("table_name", tableName);
params.put("column_name", columnName);
return sqlSession.selectList(NS + "getCategoryTreeList", params);
}
/**
* 카테고리 값 단건 조회
*/
public Map<String, Object> getCategoryTreeInfo(String companyCode, int valueId) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("value_id", valueId);
return sqlSession.selectOne(NS + "getCategoryTreeInfo", params);
}
/**
* 카테고리 값 생성
*/
@Transactional
public Map<String, Object> insertCategoryTree(Map<String, Object> body, String companyCode, String createdBy) {
String tableName = (String) body.get("table_name");
String columnName = (String) body.get("column_name");
String valueCode = (String) body.get("value_code");
String valueLabel = (String) body.get("value_label");
Object valueOrderRaw = body.get("value_order");
int valueOrder = valueOrderRaw != null ? ((Number) valueOrderRaw).intValue() : 0;
Object parentValueIdRaw = body.get("parent_value_id");
// depth / path 계산
int depth = 1;
String path = valueLabel;
if (parentValueIdRaw != null) {
Map<String, Object> parentParams = new HashMap<>();
parentParams.put("company_code", companyCode);
parentParams.put("value_id", ((Number) parentValueIdRaw).intValue());
Map<String, Object> parent = sqlSession.selectOne(NS + "getCategoryTreeInfo", parentParams);
if (parent != null) {
depth = ((Number) parent.get("depth")).intValue() + 1;
if (depth > 3) {
throw new IllegalArgumentException("카테고리는 최대 3단계까지만 가능합니다");
}
String parentPath = (String) parent.get("path");
path = parentPath != null ? parentPath + "/" + valueLabel : valueLabel;
}
}
Map<String, Object> params = new HashMap<>();
params.put("table_name", tableName);
params.put("column_name", columnName);
params.put("value_code", valueCode);
params.put("value_label", valueLabel);
params.put("value_order", valueOrder);
params.put("parent_value_id", parentValueIdRaw);
params.put("depth", depth);
params.put("path", path);
params.put("description", body.get("description"));
params.put("color", body.get("color"));
params.put("icon", body.get("icon"));
Object isActiveRaw = body.get("is_active");
Object isDefaultRaw = body.get("is_default");
params.put("is_active", isActiveRaw != null ? isActiveRaw : true);
params.put("is_default", isDefaultRaw != null ? isDefaultRaw : false);
params.put("company_code", companyCode);
params.put("created_by", createdBy);
sqlSession.insert(NS + "insertCategoryTree", params);
// useGeneratedKeys → params.get("value_id") 에 생성된 ID 저장
Map<String, Object> fetchParams = new HashMap<>();
fetchParams.put("company_code", companyCode);
fetchParams.put("value_id", params.get("value_id"));
return sqlSession.selectOne(NS + "getCategoryTreeInfo", fetchParams);
}
/**
* 카테고리 값 수정
*/
@Transactional
public Map<String, Object> updateCategoryTree(String companyCode, int valueId,
Map<String, Object> body, String updatedBy) {
Map<String, Object> currentParams = new HashMap<>();
currentParams.put("company_code", companyCode);
currentParams.put("value_id", valueId);
Map<String, Object> current = sqlSession.selectOne(NS + "getCategoryTreeInfo", currentParams);
if (current == null) return null;
String currentLabel = (String) current.get("value_label");
int currentDepth = ((Number) current.get("depth")).intValue();
String currentPath = (String) current.get("path");
Object currentParentId = current.get("parent_value_id");
String newLabel = body.containsKey("value_label") ? (String) body.get("value_label") : currentLabel;
int newDepth = currentDepth;
String newPath = currentPath;
Object newParentId = body.containsKey("parent_value_id") ? body.get("parent_value_id") : currentParentId;
boolean labelChanged = body.containsKey("value_label")
&& !Objects.equals(newLabel, currentLabel);
boolean parentChanged = body.containsKey("parent_value_id")
&& !Objects.equals(toLong(body.get("parent_value_id")), toLong(currentParentId));
if (parentChanged) {
if (body.get("parent_value_id") != null) {
Map<String, Object> newParentParams = new HashMap<>();
newParentParams.put("company_code", companyCode);
newParentParams.put("value_id", ((Number) body.get("parent_value_id")).intValue());
Map<String, Object> newParent = sqlSession.selectOne(NS + "getCategoryTreeInfo", newParentParams);
if (newParent != null) {
newDepth = ((Number) newParent.get("depth")).intValue() + 1;
if (newDepth > 3) {
throw new IllegalArgumentException("카테고리는 최대 3단계까지만 가능합니다");
}
String parentPath = (String) newParent.get("path");
newPath = parentPath != null ? parentPath + "/" + newLabel : newLabel;
}
} else {
newDepth = 1;
newPath = newLabel;
}
} else if (labelChanged) {
if (currentParentId != null) {
Map<String, Object> parentParams = new HashMap<>();
parentParams.put("company_code", companyCode);
parentParams.put("value_id", ((Number) currentParentId).intValue());
Map<String, Object> parent = sqlSession.selectOne(NS + "getCategoryTreeInfo", parentParams);
String parentPath = parent != null ? (String) parent.get("path") : null;
newPath = parentPath != null ? parentPath + "/" + newLabel : newLabel;
} else {
newPath = newLabel;
}
}
Map<String, Object> updateParams = new HashMap<>();
updateParams.put("company_code", companyCode);
updateParams.put("value_id", valueId);
updateParams.put("value_code", body.get("value_code"));
updateParams.put("value_label", body.containsKey("value_label") ? newLabel : null);
updateParams.put("value_order", body.get("value_order"));
updateParams.put("parent_value_id", newParentId);
updateParams.put("depth", newDepth);
updateParams.put("path", newPath);
updateParams.put("description", body.get("description"));
updateParams.put("color", body.get("color"));
updateParams.put("icon", body.get("icon"));
updateParams.put("is_active", body.get("is_active"));
updateParams.put("is_default", body.get("is_default"));
updateParams.put("updated_by", updatedBy);
int affected = sqlSession.update(NS + "updateCategoryTree", updateParams);
if (affected == 0) return null;
if (labelChanged || parentChanged) {
updateChildrenPaths(companyCode, valueId, newPath != null ? newPath : "");
}
Map<String, Object> fetchParams = new HashMap<>();
fetchParams.put("company_code", companyCode);
fetchParams.put("value_id", valueId);
return sqlSession.selectOne(NS + "getCategoryTreeInfo", fetchParams);
}
/**
* 카테고리 값 삭제 가능 여부 사전 확인
*/
public Map<String, Object> checkCanDelete(String companyCode, int valueId) {
Map<String, Object> value = getCategoryTreeInfo(companyCode, valueId);
if (value == null) {
Map<String, Object> res = new LinkedHashMap<>();
res.put("can_delete", false);
res.put("reason", "카테고리 값을 찾을 수 없습니다");
return res;
}
Map<String, Object> childParams = new HashMap<>();
childParams.put("value_id", valueId);
childParams.put("company_code", companyCode);
Integer childCountObj = sqlSession.selectOne(NS + "getCategoryTreeChildrenCnt", childParams);
int childCount = childCountObj != null ? childCountObj : 0;
if (childCount > 0) {
Map<String, Object> res = new LinkedHashMap<>();
res.put("can_delete", false);
res.put("reason", "하위 카테고리가 " + childCount + "개 존재합니다. 하위 카테고리를 먼저 삭제해주세요.");
return res;
}
Map<String, Object> usage = checkCategoryValueInUse(companyCode, value);
boolean inUse = Boolean.TRUE.equals(usage.get("in_use"));
if (inUse) {
int count = ((Number) usage.get("count")).intValue();
Map<String, Object> res = new LinkedHashMap<>();
res.put("can_delete", false);
res.put("reason", "이 카테고리 값(" + value.get("value_label") + ")은 " + count + "건의 데이터에서 사용 중이므로 삭제할 수 없습니다.");
return res;
}
Map<String, Object> res = new LinkedHashMap<>();
res.put("can_delete", true);
return res;
}
/**
* 카테고리 값 삭제 (자식·사용 여부 검증 후 삭제)
*/
@Transactional
public boolean deleteCategoryTree(String companyCode, int valueId) {
Map<String, Object> value = getCategoryTreeInfo(companyCode, valueId);
if (value == null) return false;
// 1. 자식 존재 여부
Map<String, Object> childParams = new HashMap<>();
childParams.put("value_id", valueId);
childParams.put("company_code", companyCode);
Integer childCountObj = sqlSession.selectOne(NS + "getCategoryTreeChildrenCnt", childParams);
int childCount = childCountObj != null ? childCountObj : 0;
if (childCount > 0) {
throw new IllegalStateException(
"VALIDATION:하위 카테고리가 " + childCount + "개 존재합니다. 하위 카테고리를 먼저 삭제해주세요.");
}
// 2. 실제 데이터 사용 여부
Map<String, Object> usage = checkCategoryValueInUse(companyCode, value);
boolean inUse = Boolean.TRUE.equals(usage.get("in_use"));
if (inUse) {
int count = ((Number) usage.get("count")).intValue();
throw new IllegalStateException(
"VALIDATION:이 카테고리 값(" + value.get("value_label") + ")은 "
+ value.get("table_name") + " 테이블에서 " + count + "건의 데이터가 사용 중이므로 삭제할 수 없습니다.");
}
// 3. 삭제
Map<String, Object> deleteParams = new HashMap<>();
deleteParams.put("company_code", companyCode);
deleteParams.put("value_id", valueId);
return sqlSession.delete(NS + "deleteCategoryTree", deleteParams) > 0;
}
/**
* 테이블의 카테고리 컬럼 목록 조회
*/
public List<Map<String, Object>> getCategoryTreeColumnList(String companyCode, String tableName) {
Map<String, Object> params = new HashMap<>();
params.put("table_name", tableName);
params.put("company_code", companyCode);
return sqlSession.selectList(NS + "getCategoryTreeColumnList", params);
}
/**
* 전체 카테고리 키 목록 조회 (모든 테이블.컬럼 조합)
*/
public List<Map<String, Object>> getCategoryTreeKeyList(String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
return sqlSession.selectList(NS + "getCategoryTreeKeyList", params);
}
// ─── private helpers ────────────────────────────────────────────────────────
/**
* 플랫 리스트 → 트리 구조 변환
*/
private List<Map<String, Object>> buildTree(List<Map<String, Object>> flatList) {
Map<Object, Map<String, Object>> map = new LinkedHashMap<>();
List<Map<String, Object>> roots = new ArrayList<>();
for (Map<String, Object> item : flatList) {
Map<String, Object> node = new LinkedHashMap<>(item);
node.put("children", new ArrayList<>());
map.put(item.get("value_id"), node);
}
for (Map<String, Object> item : flatList) {
Object parentId = item.get("parent_value_id");
Map<String, Object> node = map.get(item.get("value_id"));
if (parentId != null && map.containsKey(parentId)) {
@SuppressWarnings("unchecked")
List<Map<String, Object>> children =
(List<Map<String, Object>>) map.get(parentId).get("children");
children.add(node);
} else {
roots.add(node);
}
}
return roots;
}
/**
* 하위 항목들의 path 재귀 업데이트
*/
private void updateChildrenPaths(String companyCode, int parentValueId, String parentPath) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("parent_value_id", parentValueId);
List<Map<String, Object>> children = sqlSession.selectList(NS + "getCategoryTreeChildrenList", params);
for (Map<String, Object> child : children) {
String valueLabel = (String) child.get("value_label");
String newPath = parentPath + "/" + valueLabel;
Map<String, Object> updateParams = new HashMap<>();
updateParams.put("value_id", child.get("value_id"));
updateParams.put("path", newPath);
sqlSession.update(NS + "updateCategoryTreeChildPath", updateParams);
int childId = ((Number) child.get("value_id")).intValue();
updateChildrenPaths(companyCode, childId, newPath);
}
}
/**
* 카테고리 값이 실제 데이터 테이블에서 사용 중인지 확인
* 오류 발생 시 무시하고 삭제 허용 (Node.js 동일 동작)
*/
private Map<String, Object> checkCategoryValueInUse(String companyCode, Map<String, Object> value) {
String tableName = (String) value.get("table_name");
String columnName = (String) value.get("column_name");
String valueCode = (String) value.get("value_code");
Map<String, Object> notInUse = Map.of("in_use", false, "count", 0);
try {
// 1. 테이블 존재 확인
Map<String, Object> tableParams = new HashMap<>();
tableParams.put("table_name", tableName);
Integer teObj = sqlSession.selectOne(NS + "checkTableExists", tableParams);
if (teObj == null || teObj == 0) return notInUse;
// 2. 컬럼 존재 확인
Map<String, Object> colParams = new HashMap<>();
colParams.put("table_name", tableName);
colParams.put("column_name", columnName);
Integer ceObj = sqlSession.selectOne(NS + "checkColumnExists", colParams);
if (ceObj == null || ceObj == 0) return notInUse;
// 3. company_code 컬럼 존재 확인
Map<String, Object> companyColParams = new HashMap<>();
companyColParams.put("table_name", tableName);
companyColParams.put("column_name", "company_code");
Integer ccObj = sqlSession.selectOne(NS + "checkColumnExists", companyColParams);
boolean hasCompanyCode = ccObj != null && ccObj > 0;
// 4. 사용 건수 조회
int count;
if (hasCompanyCode && !"*".equals(companyCode)) {
Map<String, Object> countParams = new HashMap<>();
countParams.put("table_name", tableName);
countParams.put("column_name", columnName);
countParams.put("company_code", companyCode);
countParams.put("value_code", valueCode);
Integer cntObj = sqlSession.selectOne(NS + "countCategoryUsageWithCompany", countParams);
count = cntObj != null ? cntObj : 0;
} else {
Map<String, Object> countParams = new HashMap<>();
countParams.put("table_name", tableName);
countParams.put("column_name", columnName);
countParams.put("value_code", valueCode);
Integer cntObj = sqlSession.selectOne(NS + "countCategoryUsage", countParams);
count = cntObj != null ? cntObj : 0;
}
Map<String, Object> result = new HashMap<>();
result.put("in_use", count > 0);
result.put("count", count);
return result;
} catch (Exception e) {
log.warn("카테고리 사용 여부 확인 중 오류 (무시하고 삭제 허용): {}", e.getMessage());
return notInUse;
}
}
}
@@ -1,270 +0,0 @@
package com.erp.service;
import com.erp.common.BaseService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.stream.Collectors;
@Service
@Slf4j
public class CategoryValueCascadingService extends BaseService {
private static final String NS = "categoryValueCascading.";
@Autowired
private CommonService commonService;
@Autowired
private JdbcTemplate jdbcTemplate;
public Map<String, Object> getCategoryValueCascadingGroupList(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
commonService.applyPagination(params);
int totalCount = sqlSession.selectOne(NS + "getCategoryValueCascadingGroupListCnt", params);
List<Map<String, Object>> list = sqlSession.selectList(NS + "getCategoryValueCascadingGroupList", params);
return commonService.buildListResponse(list, totalCount, params);
}
public Map<String, Object> getCategoryValueCascadingGroupInfo(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
Map<String, Object> group = sqlSession.selectOne(NS + "getCategoryValueCascadingGroupInfo", params);
if (group == null) return null;
List<Map<String, Object>> mappings = sqlSession.selectList(NS + "getCategoryValueCascadingMappingsByGroupId", params);
Map<String, List<Map<String, Object>>> mappingsByParent = new LinkedHashMap<>();
for (Map<String, Object> m : mappings) {
String parentKey = String.valueOf(m.get("parent_value_code"));
mappingsByParent.computeIfAbsent(parentKey, k -> new ArrayList<>()).add(Map.of(
"child_value_code", m.getOrDefault("child_value_code", ""),
"child_value_label", m.getOrDefault("child_value_label", ""),
"display_order", m.getOrDefault("display_order", 0)
));
}
Map<String, Object> result = new LinkedHashMap<>(group);
result.put("mappings", mappings);
result.put("mappings_by_parent", mappingsByParent);
return result;
}
public Map<String, Object> getCategoryValueCascadingGroupByCode(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
return sqlSession.selectOne(NS + "getCategoryValueCascadingGroupByCode", params);
}
@Transactional
public Map<String, Object> insertCategoryValueCascadingGroup(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
sqlSession.insert(NS + "insertCategoryValueCascadingGroup", params);
return params;
}
@Transactional
public Map<String, Object> updateCategoryValueCascadingGroup(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
sqlSession.update(NS + "updateCategoryValueCascadingGroup", params);
return params;
}
@Transactional
public Map<String, Object> deleteCategoryValueCascadingGroup(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
sqlSession.update(NS + "deleteCategoryValueCascadingGroup", params);
return params;
}
@Transactional
@SuppressWarnings("unchecked")
public Map<String, Object> saveCategoryValueCascadingMappings(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
String companyCode = (String) params.get("company_code");
Object groupId = params.get("group_id");
sqlSession.delete(NS + "deleteCategoryValueCascadingMappingsByGroupId", params);
int savedCount = 0;
Object mappingsObj = params.get("mappings");
if (mappingsObj instanceof List<?>) {
List<Map<String, Object>> mappings = (List<Map<String, Object>>) mappingsObj;
for (Map<String, Object> mapping : mappings) {
Map<String, Object> mappingParams = new HashMap<>(mapping);
mappingParams.put("group_id", groupId);
mappingParams.put("company_code", companyCode);
sqlSession.insert(NS + "insertCategoryValueCascadingMapping", mappingParams);
savedCount++;
}
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("saved_count", savedCount);
result.put("group_id", groupId);
return result;
}
public Map<String, Object> getCategoryValueCascadingParentOptions(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
String companyCode = (String) params.get("company_code");
Map<String, Object> group = sqlSession.selectOne(NS + "getCategoryValueCascadingGroupByCode", params);
if (group == null) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("data", Collections.emptyList());
return result;
}
String tableName = String.valueOf(group.get("parent_table_name"));
String columnName = String.valueOf(group.get("parent_column_name"));
Object menuObjid = group.get("parent_menu_objid");
StringBuilder sql = new StringBuilder(
"SELECT value_code as value, value_label as label, value_order as display_order" +
" FROM category_values WHERE table_name = ? AND column_name = ? AND is_active = true");
List<Object> sqlParams = new ArrayList<>(Arrays.asList(tableName, columnName));
if (menuObjid != null) {
sql.append(" AND menu_objid = ?");
sqlParams.add(menuObjid);
}
if (companyCode != null && !"*".equals(companyCode)) {
sql.append(" AND (company_code = ? OR company_code = '*')");
sqlParams.add(companyCode);
}
sql.append(" ORDER BY value_order, value_label");
List<Map<String, Object>> options = jdbcTemplate.queryForList(sql.toString(), sqlParams.toArray());
Map<String, Object> result = new LinkedHashMap<>();
result.put("data", options);
return result;
}
public Map<String, Object> getCategoryValueCascadingChildOptions(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
String companyCode = (String) params.get("company_code");
Map<String, Object> group = sqlSession.selectOne(NS + "getCategoryValueCascadingGroupByCode", params);
if (group == null) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("data", Collections.emptyList());
return result;
}
String tableName = String.valueOf(group.get("child_table_name"));
String columnName = String.valueOf(group.get("child_column_name"));
Object menuObjid = group.get("child_menu_objid");
StringBuilder sql = new StringBuilder(
"SELECT value_code as value, value_label as label, value_order as display_order" +
" FROM category_values WHERE table_name = ? AND column_name = ? AND is_active = true");
List<Object> sqlParams = new ArrayList<>(Arrays.asList(tableName, columnName));
if (menuObjid != null) {
sql.append(" AND menu_objid = ?");
sqlParams.add(menuObjid);
}
if (companyCode != null && !"*".equals(companyCode)) {
sql.append(" AND (company_code = ? OR company_code = '*')");
sqlParams.add(companyCode);
}
sql.append(" ORDER BY value_order, value_label");
List<Map<String, Object>> options = jdbcTemplate.queryForList(sql.toString(), sqlParams.toArray());
Map<String, Object> result = new LinkedHashMap<>();
result.put("data", options);
return result;
}
public Map<String, Object> getCategoryValueCascadingOptions(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
String parentValuesStr = params.get("parent_values") != null ? String.valueOf(params.get("parent_values")) : null;
String parentValue = params.get("parent_value") != null ? String.valueOf(params.get("parent_value")) : null;
List<String> parentValueArray = new ArrayList<>();
if (parentValuesStr != null && !parentValuesStr.isEmpty()) {
for (String v : parentValuesStr.split(",")) {
String trimmed = v.trim();
if (!trimmed.isEmpty()) parentValueArray.add(trimmed);
}
} else if (parentValue != null && !parentValue.isEmpty()) {
parentValueArray.add(parentValue);
}
if (parentValueArray.isEmpty()) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("data", Collections.emptyList());
return result;
}
Map<String, Object> group = sqlSession.selectOne(NS + "getCategoryValueCascadingGroupByCode", params);
if (group == null) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("data", Collections.emptyList());
return result;
}
Object groupId = group.get("group_id");
String showGroupLabel = group.get("show_group_label") != null ? String.valueOf(group.get("show_group_label")) : "N";
String placeholders = parentValueArray.stream().map(v -> "?").collect(Collectors.joining(", "));
String sql = "SELECT DISTINCT child_value_code as value, child_value_label as label," +
" parent_value_code as parent_value, parent_value_label as parent_label, display_order" +
" FROM category_value_cascading_mapping" +
" WHERE group_id = ? AND parent_value_code IN (" + placeholders + ") AND is_active = 'Y'" +
" ORDER BY parent_value_code, display_order, child_value_label";
List<Object> sqlParams = new ArrayList<>();
sqlParams.add(groupId);
sqlParams.addAll(parentValueArray);
List<Map<String, Object>> options = jdbcTemplate.queryForList(sql, sqlParams.toArray());
Map<String, Object> result = new LinkedHashMap<>();
result.put("data", options);
result.put("show_group_label", "Y".equals(showGroupLabel));
return result;
}
public Map<String, Object> getCategoryValueCascadingMappingsByTable(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
String companyCode = (String) params.get("company_code");
String tableName = (String) params.get("table_name");
StringBuilder groupSql = new StringBuilder(
"SELECT group_id, relation_code, child_column_name" +
" FROM category_value_cascading_group" +
" WHERE child_table_name = ? AND is_active = 'Y'");
List<Object> groupSqlParams = new ArrayList<>();
groupSqlParams.add(tableName);
if (companyCode != null && !"*".equals(companyCode)) {
groupSql.append(" AND (company_code = ? OR company_code = '*')");
groupSqlParams.add(companyCode);
}
List<Map<String, Object>> groups = jdbcTemplate.queryForList(groupSql.toString(), groupSqlParams.toArray());
Map<String, Object> mappings = new LinkedHashMap<>();
for (Map<String, Object> group : groups) {
Object groupId = group.get("group_id");
String childColumnName = String.valueOf(group.get("child_column_name"));
List<Map<String, Object>> groupMappings = jdbcTemplate.queryForList(
"SELECT DISTINCT child_value_code as code, child_value_label as label" +
" FROM category_value_cascading_mapping" +
" WHERE group_id = ? AND is_active = 'Y'" +
" ORDER BY child_value_label",
groupId);
if (!groupMappings.isEmpty()) {
mappings.put(childColumnName, groupMappings);
}
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("data", mappings);
return result;
}
}
@@ -1,247 +0,0 @@
package com.erp.service;
import com.erp.common.BaseService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Slf4j
public class CodeMergeService extends BaseService {
private final JdbcTemplate jdbcTemplate;
private static final String NS = "codeMerge.";
// Tables With Column
/**
* GET /tables-with-column/:columnName
* 해당 컬럼과 company_code 컬럼을 함께 가진 public 테이블 목록 반환
*/
public Map<String, Object> getTablesWithColumn(String columnName) {
Map<String, Object> params = new HashMap<>();
params.put("column_name", columnName);
List<Map<String, Object>> rows = sqlSession.selectList(NS + "getTablesWithColumn", params);
List<String> tables = rows.stream()
.map(r -> {
Object val = r.get("table_name");
return val != null ? val.toString() : null;
})
.filter(Objects::nonNull)
.collect(Collectors.toList());
Map<String, Object> result = new LinkedHashMap<>();
result.put("column_name", columnName);
result.put("tables", tables);
result.put("count", tables.size());
return result;
}
// Preview (column-based)
/**
* POST /preview
* columnName + oldValue 기준으로 영향받을 테이블/ 미리보기 (DB 변경 없음)
*/
public Map<String, Object> previewCodeMerge(Map<String, Object> body) {
String columnName = str(body.get("column_name"));
String oldValue = str(body.get("old_value"));
String companyCode = str(body.get("company_code"));
if (isBlank(columnName) || isBlank(oldValue)) {
throw new IllegalArgumentException("필수 필드가 누락되었습니다. (columnName, oldValue)");
}
log.info("코드 병합 미리보기: column={}, oldValue={}, company={}", columnName, oldValue, companyCode);
Map<String, Object> params = new HashMap<>();
params.put("column_name", columnName);
List<Map<String, Object>> tableRows = sqlSession.selectList(NS + "getTablesWithColumn", params);
List<Map<String, Object>> preview = new ArrayList<>();
int totalRows = 0;
for (Map<String, Object> tableRow : tableRows) {
Object nameVal = tableRow.get("table_name");
if (nameVal == null) continue;
String tableName = nameVal.toString();
// 테이블명·컬럼명은 information_schema에서 검증된 SQL 인젝션 위험 없음
String countSql = String.format(
"SELECT COUNT(*) FROM \"%s\" WHERE \"%s\" = ? AND company_code = ?",
tableName, columnName);
try {
Integer count = jdbcTemplate.queryForObject(countSql, Integer.class, oldValue, companyCode);
if (count != null && count > 0) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("table_name", tableName);
item.put("affected_rows", count);
preview.add(item);
totalRows += count;
}
} catch (Exception e) {
log.warn("테이블 {} 조회 실패 (건너뜀): {}", tableName, e.getMessage());
}
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("column_name", columnName);
result.put("old_value", oldValue);
result.put("preview", preview);
result.put("total_affected_rows", totalRows);
return result;
}
// Merge All Tables (column-based)
/**
* POST /merge-all-tables
* PostgreSQL 함수 merge_code_all_tables(columnName, oldValue, newValue, companyCode) 호출
*/
@Transactional
public Map<String, Object> mergeAllTables(Map<String, Object> body) {
String columnName = str(body.get("column_name"));
String oldValue = str(body.get("old_value"));
String newValue = str(body.get("new_value"));
String companyCode = str(body.get("company_code"));
if (isBlank(columnName) || isBlank(oldValue) || isBlank(newValue)) {
throw new IllegalArgumentException("필수 필드가 누락되었습니다. (columnName, oldValue, newValue)");
}
if (oldValue.equals(newValue)) {
throw new IllegalArgumentException("기존 값과 새 값이 동일합니다.");
}
log.info("코드 병합 시작: column={}, {} → {}, company={}", columnName, oldValue, newValue, companyCode);
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT * FROM merge_code_all_tables(?, ?, ?, ?)",
columnName, oldValue, newValue, companyCode);
int totalRows = rows.stream()
.mapToInt(r -> r.get("rows_updated") != null ? ((Number) r.get("rows_updated")).intValue() : 0)
.sum();
List<Map<String, Object>> affectedTables = rows.stream().map(r -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("table_name", r.get("table_name"));
item.put("rows_updated", r.get("rows_updated") != null ? ((Number) r.get("rows_updated")).intValue() : 0);
return item;
}).collect(Collectors.toList());
log.info("코드 병합 완료: 영향 테이블 {}개, 총 {}행", affectedTables.size(), totalRows);
Map<String, Object> result = new LinkedHashMap<>();
result.put("column_name", columnName);
result.put("old_value", oldValue);
result.put("new_value", newValue);
result.put("affected_tables", affectedTables);
result.put("total_rows_updated", totalRows);
return result;
}
// Merge By Value
/**
* POST /merge-by-value
* PostgreSQL 함수 merge_code_by_value(oldValue, newValue, companyCode) 호출
* 컬럼명에 관계없이 해당 값을 가진 모든 위치를 변경
*/
@Transactional
public Map<String, Object> mergeByValue(Map<String, Object> body) {
String oldValue = str(body.get("old_value"));
String newValue = str(body.get("new_value"));
String companyCode = str(body.get("company_code"));
if (isBlank(oldValue) || isBlank(newValue)) {
throw new IllegalArgumentException("필수 필드가 누락되었습니다. (oldValue, newValue)");
}
if (oldValue.equals(newValue)) {
throw new IllegalArgumentException("기존 값과 새 값이 동일합니다.");
}
log.info("값 기반 코드 병합 시작: {} → {}, company={}", oldValue, newValue, companyCode);
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT * FROM merge_code_by_value(?, ?, ?)",
oldValue, newValue, companyCode);
int totalRows = rows.stream()
.mapToInt(r -> r.get("out_rows_updated") != null ? ((Number) r.get("out_rows_updated")).intValue() : 0)
.sum();
List<Map<String, Object>> affectedData = rows.stream().map(r -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("table_name", r.get("out_table_name"));
item.put("column_name", r.get("out_column_name"));
item.put("rows_updated", r.get("out_rows_updated") != null ? ((Number) r.get("out_rows_updated")).intValue() : 0);
return item;
}).collect(Collectors.toList());
log.info("값 기반 코드 병합 완료: {} → {}, 총 {}행", oldValue, newValue, totalRows);
Map<String, Object> result = new LinkedHashMap<>();
result.put("old_value", oldValue);
result.put("new_value", newValue);
result.put("affected_data", affectedData);
result.put("total_rows_updated", totalRows);
return result;
}
// Preview By Value
/**
* POST /preview-by-value
* PostgreSQL 함수 preview_merge_code_by_value(oldValue, companyCode) 호출
*/
public Map<String, Object> previewByValue(Map<String, Object> body) {
String oldValue = str(body.get("old_value"));
String companyCode = str(body.get("company_code"));
if (isBlank(oldValue)) {
throw new IllegalArgumentException("필수 필드가 누락되었습니다. (oldValue)");
}
log.info("값 기반 코드 병합 미리보기: oldValue={}, company={}", oldValue, companyCode);
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT * FROM preview_merge_code_by_value(?, ?)",
oldValue, companyCode);
int totalRows = rows.stream()
.mapToInt(r -> r.get("out_affected_rows") != null ? ((Number) r.get("out_affected_rows")).intValue() : 0)
.sum();
List<Map<String, Object>> preview = rows.stream().map(r -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("table_name", r.get("out_table_name"));
item.put("column_name", r.get("out_column_name"));
item.put("affected_rows", r.get("out_affected_rows") != null ? ((Number) r.get("out_affected_rows")).intValue() : 0);
return item;
}).collect(Collectors.toList());
Map<String, Object> result = new LinkedHashMap<>();
result.put("old_value", oldValue);
result.put("preview", preview);
result.put("total_affected_rows", totalRows);
return result;
}
// Helpers
private String str(Object val) {
return val != null ? val.toString() : null;
}
private boolean isBlank(String s) {
return s == null || s.isBlank();
}
}
@@ -8,10 +8,12 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.*;
/**
* Common Code Service
* Common Code Service 마스터-디테일 패턴.
*
* commonCodeRoutes.ts 포팅.
* 테이블: code_category, code_info
* code_info : 1레벨 그룹 마스터 (PK = code_info + company_code)
* code_detail : 2레벨 ~ 무한대 트리 (PK = code_detail_id, parent_detail_id self-ref)
*
* 캐스케이딩/카테고리 구조 폐기. 단일 그룹 안에서 재귀 트리.
*/
@Service
@Slf4j
@@ -19,13 +21,11 @@ public class CommonCodeService extends BaseService {
private static final String NS = "commonCode.";
private static final long DEFAULT_MENU_OBJID = 1757401858940L;
//
// 카테고리 목록
// CODE_INFO 그룹 마스터 CRUD
//
public Map<String, Object> getCommonCodeCategoryList(Map<String, Object> params) {
public Map<String, Object> getCodeInfoList(Map<String, Object> params) {
int page = toInt(params.get("page"), 1);
int size = toInt(params.get("size"), 20);
params.put("limit", size);
@@ -34,425 +34,280 @@ public class CommonCodeService extends BaseService {
Object isActiveRaw = params.get("is_active");
if (isActiveRaw != null) params.put("is_active", toActiveStr(isActiveRaw));
List<Map<String, Object>> categories = sqlSession.selectList(NS + "getCommonCodeCategoryList", params);
Integer totalObj = sqlSession.selectOne(NS + "getCommonCodeCategoryListCnt", params);
List<Map<String, Object>> data = sqlSession.selectList(NS + "getCodeInfoList", params);
Integer totalObj = sqlSession.selectOne(NS + "getCodeInfoListCnt", params);
int total = totalObj != null ? totalObj : 0;
Map<String, Object> result = new LinkedHashMap<>();
result.put("data", categories);
result.put("data", data);
result.put("total", total);
return result;
}
//
// 카테고리 중복 확인
//
public Map<String, Object> getCodeInfoInfo(String codeInfo, String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("code_info", codeInfo);
params.put("company_code", companyCode);
return sqlSession.selectOne(NS + "getCodeInfoInfo", params);
}
public Map<String, Object> checkCategoryDuplicate(String field, String value,
String excludeCode, String companyCode) {
if (value == null || value.trim().isEmpty()) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("is_duplicate", false);
result.put("message", "값을 입력해주세요.");
return result;
@Transactional
public Map<String, Object> insertCodeInfo(Map<String, Object> body, String companyCode, String userId) {
Object rawCodeInfo = body.get("code_info");
String codeInfo = rawCodeInfo == null ? null : rawCodeInfo.toString().trim();
if (codeInfo != null && !codeInfo.isEmpty()
&& getCodeInfoInfo(codeInfo, companyCode) != null) {
throw new IllegalArgumentException("이미 존재하는 그룹 코드입니다: " + codeInfo);
}
Map<String, Object> params = new HashMap<>();
params.put("field", field != null ? field : "category_code");
params.put("value", value.trim());
params.put("exclude_code", excludeCode);
params.put("company_code", companyCode);
Integer countObj = sqlSession.selectOne(NS + "getCommonCodeCategoryDuplicateByField", params);
boolean isDuplicate = countObj != null && countObj > 0;
Map<String, Object> result = new LinkedHashMap<>();
result.put("is_duplicate", isDuplicate);
result.put("message", isDuplicate ? "이미 사용 중인 값입니다." : "사용 가능한 값입니다.");
return result;
}
//
// 카테고리 생성
//
@Transactional
public Map<String, Object> insertCommonCodeCategory(Map<String, Object> body, String companyCode, String userId) {
Map<String, Object> params = new HashMap<>();
params.put("category_code", body.get("category_code"));
params.put("category_name", body.get("category_name"));
params.put("category_name_eng", body.getOrDefault("category_name_eng", null));
params.put("description", body.getOrDefault("description", null));
params.put("sort_order", body.getOrDefault("sort_order", 0));
params.put("is_active", toActiveStr(body.getOrDefault("is_active", true)));
params.put("menu_objid", body.getOrDefault("menu_objid", DEFAULT_MENU_OBJID));
params.put("company_code", companyCode);
params.put("created_by", userId);
params.put("updated_by", userId);
sqlSession.insert(NS + "insertCommonCodeCategory", params);
Map<String, Object> q = new HashMap<>();
q.put("category_code", params.get("category_code"));
q.put("company_code", companyCode);
return sqlSession.selectOne(NS + "getCommonCodeCategoryInfo", q);
}
//
// 카테고리 수정
//
@Transactional
public Map<String, Object> updateCommonCodeCategory(String categoryCode, Map<String, Object> body,
String companyCode, String userId) {
Map<String, Object> params = new HashMap<>();
params.put("category_code", categoryCode);
params.put("code_info", body.get("code_info"));
params.put("code_name", body.get("code_name"));
params.put("code_name_eng", body.getOrDefault("code_name_eng", null));
params.put("description", body.getOrDefault("description", null));
params.put("sort_order", body.getOrDefault("sort_order", 0));
params.put("is_active", toActiveStr(body.getOrDefault("is_active", true)));
params.put("menu_objid", body.getOrDefault("menu_objid", null));
params.put("company_code", companyCode);
params.put("created_by", userId);
params.put("updated_by", userId);
if (body.containsKey("category_name")) params.put("category_name", body.get("category_name"));
if (body.containsKey("category_name_eng")) params.put("category_name_eng", body.get("category_name_eng"));
if (body.containsKey("description")) params.put("description", body.get("description"));
if (body.containsKey("sort_order")) params.put("sort_order", body.get("sort_order"));
if (body.containsKey("is_active")) params.put("is_active", toActiveStr(body.get("is_active")));
sqlSession.insert(NS + "insertCodeInfo", params);
int updated = sqlSession.update(NS + "updateCommonCodeCategory", params);
if (updated == 0) return null;
Map<String, Object> q = new HashMap<>();
q.put("category_code", categoryCode);
q.put("company_code", companyCode);
return sqlSession.selectOne(NS + "getCommonCodeCategoryInfo", q);
return getCodeInfoInfo(String.valueOf(params.get("code_info")), companyCode);
}
//
// 카테고리 삭제
//
@Transactional
public void deleteCommonCodeCategory(String categoryCode, String companyCode) {
public Map<String, Object> updateCodeInfo(String codeInfo, Map<String, Object> body,
String companyCode, String userId) {
Map<String, Object> params = new HashMap<>();
params.put("category_code", categoryCode);
params.put("company_code", companyCode);
int deleted = sqlSession.delete(NS + "deleteCommonCodeCategory", params);
if (deleted == 0) throw new IllegalArgumentException("카테고리를 찾을 수 없습니다.");
params.put("code_info", codeInfo);
params.put("company_code", companyCode);
params.put("updated_by", userId);
if (body.containsKey("code_name")) params.put("code_name", body.get("code_name"));
if (body.containsKey("code_name_eng")) params.put("code_name_eng", body.get("code_name_eng"));
if (body.containsKey("description")) params.put("description", body.get("description"));
if (body.containsKey("sort_order")) params.put("sort_order", body.get("sort_order"));
if (body.containsKey("is_active")) params.put("is_active", toActiveStr(body.get("is_active")));
if (body.containsKey("menu_objid")) params.put("menu_objid", body.get("menu_objid"));
int updated = sqlSession.update(NS + "updateCodeInfo", params);
if (updated == 0) return null;
return getCodeInfoInfo(codeInfo, companyCode);
}
//
// 코드 목록 (snake_case + camelCase 이중 필드)
//
@Transactional
public void deleteCodeInfo(String codeInfo, String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("code_info", codeInfo);
params.put("company_code", companyCode);
public Map<String, Object> getCommonCodeList(String categoryCode, Map<String, Object> params) {
int page = toInt(params.get("page"), 1);
int size = toInt(params.get("size"), 20);
params.put("category_code", categoryCode);
params.put("limit", size);
params.put("offset", (page - 1) * size);
Object isActiveRaw = params.get("is_active");
if (isActiveRaw != null) params.put("is_active", toActiveStr(isActiveRaw));
List<Map<String, Object>> rawList = sqlSession.selectList(NS + "getCommonCodeList", params);
Integer totalObj = sqlSession.selectOne(NS + "getCommonCodeListCnt", params);
int total = totalObj != null ? totalObj : 0;
List<Map<String, Object>> codes = new ArrayList<>();
for (Map<String, Object> raw : rawList) {
codes.add(transformCode(raw));
}
int deleted = sqlSession.delete(NS + "deleteCodeInfo", params);
if (deleted == 0) throw new IllegalArgumentException("코드 그룹을 찾을 수 없습니다.");
}
public Map<String, Object> checkCodeInfoDuplicate(String field, String value,
String excludeCode, String companyCode) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("data", codes);
result.put("total", total);
return result;
}
//
// 코드 중복 확인
//
public Map<String, Object> checkCodeDuplicate(String categoryCode, String field, String value,
String excludeCode, String companyCode) {
if (value == null || value.trim().isEmpty()) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("is_duplicate", false);
result.put("message", "값을 입력해주세요.");
result.put("message", "값을 입력해주세요.");
return result;
}
Map<String, Object> params = new HashMap<>();
params.put("category_code", categoryCode);
params.put("field", field != null ? field : "code_value");
params.put("field", field != null ? field : "code_info");
params.put("value", value.trim());
params.put("exclude_code", excludeCode);
params.put("company_code", companyCode);
Integer countObj = sqlSession.selectOne(NS + "getCommonCodeDuplicateByField", params);
params.put("exclude_code", excludeCode);
params.put("company_code", companyCode);
Integer countObj = sqlSession.selectOne(NS + "getCodeInfoDuplicateByField", params);
boolean isDuplicate = countObj != null && countObj > 0;
Map<String, Object> result = new LinkedHashMap<>();
result.put("is_duplicate", isDuplicate);
result.put("message", isDuplicate ? "이미 사용 중인 값입니다." : "사용 가능한 값입니다.");
return result;
}
//
// 코드 생성
// CODE_DETAIL 디테일 트리 CRUD
//
@Transactional
public Map<String, Object> insertCommonCode(String categoryCode, Map<String, Object> body,
String companyCode, String userId) {
// parentCodeValue 기반 depth 자동 계산
Object parentCodeValueRaw = body.getOrDefault("parent_code_value", null);
int depth = 1;
if (parentCodeValueRaw != null && !parentCodeValueRaw.toString().isEmpty()) {
Map<String, Object> parentParams = new HashMap<>();
parentParams.put("category_code", categoryCode);
parentParams.put("code_value", parentCodeValueRaw.toString());
parentParams.put("company_code", companyCode);
Integer parentDepth = sqlSession.selectOne(NS + "getCommonCodeParentDepth", parentParams);
depth = (parentDepth != null ? parentDepth : 0) + 1;
}
Map<String, Object> params = new HashMap<>();
params.put("category_code", categoryCode);
params.put("code_value", body.get("code_value"));
params.put("code_name", body.get("code_name"));
params.put("code_name_eng", body.getOrDefault("code_name_eng", null));
params.put("description", body.getOrDefault("description", null));
params.put("sort_order", body.getOrDefault("sort_order", 0));
params.put("is_active", toActiveStr(body.getOrDefault("is_active", true)));
params.put("menu_objid", body.getOrDefault("menu_objid", DEFAULT_MENU_OBJID));
params.put("company_code", companyCode);
params.put("parent_code_value", parentCodeValueRaw);
params.put("depth", depth);
params.put("created_by", userId);
params.put("updated_by", userId);
sqlSession.insert(NS + "insertCommonCode", params);
Map<String, Object> q = new HashMap<>();
q.put("category_code", categoryCode);
q.put("code_value", params.get("code_value"));
q.put("company_code", companyCode);
Map<String, Object> raw = sqlSession.selectOne(NS + "getCommonCodeInfo", q);
return raw != null ? transformCode(raw) : null;
}
//
// 코드 정렬 순서 변경
//
@Transactional
public void updateCommonCodeOrder(String categoryCode, List<Map<String, Object>> codes, String companyCode) {
for (Map<String, Object> code : codes) {
Map<String, Object> params = new HashMap<>();
params.put("category_code", categoryCode);
params.put("code_value", code.get("code_value"));
params.put("sort_order", code.get("sort_order"));
params.put("company_code", companyCode);
sqlSession.update(NS + "updateCommonCodeSortOrder", params);
}
}
//
// 계층형 코드 목록
//
public List<Map<String, Object>> getCommonCodeHierarchicalList(String categoryCode, Map<String, Object> params) {
params.put("category_code", categoryCode);
public Map<String, Object> getCodeDetailList(String codeInfo, Map<String, Object> params) {
int page = toInt(params.get("page"), 1);
int size = toInt(params.get("size"), 20);
params.put("code_info", codeInfo);
params.put("limit", size);
params.put("offset", (page - 1) * size);
Object isActiveRaw = params.get("is_active");
if (isActiveRaw != null) params.put("is_active", toActiveStr(isActiveRaw));
else params.remove("is_active");
// parentCodeValue, depth 필터는 params에 그대로 전달 (XML에서 처리)
List<Map<String, Object>> rawList = sqlSession.selectList(NS + "getCommonCodeHierarchicalList", params);
List<Map<String, Object>> result = new ArrayList<>();
for (Map<String, Object> raw : rawList) {
result.add(transformCode(raw));
Object parentRaw = params.get("parent_detail_id");
if (parentRaw != null && !parentRaw.toString().isEmpty()) {
params.put("parent_detail_id", toLong(parentRaw));
} else {
params.remove("parent_detail_id");
}
return result;
}
//
// 트리 구조 { flat: [...], tree: [...] }
//
public Map<String, Object> getCommonCodeTree(String categoryCode, String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("category_code", categoryCode);
params.put("company_code", companyCode);
List<Map<String, Object>> flatList = sqlSession.selectList(NS + "getCommonCodeTreeList", params);
List<Map<String, Object>> flatTransformed = new ArrayList<>();
for (Map<String, Object> raw : flatList) {
flatTransformed.add(transformCode(raw));
}
List<Map<String, Object>> data = sqlSession.selectList(NS + "getCodeDetailList", params);
Integer totalObj = sqlSession.selectOne(NS + "getCodeDetailListCnt", params);
int total = totalObj != null ? totalObj : 0;
Map<String, Object> result = new LinkedHashMap<>();
result.put("flat", flatTransformed);
result.put("tree", buildTree(flatList));
result.put("data", data);
result.put("total", total);
return result;
}
//
// 자식 존재 여부
//
public Map<String, Object> hasChildren(String categoryCode, String codeValue, String companyCode) {
/**
* 그룹 전체 트리 평탄화된 리스트로 반환 (depth + sort_order ).
* 프론트가 parent_detail_id nest 처리하기 좋게.
*/
public List<Map<String, Object>> getCodeDetailTree(String codeInfo, String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("category_code", categoryCode);
params.put("code_value", codeValue);
params.put("company_code", companyCode);
Integer countObj = sqlSession.selectOne(NS + "getCommonCodeChildrenCnt", params);
params.put("code_info", codeInfo);
params.put("company_code", companyCode);
return sqlSession.selectList(NS + "getCodeDetailTree", params);
}
public Map<String, Object> getCodeDetailInfo(Long codeDetailId, String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("code_detail_id", codeDetailId);
params.put("company_code", companyCode);
return sqlSession.selectOne(NS + "getCodeDetailInfo", params);
}
@Transactional
public Map<String, Object> insertCodeDetail(String codeInfo, Map<String, Object> body,
String companyCode, String userId) {
// parent_detail_id 기반 depth 자동 계산. NULL = 그룹 직속 (depth=2).
Long parentDetailId = toLong(body.getOrDefault("parent_detail_id", null));
int depth = 2;
if (parentDetailId != null) {
Map<String, Object> parentParams = new HashMap<>();
parentParams.put("code_detail_id", parentDetailId);
parentParams.put("company_code", companyCode);
Integer parentDepth = sqlSession.selectOne(NS + "getCodeDetailParentDepth", parentParams);
depth = (parentDepth != null ? parentDepth : 1) + 1;
}
Map<String, Object> params = new HashMap<>();
params.put("code_info", codeInfo);
params.put("parent_detail_id", parentDetailId);
params.put("code_value", body.get("code_value"));
params.put("code_name", body.get("code_name"));
params.put("code_name_eng", body.getOrDefault("code_name_eng", null));
params.put("description", body.getOrDefault("description", null));
params.put("depth", depth);
params.put("sort_order", body.getOrDefault("sort_order", 0));
params.put("is_active", toActiveStr(body.getOrDefault("is_active", true)));
params.put("company_code", companyCode);
params.put("created_by", userId);
params.put("updated_by", userId);
sqlSession.insert(NS + "insertCodeDetail", params);
Object newIdRaw = params.get("code_detail_id");
Long newId = toLong(newIdRaw);
return newId != null ? getCodeDetailInfo(newId, companyCode) : null;
}
@Transactional
public Map<String, Object> updateCodeDetail(Long codeDetailId, Map<String, Object> body,
String companyCode, String userId) {
Map<String, Object> params = new HashMap<>();
params.put("code_detail_id", codeDetailId);
params.put("company_code", companyCode);
params.put("updated_by", userId);
if (body.containsKey("code_value")) params.put("code_value", body.get("code_value"));
if (body.containsKey("code_name")) params.put("code_name", body.get("code_name"));
if (body.containsKey("code_name_eng")) params.put("code_name_eng", body.get("code_name_eng"));
if (body.containsKey("description")) params.put("description", body.get("description"));
if (body.containsKey("sort_order")) params.put("sort_order", body.get("sort_order"));
if (body.containsKey("is_active")) params.put("is_active", toActiveStr(body.get("is_active")));
// parent_detail_id 변경 depth 재계산.
if (body.containsKey("parent_detail_id")) {
Long newParent = toLong(body.get("parent_detail_id"));
int newDepth = 2;
if (newParent != null) {
Map<String, Object> parentParams = new HashMap<>();
parentParams.put("code_detail_id", newParent);
parentParams.put("company_code", companyCode);
Integer parentDepth = sqlSession.selectOne(NS + "getCodeDetailParentDepth", parentParams);
newDepth = (parentDepth != null ? parentDepth : 1) + 1;
}
params.put("reparent", true);
params.put("parent_detail_id", newParent);
params.put("depth", newDepth);
}
int updated = sqlSession.update(NS + "updateCodeDetail", params);
if (updated == 0) return null;
return getCodeDetailInfo(codeDetailId, companyCode);
}
@Transactional
public void deleteCodeDetail(Long codeDetailId, String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("code_detail_id", codeDetailId);
params.put("company_code", companyCode);
int deleted = sqlSession.delete(NS + "deleteCodeDetail", params);
if (deleted == 0) throw new IllegalArgumentException("코드를 찾을 수 없습니다.");
}
public Map<String, Object> checkCodeDetailDuplicate(String codeInfo, String codeValue,
Long excludeId, String companyCode) {
Map<String, Object> result = new LinkedHashMap<>();
if (codeValue == null || codeValue.trim().isEmpty()) {
result.put("is_duplicate", false);
result.put("message", "값을 입력해주세요.");
return result;
}
Map<String, Object> params = new HashMap<>();
params.put("code_info", codeInfo);
params.put("code_value", codeValue.trim());
params.put("exclude_id", excludeId);
params.put("company_code", companyCode);
Integer countObj = sqlSession.selectOne(NS + "getCodeDetailDuplicateCnt", params);
boolean isDuplicate = countObj != null && countObj > 0;
result.put("is_duplicate", isDuplicate);
result.put("message", isDuplicate ? "이미 사용 중인 값입니다." : "사용 가능한 값입니다.");
return result;
}
public Map<String, Object> hasCodeDetailChildren(Long codeDetailId, String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("code_detail_id", codeDetailId);
params.put("company_code", companyCode);
Integer countObj = sqlSession.selectOne(NS + "getCodeDetailChildrenCnt", params);
int count = countObj != null ? countObj : 0;
Map<String, Object> result = new LinkedHashMap<>();
result.put("has_children", count > 0);
result.put("count", count);
return result;
}
//
// 코드 수정
//
@Transactional
public Map<String, Object> updateCommonCode(String categoryCode, String codeValue,
Map<String, Object> body, String companyCode, String userId) {
Map<String, Object> params = new HashMap<>();
params.put("category_code", categoryCode);
params.put("code_value", codeValue);
params.put("company_code", companyCode);
params.put("updated_by", userId);
if (body.containsKey("code_name")) params.put("code_name", body.get("code_name"));
if (body.containsKey("code_name_eng")) params.put("code_name_eng", body.get("code_name_eng"));
if (body.containsKey("description")) params.put("description", body.get("description"));
if (body.containsKey("sort_order")) params.put("sort_order", body.get("sort_order"));
if (body.containsKey("is_active")) params.put("is_active", toActiveStr(body.get("is_active")));
if (body.containsKey("parent_code_value")) {
Object newParent = body.get("parent_code_value");
params.put("parent_code_value", newParent);
// parentCodeValue 변경 depth 재계산
if (newParent != null && !newParent.toString().isEmpty()) {
Map<String, Object> parentParams = new HashMap<>();
parentParams.put("category_code", categoryCode);
parentParams.put("code_value", newParent.toString());
parentParams.put("company_code", companyCode);
Integer parentDepth = sqlSession.selectOne(NS + "getCommonCodeParentDepth", parentParams);
params.put("depth", (parentDepth != null ? parentDepth : 0) + 1);
} else {
params.put("depth", 1);
}
} else if (body.containsKey("depth")) {
params.put("depth", body.get("depth"));
}
int updated = sqlSession.update(NS + "updateCommonCode", params);
if (updated == 0) return null;
Map<String, Object> q = new HashMap<>();
q.put("category_code", categoryCode);
q.put("code_value", codeValue);
q.put("company_code", companyCode);
Map<String, Object> raw = sqlSession.selectOne(NS + "getCommonCodeInfo", q);
return raw != null ? transformCode(raw) : null;
}
//
// 코드 삭제
//
@Transactional
public void deleteCommonCode(String categoryCode, String codeValue, String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("category_code", categoryCode);
params.put("code_value", codeValue);
params.put("company_code", companyCode);
int deleted = sqlSession.delete(NS + "deleteCommonCode", params);
if (deleted == 0) throw new IllegalArgumentException("코드를 찾을 수 없습니다.");
}
//
// 코드 옵션 목록
//
public List<Map<String, Object>> getCommonCodeOptionList(String categoryCode, Map<String, Object> params) {
params.put("category_code", categoryCode);
Object isActiveRaw = params.get("is_active");
// 미지정 활성 코드만 반환 (드롭다운 기본 동작)
params.put("is_active", isActiveRaw != null ? toActiveStr(isActiveRaw) : "Y");
List<Map<String, Object>> rawList = sqlSession.selectList(NS + "getCommonCodeOptionList", params);
List<Map<String, Object>> options = new ArrayList<>();
for (Map<String, Object> raw : rawList) {
Map<String, Object> opt = new LinkedHashMap<>();
opt.put("value", raw.get("code_value"));
opt.put("label", raw.get("code_name"));
opt.put("label_eng", raw.get("code_name_eng"));
options.add(opt);
}
return options;
}
//
// Private helpers
//
/** snake_case 원본 + camelCase 별칭을 모두 포함하는 Map 반환 */
private Map<String, Object> transformCode(Map<String, Object> raw) {
Map<String, Object> item = new LinkedHashMap<>(raw);
item.put("code_value", raw.get("code_value"));
item.put("code_name", raw.get("code_name"));
item.put("code_name_eng", raw.get("code_name_eng"));
item.put("code_category", raw.get("code_category"));
item.put("sort_order", raw.get("sort_order"));
item.put("is_active", raw.get("is_active"));
item.put("menu_objid", raw.get("menu_objid"));
item.put("company_code", raw.get("company_code"));
item.put("parent_code_value", raw.get("parent_code_value"));
item.put("created_by", raw.get("created_by"));
item.put("updated_by", raw.get("updated_by"));
item.put("created_date", raw.get("created_date"));
item.put("updated_date", raw.get("updated_date"));
return item;
}
/** 평탄 목록을 부모-자식 트리로 변환 */
@SuppressWarnings("unchecked")
private List<Map<String, Object>> buildTree(List<Map<String, Object>> codes) {
Map<String, Map<String, Object>> byValue = new LinkedHashMap<>();
for (Map<String, Object> code : codes) {
String val = objToStr(code.get("code_value"));
Map<String, Object> node = new LinkedHashMap<>(transformCode(code));
node.put("children", new ArrayList<Map<String, Object>>());
byValue.put(val, node);
}
List<Map<String, Object>> roots = new ArrayList<>();
for (Map<String, Object> code : codes) {
String val = objToStr(code.get("code_value"));
Object parentRaw = code.get("parent_code_value");
String parentVal = (parentRaw != null) ? parentRaw.toString() : null;
Map<String, Object> node = byValue.get(val);
if (parentVal == null || parentVal.isEmpty() || !byValue.containsKey(parentVal)) {
roots.add(node);
} else {
((List<Map<String, Object>>) byValue.get(parentVal).get("children")).add(node);
}
}
return roots;
}
/** boolean/String → VARCHAR 'Y'/'N' 변환 */
/** boolean / String / Number → VARCHAR(1) 'Y'/'N'. */
private String toActiveStr(Object val) {
if (val == null) return "Y";
if (val instanceof Boolean b) return b ? "Y" : "N";
if (val instanceof Number n) return n.intValue() != 0 ? "Y" : "N";
String s = val.toString().toLowerCase();
return ("true".equals(s) || "y".equals(s) || "1".equals(s)) ? "Y" : "N";
}
@@ -463,7 +318,12 @@ public class CommonCodeService extends BaseService {
catch (NumberFormatException e) { return defaultVal; }
}
private String objToStr(Object val) {
return val != null ? val.toString() : "";
private Long toLong(Object val) {
if (val == null) return null;
if (val instanceof Number n) return n.longValue();
String s = val.toString().trim();
if (s.isEmpty() || "null".equalsIgnoreCase(s)) return null;
try { return Long.parseLong(s); }
catch (NumberFormatException e) { return null; }
}
}
@@ -1,6 +1,7 @@
package com.erp.service;
import com.erp.common.BaseService;
import com.erp.constants.InputTypeConstants;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
@@ -39,12 +40,6 @@ public class DdlService extends BaseService {
"id", "created_date", "updated_date", "company_code"
);
/** 사용자가 신규 추가하는 컬럼에 허용되는 INPUT_TYPE 8종 (백엔드 백스톱) */
private static final Set<String> USER_SELECTABLE_INPUT_TYPES = Set.of(
"text", "number", "date", "code", "entity",
"numbering", "file", "image"
);
public DdlService(JdbcTemplate jdbcTemplate, PlatformTransactionManager transactionManager) {
this.jdbcTemplate = jdbcTemplate;
this.transactionTemplate = new TransactionTemplate(transactionManager);
@@ -146,9 +141,9 @@ public class DdlService extends BaseService {
transactionTemplate.execute(status -> {
jdbcTemplate.execute(ddlQuery);
String inputType = convertToInputType(column);
if (!USER_SELECTABLE_INPUT_TYPES.contains(inputType)) {
if (!InputTypeConstants.USER_SELECTABLE_INPUT_TYPES.contains(inputType)) {
throw new IllegalArgumentException(
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + USER_SELECTABLE_INPUT_TYPES
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + InputTypeConstants.USER_SELECTABLE_INPUT_TYPES
+ " (받은 값: " + inputType + ")"
);
}
@@ -231,6 +226,79 @@ public class DdlService extends BaseService {
}
}
//
// DROP COLUMN (DBeaver 방식: FK 위반은 Postgres 던지는 에러를 그대로 노출)
//
public Map<String, Object> dropColumn(String tableName, String columnName,
String companyCode, String userId) {
// 1. 시스템 테이블 보호
if (SYSTEM_TABLES.contains(tableName.toLowerCase())) {
String errorMsg = "'" + tableName + "'은 시스템 테이블이므로 컬럼을 삭제할 수 없습니다.";
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName,
"SYSTEM_TABLE_PROTECTED", false, errorMsg);
return Map.of("success", false, "message", errorMsg, "error_code", "SYSTEM_TABLE_PROTECTED");
}
// 2. 예약 컬럼 보호 (id / created_date / updated_date / company_code / writer)
if (RESERVED_COLUMNS.contains(columnName.toLowerCase()) || "writer".equalsIgnoreCase(columnName)) {
String errorMsg = "'" + columnName + "'은 시스템 예약 컬럼이므로 삭제할 수 없습니다.";
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName,
"RESERVED_COLUMN_PROTECTED", false, errorMsg);
return Map.of("success", false, "message", errorMsg, "error_code", "RESERVED_COLUMN_PROTECTED");
}
// 3. 테이블/컬럼 존재 여부
if (!tableExists(tableName)) {
String errorMsg = "테이블 '" + tableName + "'이 존재하지 않습니다.";
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName, "TABLE_NOT_FOUND", false, errorMsg);
return Map.of("success", false, "message", errorMsg, "error_code", "TABLE_NOT_FOUND");
}
if (!columnExists(tableName, columnName)) {
String errorMsg = "컬럼 '" + columnName + "'이 존재하지 않습니다.";
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName, "COLUMN_NOT_FOUND", false, errorMsg);
return Map.of("success", false, "message", errorMsg, "error_code", "COLUMN_NOT_FOUND");
}
// 4. DDL 실행 CASCADE 붙임 FK 참조 있으면 Postgres 거부 (DBeaver 동일)
String ddlQuery = "ALTER TABLE \"" + sanitize(tableName) + "\" DROP COLUMN \"" + sanitize(columnName) + "\"";
try {
transactionTemplate.execute(status -> {
jdbcTemplate.execute(ddlQuery);
// 컬럼 메타 청소
jdbcTemplate.update(
"DELETE FROM table_type_columns WHERE table_name = ? AND column_name = ?",
tableName, columnName);
jdbcTemplate.update(
"DELETE FROM column_labels WHERE table_name = ? AND column_name = ?",
tableName, columnName);
return null;
});
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName, ddlQuery, true, null);
log.info("컬럼 삭제 성공: {}.{}, 사용자: {}", tableName, columnName, userId);
return Map.of(
"success", true,
"message", "컬럼 '" + columnName + "'이 성공적으로 삭제되었습니다.",
"table_name", tableName,
"column_name", columnName,
"executed_query", ddlQuery
);
} catch (Exception e) {
String rawMsg = e.getMessage() != null ? e.getMessage() : "";
String guidance = rawMsg.toLowerCase().contains("depend") || rawMsg.toLowerCase().contains("foreign key")
? " (다른 테이블에서 외래키로 참조 중인 컬럼은 삭제할 수 없습니다)"
: "";
String errorMsg = "컬럼 삭제 실패: " + rawMsg + guidance;
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName,
"FAILED: " + rawMsg, false, errorMsg);
log.error("컬럼 삭제 실패: {}.{}, 사용자: {}, 오류: {}", tableName, columnName, userId, rawMsg, e);
return Map.of("success", false, "message", errorMsg, "error_code", "EXECUTION_FAILED");
}
}
//
// VALIDATE
//
@@ -421,9 +489,9 @@ public class DdlService extends BaseService {
for (int i = 0; i < columns.size(); i++) {
Map<String, Object> col = columns.get(i);
String inputType = convertToInputType(col);
if (!USER_SELECTABLE_INPUT_TYPES.contains(inputType)) {
if (!InputTypeConstants.USER_SELECTABLE_INPUT_TYPES.contains(inputType)) {
throw new IllegalArgumentException(
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + USER_SELECTABLE_INPUT_TYPES
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + InputTypeConstants.USER_SELECTABLE_INPUT_TYPES
+ " (받은 값: " + inputType + ")"
);
}
@@ -532,6 +600,9 @@ public class DdlService extends BaseService {
case "radio" -> "radio";
case "code" -> "code";
case "entity" -> "entity";
case "file" -> "file";
case "image" -> "image";
case "numbering" -> "numbering";
default -> "text";
};
}
@@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
@@ -20,17 +21,22 @@ public class DepartmentService extends BaseService {
//
public List<Map<String, Object>> getDepartments(String companyCode) {
return getDepartments(companyCode, false);
return getDepartments(companyCode, false, null);
}
/** soft-delete 대응 — includeDeleted=true 면 DELETED_AT 부서도 포함 */
public List<Map<String, Object>> getDepartments(String companyCode, boolean includeDeleted) {
return getDepartments(companyCode, includeDeleted, null);
}
/** 기준일 필터 — baseDate 가 있으면 해당 시점에 active 한 부서만 반환 (start_date ≤ baseDate ≤ end_date OR end_date IS NULL) */
public List<Map<String, Object>> getDepartments(String companyCode, boolean includeDeleted, String baseDate) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("include_deleted", includeDeleted);
params.put("base_date", baseDate); // null/빈문자면 XML if skip
List<Map<String, Object>> departments = sqlSession.selectList("department.selectDepartments", params);
// member_count를 int로 변환
for (Map<String, Object> dept : departments) {
Object cnt = dept.get("member_count");
if (cnt != null) {
@@ -38,6 +44,10 @@ public class DepartmentService extends BaseService {
} else {
dept.put("member_count", 0);
}
// dept_managers JSON 컬럼들 (String) List<Map> 으로 파싱
parseManagersJson(dept, "approval_managers");
parseManagersJson(dept, "dept_managers");
parseManagersJson(dept, "org_leaders");
}
return departments;
}
@@ -46,14 +56,26 @@ public class DepartmentService extends BaseService {
public Map<String, Object> getDepartment(String deptCode) {
Map<String, Object> params = new HashMap<>();
params.put("dept_code", deptCode);
return sqlSession.selectOne("department.selectDepartmentByCode", params);
Map<String, Object> dept = sqlSession.selectOne("department.selectDepartmentByCode", params);
if (dept != null) {
parseManagersJson(dept, "approval_managers");
parseManagersJson(dept, "dept_managers");
parseManagersJson(dept, "org_leaders");
}
return dept;
}
/** deleted 부서까지 포함 — 복구 검증 / 부모 deleted 체크 등 internal 흐름용 */
public Map<String, Object> getDepartmentIncludingDeleted(String deptCode) {
Map<String, Object> params = new HashMap<>();
params.put("dept_code", deptCode);
return sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted", params);
Map<String, Object> dept = sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted", params);
if (dept != null) {
parseManagersJson(dept, "approval_managers");
parseManagersJson(dept, "dept_managers");
parseManagersJson(dept, "org_leaders");
}
return dept;
}
@Transactional
@@ -129,11 +151,15 @@ public class DepartmentService extends BaseService {
insertParams.put("location", nullIfBlank(bodyParam(body, "location", "location")));
sqlSession.insert("department.insertDepartment", insertParams);
syncManagers(deptCode, companyCode, body, "approval");
syncManagers(deptCode, companyCode, body, "dept");
syncManagers(deptCode, companyCode, body, "org_leader");
log.info("부서 생성 성공: deptCode={}, deptName={}", deptCode, deptName);
Map<String, Object> findParams = new HashMap<>();
findParams.put("dept_code", deptCode);
return sqlSession.selectOne("department.selectDepartmentByCode", findParams);
return getDepartment(deptCode);
}
@Transactional
@@ -196,10 +222,12 @@ public class DepartmentService extends BaseService {
return null;
}
syncManagers(deptCode, deptCompanyCode, body, "approval");
syncManagers(deptCode, deptCompanyCode, body, "dept");
syncManagers(deptCode, deptCompanyCode, body, "org_leader");
log.info("부서 수정 성공: deptCode={}", deptCode);
Map<String, Object> findParams = new HashMap<>();
findParams.put("dept_code", deptCode);
return sqlSession.selectOne("department.selectDepartmentByCode", findParams);
return getDepartment(deptCode);
}
/**
@@ -338,6 +366,330 @@ public class DepartmentService extends BaseService {
}
}
//
// 일괄등록 / 일괄업데이트 (Bulk)
//
private static final int BULK_MAX_ROWS = 1000;
/**
* 일괄등록 preview (read-only validation). DB 쓰기 없음.
* batch dept_name 중복 + DB active 중복 + parent/날짜/매니저 검증.
* row row_index / result(ok|error) / error_detail 채워서 반환.
*/
public List<Map<String, Object>> bulkPreviewCreate(String companyCode, List<Map<String, Object>> rows) {
List<Map<String, Object>> results = new ArrayList<>();
if (rows == null || rows.isEmpty()) return results;
if (rows.size() > BULK_MAX_ROWS) {
throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 처리 가능합니다.");
}
Set<String> existingNames = collectActiveDeptNames(companyCode);
Set<String> batchNames = new HashSet<>();
for (int i = 0; i < rows.size(); i++) {
Map<String, Object> input = rows.get(i);
Map<String, Object> out = new HashMap<>(input);
out.put("row_index", i);
String error = validateBulkCreateRow(input, companyCode, existingNames, batchNames);
if (error == null) {
out.put("result", "ok");
out.put("error_detail", null);
String dn = trimString(input.get("dept_name"));
if (dn != null) batchNames.add(dn.toLowerCase());
} else {
out.put("result", "error");
out.put("error_detail", error);
}
results.add(out);
}
return results;
}
/**
* 일괄업데이트 preview (read-only). mode = department | manager.
*/
public List<Map<String, Object>> bulkPreviewUpdate(String companyCode, String mode, List<Map<String, Object>> rows) {
List<Map<String, Object>> results = new ArrayList<>();
if (rows == null || rows.isEmpty()) return results;
if (rows.size() > BULK_MAX_ROWS) {
throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 처리 가능합니다.");
}
if (!"department".equals(mode) && !"manager".equals(mode)) {
throw new IllegalArgumentException("mode 는 department | manager 여야 합니다.");
}
for (int i = 0; i < rows.size(); i++) {
Map<String, Object> input = rows.get(i);
Map<String, Object> out = new HashMap<>(input);
out.put("row_index", i);
String error = validateBulkUpdateRow(input, companyCode, mode);
if (error == null) {
out.put("result", "ok");
out.put("error_detail", null);
} else {
out.put("result", "error");
out.put("error_detail", error);
}
results.add(out);
}
return results;
}
/**
* 일괄등록 실제 저장 (@Transactional, all-or-nothing).
* row createDepartment 위임 검증 + manager sync 까지 동일 흐름.
* 중간 실패 IllegalArgumentException 으로 행번호+사유 합쳐서 던짐 전체 롤백.
*/
@Transactional
public int bulkSaveCreate(String companyCode, List<Map<String, Object>> rows) {
if (rows == null || rows.isEmpty()) return 0;
if (rows.size() > BULK_MAX_ROWS) {
throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 등록 가능합니다.");
}
int inserted = 0;
for (int i = 0; i < rows.size(); i++) {
Map<String, Object> row = rows.get(i);
String label = trimString(row.get("dept_name"));
try {
createDepartment(companyCode, row);
inserted++;
} catch (DuplicateDeptNameException | IllegalArgumentException | IllegalStateException e) {
throw new IllegalArgumentException("" + (i + 1) + " (" + (label != null ? label : "?") + "): " + e.getMessage());
}
}
log.info("부서 일괄등록 성공: company={}, inserted={}", companyCode, inserted);
return inserted;
}
/**
* 일괄업데이트 실제 적용 (@Transactional). mode = department | manager.
* department: 부서 정보 부분 업데이트 (row null/미지정 필드는 기존값 보존).
* manager: row 명시된 매니저 role sync (delete-all + insert-all).
*/
@Transactional
public int bulkUpdate(String companyCode, String mode, List<Map<String, Object>> rows) {
if (rows == null || rows.isEmpty()) return 0;
if (rows.size() > BULK_MAX_ROWS) {
throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 수정 가능합니다.");
}
if (!"department".equals(mode) && !"manager".equals(mode)) {
throw new IllegalArgumentException("mode 는 department | manager 여야 합니다.");
}
int updated = 0;
for (int i = 0; i < rows.size(); i++) {
Map<String, Object> row = rows.get(i);
String deptCode = trimString(row.get("dept_code"));
if (deptCode == null) {
throw new IllegalArgumentException("" + (i + 1) + ": 부서코드(dept_code) 필수.");
}
Map<String, Object> existing = getDepartment(deptCode);
if (existing == null) {
throw new IllegalArgumentException("" + (i + 1) + ": 부서를 찾을 수 없습니다: " + deptCode);
}
String deptCompanyCode = existing.get("company_code") != null
? existing.get("company_code").toString() : null;
if (!companyCode.equals(deptCompanyCode) && !"*".equals(deptCompanyCode)) {
throw new IllegalArgumentException("" + (i + 1) + ": 다른 회사의 부서입니다: " + deptCode);
}
try {
if ("department".equals(mode)) {
Map<String, Object> merged = buildMergedDeptBody(existing, row);
Map<String, Object> result = updateDepartment(deptCode, merged);
if (result == null) {
throw new IllegalStateException("수정 실패: " + deptCode);
}
} else {
// manager mode row 명시된 role sync
if (row.containsKey("approval_managers")) {
syncManagers(deptCode, companyCode, row, "approval");
}
if (row.containsKey("dept_managers")) {
syncManagers(deptCode, companyCode, row, "dept");
}
if (row.containsKey("org_leaders")) {
syncManagers(deptCode, companyCode, row, "org_leader");
}
}
updated++;
} catch (DuplicateDeptNameException | IllegalArgumentException | IllegalStateException e) {
throw new IllegalArgumentException("" + (i + 1) + " (" + deptCode + "): " + e.getMessage());
}
}
log.info("부서 일괄수정 성공: company={}, mode={}, updated={}", companyCode, mode, updated);
return updated;
}
/** company 의 active 부서명 lowercase set — 일괄등록 중복검증용 */
private Set<String> collectActiveDeptNames(String companyCode) {
Set<String> names = new HashSet<>();
for (Map<String, Object> d : getDepartments(companyCode, false, null)) {
Object name = d.get("dept_name");
if (name != null) names.add(name.toString().trim().toLowerCase());
}
return names;
}
/**
* 일괄등록 row 검증. null = ok. 에러 메시지 반환 해당 row error.
*/
private String validateBulkCreateRow(Map<String, Object> row, String companyCode,
Set<String> existingNames, Set<String> batchNames) {
String deptName = trimString(row.get("dept_name"));
if (deptName == null || deptName.isEmpty()) return "부서명은 필수입니다.";
String lower = deptName.toLowerCase();
if (batchNames.contains(lower)) return "동일 일괄 내 부서명 중복: " + deptName;
if (existingNames.contains(lower)) return "이미 존재하는 부서명: " + deptName;
String dt = trimString(row.get("dept_type"));
if (dt != null && !"dept".equals(dt) && !"team".equals(dt) && !"temp".equals(dt)) {
return "부서유형은 dept|team|temp 중 하나: " + dt;
}
String parent = trimString(row.get("parent_dept_code"));
String parentErr = validateParentForBulk(parent, companyCode);
if (parentErr != null) return parentErr;
String dateErr = validateDateRange(row);
if (dateErr != null) return dateErr;
String mgrErr = validateManagerIds(row, companyCode);
if (mgrErr != null) return mgrErr;
return null;
}
/**
* 일괄업데이트 row 검증. dept_code 필수 + 회사 격리 + (department mode 한정) 부서명/유형/날짜/부모 검증.
*/
private String validateBulkUpdateRow(Map<String, Object> row, String companyCode, String mode) {
String deptCode = trimString(row.get("dept_code"));
if (deptCode == null) return "부서코드(dept_code) 필수.";
Map<String, Object> existing = getDepartment(deptCode);
if (existing == null) return "부서를 찾을 수 없습니다: " + deptCode;
String deptCompanyCode = existing.get("company_code") != null
? existing.get("company_code").toString() : null;
if (!companyCode.equals(deptCompanyCode) && !"*".equals(deptCompanyCode)) {
return "다른 회사의 부서: " + deptCode;
}
if ("department".equals(mode)) {
String newName = trimString(row.get("dept_name"));
if (newName != null && !newName.isEmpty()) {
String existingName = existing.get("dept_name") != null
? existing.get("dept_name").toString().trim() : "";
if (!newName.equalsIgnoreCase(existingName)) {
Map<String, Object> dupParams = new HashMap<>();
dupParams.put("company_code", companyCode);
dupParams.put("dept_name", newName);
Map<String, Object> dup = sqlSession.selectOne("department.selectDuplicateDeptName", dupParams);
if (dup != null && !deptCode.equals(dup.get("dept_code"))) {
return "이미 존재하는 부서명: " + newName;
}
}
}
String dt = trimString(row.get("dept_type"));
if (dt != null && !"dept".equals(dt) && !"team".equals(dt) && !"temp".equals(dt)) {
return "부서유형은 dept|team|temp 중 하나: " + dt;
}
String dateErr = validateDateRange(row);
if (dateErr != null) return dateErr;
String parent = trimString(row.get("parent_dept_code"));
String parentErr = validateParentForBulk(parent, companyCode);
if (parentErr != null) return parentErr;
}
return validateManagerIds(row, companyCode);
}
private String validateParentForBulk(String parent, String companyCode) {
if (parent == null) return null;
Map<String, Object> p = sqlSession.selectOne(
"department.selectDepartmentByCodeIncludingDeleted", Map.of("dept_code", parent));
if (p == null) return "상위 부서를 찾을 수 없습니다: " + parent;
if (p.get("deleted_at") != null) return "삭제된 부서를 상위로 지정할 수 없음: " + parent;
Object pc = p.get("company_code");
if (pc == null || (!companyCode.equals(pc.toString()) && !"*".equals(pc.toString()))) {
return "다른 회사의 부서를 상위로 지정 불가: " + parent;
}
return null;
}
private String validateDateRange(Map<String, Object> row) {
String sd = trimString(row.get("start_date"));
String ed = trimString(row.get("end_date"));
if (sd != null && !sd.matches("\\d{4}-\\d{2}-\\d{2}")) return "시작일 형식 오류 (YYYY-MM-DD): " + sd;
if (ed != null && !ed.matches("\\d{4}-\\d{2}-\\d{2}")) return "종료일 형식 오류 (YYYY-MM-DD): " + ed;
if (sd != null && ed != null && sd.compareTo(ed) > 0) return "시작일이 종료일보다 늦을 수 없음.";
return null;
}
private String validateManagerIds(Map<String, Object> row, String companyCode) {
for (String key : new String[]{"approval_managers", "dept_managers", "org_leaders"}) {
Object raw = row.get(key);
if (raw instanceof List<?> list && list.size() > 10) {
return key + " 는 최대 10명까지 등록 가능합니다.";
}
}
List<String> ids = collectManagerUserIds(row);
if (ids.isEmpty()) return null;
Map<String, Object> vParams = new HashMap<>();
vParams.put("user_ids", ids);
vParams.put("company_code", companyCode);
List<String> valid = sqlSession.selectList("department.selectValidUserIds", vParams);
if (valid == null || valid.size() != ids.size()) {
Set<String> invalid = new HashSet<>(ids);
if (valid != null) invalid.removeAll(valid);
return "유효하지 않은 사용자 ID: " + invalid;
}
return null;
}
private List<String> collectManagerUserIds(Map<String, Object> row) {
List<String> ids = new ArrayList<>();
for (String key : new String[]{"approval_managers", "dept_managers", "org_leaders"}) {
Object raw = row.get(key);
if (raw instanceof List<?> list) {
for (Object item : list) {
String uid = null;
if (item instanceof Map<?, ?> m) {
Object v = m.get("user_id");
if (v != null) uid = v.toString().trim();
} else if (item != null) {
uid = item.toString().trim();
}
if (uid != null && !uid.isEmpty() && !ids.contains(uid)) ids.add(uid);
}
}
}
return ids;
}
/**
* 일괄업데이트 department mode 기존값 + row override 머지.
* row 값이 null/미지정이면 기존값 보존 (PATCH semantic).
* 매니저 매핑 키는 항상 제거 (department mode 에서는 다룸).
*/
private Map<String, Object> buildMergedDeptBody(Map<String, Object> existing, Map<String, Object> row) {
Map<String, Object> merged = new HashMap<>();
String[] textKeys = {
"dept_name", "parent_dept_code", "short_name", "dept_type", "org_system",
"approval_manager", "dept_manager", "zipcode", "address1", "address2",
"sort_order", "status", "location"
};
for (String k : textKeys) merged.put(k, existing.get(k));
merged.put("start_date", stringifyDate(existing.get("start_date")));
merged.put("end_date", stringifyDate(existing.get("end_date")));
for (Map.Entry<String, Object> e : row.entrySet()) {
String k = e.getKey();
if ("dept_code".equals(k)) continue;
if (e.getValue() == null) continue;
if ("approval_managers".equals(k) || "dept_managers".equals(k) || "org_leaders".equals(k)) continue;
merged.put(k, e.getValue());
}
return merged;
}
private String stringifyDate(Object date) {
if (date == null) return null;
String s = date.toString();
return s.length() >= 10 ? s.substring(0, 10) : null;
}
//
// 부서원 관리
//
@@ -472,6 +824,108 @@ public class DepartmentService extends BaseService {
return value;
}
// 관리자 매핑 sync
private static final com.fasterxml.jackson.databind.ObjectMapper JSON_MAPPER =
new com.fasterxml.jackson.databind.ObjectMapper();
private static final int MAX_MANAGERS_JSON_BYTES = 64 * 1024;
private void parseManagersJson(Map<String, Object> dept, String key) {
Object raw = dept.get(key);
if (raw == null) {
dept.put(key, new java.util.ArrayList<Map<String, Object>>());
return;
}
String s = raw.toString();
if (s.length() > MAX_MANAGERS_JSON_BYTES) {
log.warn("parseManagersJson 크기 초과 dept_code={} key={} len={}",
dept.get("dept_code"), key, s.length());
dept.put(key, new java.util.ArrayList<Map<String, Object>>());
return;
}
try {
@SuppressWarnings("unchecked")
java.util.List<Map<String, Object>> parsed = JSON_MAPPER.readValue(s,
new com.fasterxml.jackson.core.type.TypeReference<java.util.List<Map<String, Object>>>() {});
dept.put(key, parsed);
} catch (Exception e) {
log.warn("parseManagersJson 실패 dept_code={} key={} err={}",
dept.get("dept_code"), key, e.getMessage());
dept.put(key, new java.util.ArrayList<Map<String, Object>>());
}
}
/**
* 부서 관리자 role 단위 sync 항상 delete-all + insert-all 패턴.
* body 키는 (role ): "approval_managers" / "dept_managers" / "org_leaders".
* 값은 List&lt;Map&gt; 형태이며 element 에서 "user_id" 추출.
* 최대 10명 검증 + user_id 무시.
*/
private void syncManagers(String deptCode, String companyCode, Map<String, Object> body, String role) {
String bodyKey = switch (role) {
case "approval" -> "approval_managers";
case "dept" -> "dept_managers";
case "org_leader" -> "org_leaders";
default -> throw new IllegalArgumentException("Unknown role: " + role);
};
// PUT partial update: 키가 명시적으로 존재할 때만 sync.
// body 자체가 없으면 기존 매핑 보존 (partial update 의도).
if (!body.containsKey(bodyKey)) {
return;
}
Object raw = body.get(bodyKey);
java.util.List<String> userIds = new java.util.ArrayList<>();
if (raw instanceof java.util.List<?> list) {
for (Object item : list) {
String uid = null;
if (item instanceof Map<?, ?> m) {
Object v = m.get("user_id");
if (v != null) uid = v.toString().trim();
} else if (item != null) {
uid = item.toString().trim();
}
if (uid != null && !uid.isEmpty() && !userIds.contains(uid)) {
userIds.add(uid);
}
}
}
if (userIds.size() > 10) {
String roleLabel = switch (role) {
case "approval" -> "결재 관리자";
case "dept" -> "부서 관리자";
case "org_leader" -> "조직장";
default -> role;
};
throw new IllegalArgumentException(roleLabel + " 는 최대 10명까지 등록 가능합니다.");
}
// user_id 같은 회사 (or '*') 실존하는지 검증 cross-tenant 차단
if (!userIds.isEmpty()) {
Map<String, Object> vParams = new HashMap<>();
vParams.put("user_ids", userIds);
vParams.put("company_code", companyCode);
List<String> validUserIds = sqlSession.selectList("department.selectValidUserIds", vParams);
if (validUserIds == null || validUserIds.size() != userIds.size()) {
Set<String> invalid = new HashSet<>(userIds);
if (validUserIds != null) invalid.removeAll(validUserIds);
throw new IllegalArgumentException("유효하지 않은 사용자 ID: " + invalid);
}
}
// delete-all
Map<String, Object> delParams = new HashMap<>();
delParams.put("dept_code", deptCode);
delParams.put("role", role);
sqlSession.delete("department.deleteDeptManagersByDeptAndRole", delParams);
// insert-all
if (!userIds.isEmpty()) {
Map<String, Object> insParams = new HashMap<>();
insParams.put("dept_code", deptCode);
insParams.put("role", role);
insParams.put("user_ids", userIds);
sqlSession.insert("department.insertDeptManagers", insParams);
}
}
// 중복 예외 클래스
public static class DuplicateDeptNameException extends RuntimeException {
@@ -101,20 +101,20 @@ public class EntityReferenceService extends BaseService {
}
public Map<String, Object> getCodeData(Map<String, Object> params) {
String codeCategory = (String) params.get("code_category");
String codeInfo = (String) params.get("code_info");
String companyCode = (String) params.get("company_code");
int limit = toInt(params.getOrDefault("limit", 100));
Object search = params.get("search");
Map<String, Object> queryParams = new HashMap<>();
queryParams.put("code_category", codeCategory);
queryParams.put("code_info", codeInfo);
queryParams.put("company_code", companyCode);
queryParams.put("limit", limit);
if (search != null && !search.toString().isBlank()) {
queryParams.put("search_like", "%" + search + "%");
}
log.info("공통 코드 데이터 조회: category={}, company={}", codeCategory, companyCode);
log.info("공통 코드 데이터 조회: category={}, company={}", codeInfo, companyCode);
List<Map<String, Object>> rows = sqlSession.selectList(NS + "selectCodeData", queryParams);
@@ -128,7 +128,7 @@ public class EntityReferenceService extends BaseService {
Map<String, Object> result = new LinkedHashMap<>();
result.put("options", options);
result.put("code_category", codeCategory);
result.put("code_info", codeInfo);
return result;
}
@@ -394,12 +394,12 @@ public class EntitySearchService extends BaseService {
Map<String, Object> ttcp = new HashMap<>();
ttcp.put("table_name", tableName);
ttcp.put("column_name", columnName);
Map<String, Object> ttcRow = sqlSession.selectOne(NS + "getCodeCategoryInfo", ttcp);
String codeCategory = ttcRow != null ? (String) ttcRow.get("code_category") : null;
Map<String, Object> ttcRow = sqlSession.selectOne(NS + "getCodeInfoInfo", ttcp);
String codeInfo = ttcRow != null ? (String) ttcRow.get("code_info") : null;
if (codeCategory != null) {
if (codeInfo != null) {
Map<String, Object> cip = new HashMap<>();
cip.put("code_category", codeCategory);
cip.put("code_info", codeInfo);
cip.put("raw_values", rawValues);
cip.put("company_code", companyCode);
List<Map<String, Object>> ciRows = sqlSession.selectList(NS + "getCodeInfoList", cip);
@@ -297,29 +297,61 @@ public class ExternalDbConnectionService extends BaseService {
private Map<String, Object> executeConnectionTest(
String dbType, Map<String, Object> conn, String password) {
String type = dbType == null ? "" : dbType.toLowerCase();
String host = str(conn, "host");
int port = toInt(conn, "port", 5432);
int port = toInt(conn, "port", defaultPort(type));
String database = str(conn, "database_name");
String username = str(conn, "username");
String sslEnabled = str(conn, "ssl_enabled");
int connTimeout = toInt(conn, "connection_timeout", 30);
boolean ssl = "Y".equalsIgnoreCase(sslEnabled);
if (!"postgresql".equalsIgnoreCase(dbType)) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("success", false);
result.put("message", "이 버전에서는 PostgreSQL 연결만 테스트가 지원됩니다.");
return result;
}
String url = String.format("jdbc:postgresql://%s:%d/%s", host, port, database);
String url;
Properties props = new Properties();
props.setProperty("user", username);
props.setProperty("password", password);
props.setProperty("connect_timeout", String.valueOf(connTimeout));
props.setProperty("socket_timeout", "30");
if ("Y".equalsIgnoreCase(sslEnabled)) {
props.setProperty("ssl", "true");
props.setProperty("sslmode", "require");
if (username != null) props.setProperty("user", username);
if (password != null) props.setProperty("password", password);
switch (type) {
case "postgresql" -> {
url = String.format("jdbc:postgresql://%s:%d/%s", host, port, database);
props.setProperty("connect_timeout", String.valueOf(connTimeout));
props.setProperty("socket_timeout", "30");
if (ssl) {
props.setProperty("ssl", "true");
props.setProperty("sslmode", "require");
}
}
case "mysql" -> {
url = String.format("jdbc:mysql://%s:%d/%s", host, port, database);
props.setProperty("connectTimeout", String.valueOf(connTimeout * 1000));
props.setProperty("socketTimeout", "30000");
props.setProperty("useSSL", String.valueOf(ssl));
props.setProperty("allowPublicKeyRetrieval", "true");
}
case "mariadb" -> {
url = String.format("jdbc:mariadb://%s:%d/%s", host, port, database);
props.setProperty("connectTimeout", String.valueOf(connTimeout * 1000));
props.setProperty("socketTimeout", "30000");
if (ssl) props.setProperty("useSsl", "true");
}
case "mssql", "sqlserver" -> {
StringBuilder sb = new StringBuilder()
.append("jdbc:sqlserver://").append(host).append(':').append(port)
.append(";databaseName=").append(database)
.append(";loginTimeout=").append(connTimeout)
.append(";encrypt=").append(ssl ? "true;trustServerCertificate=true" : "false");
url = sb.toString();
}
case "sqlite" -> {
// SQLite: host/port 무의미. database_name 파일 경로로 사용 (비면 in-memory)
url = "jdbc:sqlite:" + (database != null && !database.isBlank() ? database : ":memory:");
}
default -> {
Map<String, Object> result = new LinkedHashMap<>();
result.put("success", false);
result.put("message", "지원하지 않는 DB 타입입니다: " + dbType);
return result;
}
}
try (Connection c = DriverManager.getConnection(url, props);
@@ -328,7 +360,11 @@ public class ExternalDbConnectionService extends BaseService {
Map<String, Object> result = new LinkedHashMap<>();
result.put("success", true);
result.put("message", "연결 성공");
result.put("details", Map.of("host", host, "database", database, "port", port));
Map<String, Object> details = new LinkedHashMap<>();
details.put("host", host == null ? "" : host);
details.put("database", database == null ? "" : database);
details.put("port", port);
result.put("details", details);
return result;
} catch (SQLException e) {
log.warn("DB 연결 테스트 실패 ({}): {}", url, e.getMessage());
@@ -342,6 +378,16 @@ public class ExternalDbConnectionService extends BaseService {
}
}
private int defaultPort(String dbType) {
if (dbType == null) return 5432;
return switch (dbType.toLowerCase()) {
case "mysql", "mariadb" -> 3306;
case "mssql", "sqlserver" -> 1433;
case "sqlite" -> 0;
default -> 5432;
};
}
// SQL 쿼리 실행 (SELECT only)
public Map<String, Object> executeQuery(long id, String sql) {
@@ -189,7 +189,14 @@ public class NumberingRuleService extends BaseService {
return allocateCode(ruleId, companyCode, null, null);
}
/** POST /:ruleId/reset → 순번 초기화 */
/**
* POST /:ruleId/reset 순번 초기화 (admin)
*
* 테이블 처리:
* 1. numbering_rule_sequences (prefix 발번 카운터, 실제 ground truth) 전체 DELETE 다음 발번 1 부터
* 2. numbering_rules.current_sequence (표시용) 직접 0 으로 set
* - admin 전용 SQL `setCurrentSequenceInRule` 사용 (GREATEST 없음)
*/
@Transactional
public void resetSequence(String ruleId, String companyCode) {
Map<String, Object> params = new HashMap<>();
@@ -197,10 +204,32 @@ public class NumberingRuleService extends BaseService {
params.put("company_code", companyCode);
params.put("current_sequence", 0);
sqlSession.delete(NS + "deleteSequencesByRuleId", params);
sqlSession.update(NS + "updateCurrentSequenceInRule", params);
sqlSession.update(NS + "setCurrentSequenceInRule", params);
log.info("시퀀스 초기화 완료: ruleId={}, companyCode={}", ruleId, companyCode);
}
/**
* PUT /:ruleId/sequence 현재 시퀀스 임의 값으로 수정 (admin)
*
* admin "지금 카운터를 N 으로 set" 의도. 다음 발번은 N+1 부터.
* 테이블 처리:
* 1. numbering_rule_sequences (prefix 실제 카운터) 전체 DELETE
* 다음 allocate row INSERT (current_sequence=1) 되거나
* 또는 admin set 값을 기반으로 시작하도록 별도 처리 필요할 있음
* - 운영 단계라 historical sequence 폐기 안전
* 2. numbering_rules.current_sequence newSequence set
*/
@Transactional
public void updateRuleSequence(String ruleId, Integer newSequence, String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("rule_id", ruleId);
params.put("company_code", companyCode);
params.put("current_sequence", newSequence);
sqlSession.delete(NS + "deleteSequencesByRuleId", params);
sqlSession.update(NS + "setCurrentSequenceInRule", params);
log.info("시퀀스 수정 완료: ruleId={}, newSequence={}, companyCode={}", ruleId, newSequence, companyCode);
}
// ================================================================
// Available Rules
// ================================================================
@@ -426,12 +455,31 @@ public class NumberingRuleService extends BaseService {
return seq == null ? 0L : ((Number) seq).longValue();
}
/** 순번 증가 UPSERT ON CONFLICT DO UPDATE RETURNING */
/**
* 순번 증가 UPSERT ON CONFLICT DO UPDATE RETURNING.
*
* INSERT 분기의 base :
* - 동일 prefix row 없을 ( 발번 / admin reset / 카테고리 )
* `numbering_rules.current_sequence + 1` 부터 시작.
* - 의미: admin sequence N 으로 set 하고 historical sequences 비웠을 ,
* 다음 발번이 N+1 부터 정확히 시작되도록.
* - numbering_rules row 없는 비정상 케이스는 0+1=1.
*/
private long incrementSequenceForPrefix(String ruleId, String companyCode, String prefixKey) {
String sql = """
INSERT INTO numbering_rule_sequences
(rule_id, company_code, prefix_key, current_sequence, last_allocated_at)
VALUES (?, ?, ?, 1, NOW())
VALUES (
?, ?, ?,
COALESCE((
SELECT current_sequence
FROM numbering_rules
WHERE rule_id = ?
AND (company_code = ? OR company_code = '*')
LIMIT 1
), 0) + 1,
NOW()
)
ON CONFLICT (rule_id, company_code, prefix_key)
DO UPDATE SET
current_sequence = numbering_rule_sequences.current_sequence + 1,
@@ -439,7 +487,7 @@ public class NumberingRuleService extends BaseService {
RETURNING current_sequence
""";
Long newSeq = jdbcTemplate.queryForObject(sql, Long.class,
ruleId, companyCode, prefixKey);
ruleId, companyCode, prefixKey, ruleId, companyCode);
return newSeq != null ? newSeq : 1L;
}
@@ -16,6 +16,18 @@ public class ScreenGroupService extends BaseService {
private static final String NS = "screenGroup.";
/**
* canonical table / legacy table-list / hidden v2-table-list 위젯 카운트 합산.
* screen type inference 모두 grid 화면으로 인식해야 한다 (frontend
* isTableLikeComponentType 동일 정책 2026-05-19 canonical cleanup follow-up).
*/
private static int countTableLikeWidgets(Map<String, Integer> widgetCounts) {
if (widgetCounts == null) return 0;
return widgetCounts.getOrDefault("table", 0)
+ widgetCounts.getOrDefault("table-list", 0)
+ widgetCounts.getOrDefault("v2-table-list", 0);
}
//
// Screen Groups
//
@@ -356,8 +368,10 @@ public class ScreenGroupService extends BaseService {
}
// 화면 타입 추론
// table-like (canonical 'table' / legacy 'table-list' / hidden 'v2-table-list')
// 어느 것이든 있으면 grid 본다.
String screenType = "form";
if (widgetCounts.getOrDefault("table", 0) > 0) {
if (countTableLikeWidgets(widgetCounts) > 0) {
screenType = "grid";
} else if (widgetCounts.getOrDefault("custom", 0) > 2) {
screenType = "dashboard";
@@ -433,11 +447,11 @@ public class ScreenGroupService extends BaseService {
if (bottomEdge > toInt(summary.get("canvas_height"))) summary.put("canvas_height", bottomEdge);
}
// 화면 타입 추론
// 화면 타입 추론 canonical / legacy / hidden v2 모두 grid 인식
summaryMap.values().forEach(summary -> {
@SuppressWarnings("unchecked")
Map<String, Integer> wc = (Map<String, Integer>) summary.get("widget_counts");
if (wc.getOrDefault("table-list", 0) > 0) {
if (countTableLikeWidgets(wc) > 0) {
summary.put("screen_type", "grid");
} else if (wc.getOrDefault("table-search-widget", 0) > 1) {
summary.put("screen_type", "dashboard");
@@ -994,7 +994,7 @@ public class ScreenManagementService extends BaseService {
}
@Transactional
public int copyCodeCategoryAndCodes(Map<String, Object> body) {
public int copyCodeInfoAndCodes(Map<String, Object> body) {
String sourceCompanyCode = (String) body.get("source_company_code");
String targetCompanyCode = (String) body.get("target_company_code");
String userId = (String) body.get("user_id");
@@ -1002,16 +1002,16 @@ public class ScreenManagementService extends BaseService {
Map<String, Object> params = new HashMap<>();
params.put("source_company_code", sourceCompanyCode);
List<Map<String, Object>> categories = sqlSession.selectList(NS + "selectCodeCategoryForCopy", params);
List<Map<String, Object>> categories = sqlSession.selectList(NS + "selectCodeInfoForCopy", params);
int count = 0;
for (Map<String, Object> cat : categories) {
Map<String, Object> cp = new HashMap<>(cat);
cp.put("target_company_code", targetCompanyCode);
sqlSession.insert(NS + "upsertCodeCategory", cp);
sqlSession.insert(NS + "upsertCodeInfo", cp);
Map<String, Object> codeParams = new HashMap<>();
codeParams.put("source_company_code", sourceCompanyCode);
codeParams.put("code_category", cat.get("category_code"));
codeParams.put("code_info", cat.get("category_code"));
List<Map<String, Object>> codes = sqlSession.selectList(NS + "selectCodeInfoForCopy", codeParams);
for (Map<String, Object> code : codes) {
Map<String, Object> cop = new HashMap<>(code);
@@ -1,368 +0,0 @@
package com.erp.service;
import com.erp.common.BaseService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.stream.Collectors;
@Service
@Slf4j
public class TableCategoryValueService extends BaseService {
private static final String NS = "tableCategoryValue.";
//
// Category Columns
//
public List<Map<String, Object>> getCategoryColumns(Map<String, Object> params) {
log.info("카테고리 컬럼 목록 조회: tableName={}, companyCode={}",
params.get("table_name"), params.get("company_code"));
return sqlSession.selectList(NS + "getCategoryColumnList", params);
}
public List<Map<String, Object>> getAllCategoryColumns(Map<String, Object> params) {
log.info("전체 카테고리 컬럼 목록 조회: companyCode={}", params.get("company_code"));
return sqlSession.selectList(NS + "getAllCategoryColumnList", params);
}
//
// Category Values Read
//
public List<Map<String, Object>> getCategoryValues(Map<String, Object> params) {
log.info("카테고리 값 목록 조회: tableName={}, columnName={}, companyCode={}",
params.get("table_name"), params.get("column_name"), params.get("company_code"));
List<Map<String, Object>> flatList = sqlSession.selectList(NS + "getCategoryValueList", params);
List<Map<String, Object>> hierarchy = buildHierarchy(flatList, null);
log.info("카테고리 값 {}개 조회 완료 (평면)", flatList.size());
return hierarchy;
}
//
// Category Values Write
//
@Transactional
public Map<String, Object> addCategoryValue(Map<String, Object> params) {
String tableName = (String) params.get("table_name");
String columnName = (String) params.get("column_name");
String valueCode = (String) params.get("value_code");
String valueLabel = (String) params.get("value_label");
String companyCode = (String) params.get("company_code");
log.info("카테고리 값 추가: tableName={}, columnName={}, valueCode={}, companyCode={}",
tableName, columnName, valueCode, companyCode);
Integer codeDup = sqlSession.selectOne(NS + "countDuplicateCode", params);
if (codeDup != null && codeDup > 0) {
throw new IllegalArgumentException("이미 존재하는 코드입니다");
}
Integer labelDup = sqlSession.selectOne(NS + "countDuplicateLabel", params);
if (labelDup != null && labelDup > 0) {
throw new IllegalArgumentException(
"이미 동일한 이름의 카테고리 값이 존재합니다: \"" + valueLabel + "\"");
}
if (params.get("value_order") == null) params.put("value_order", 0);
if (params.get("depth") == null) params.put("depth", 1);
if (params.get("is_active") == null) params.put("is_active", true);
if (params.get("is_default") == null) params.put("is_default", false);
sqlSession.insert(NS + "insertCategoryValue", params);
long valueId = toLong(params.get("value_id"));
log.info("카테고리 값 추가 완료: valueId={}", valueId);
Map<String, Object> fetchP = new HashMap<>();
fetchP.put("value_id", valueId);
return sqlSession.selectOne(NS + "getCategoryValueInfo", fetchP);
}
@Transactional
public Map<String, Object> updateCategoryValue(Map<String, Object> params) {
long valueId = toLong(params.get("value_id"));
String companyCode = (String) params.get("company_code");
log.info("카테고리 값 수정: valueId={}, companyCode={}", valueId, companyCode);
if (params.get("value_label") != null) {
Map<String, Object> current = sqlSession.selectOne(NS + "getCategoryValueLabelInfo",
Map.of("value_id", valueId));
if (current != null) {
Map<String, Object> labelP = new HashMap<>();
labelP.put("table_name", current.get("table_name"));
labelP.put("column_name", current.get("column_name"));
labelP.put("company_code", current.get("company_code"));
labelP.put("value_label", params.get("value_label"));
labelP.put("value_id", valueId);
Integer dup = sqlSession.selectOne(NS + "countDuplicateLabelExcludeSelf", labelP);
if (dup != null && dup > 0) {
throw new IllegalArgumentException(
"이미 동일한 이름의 카테고리 값이 존재합니다: \""
+ params.get("value_label") + "\"");
}
}
}
params.put("value_id", valueId);
Integer rows = sqlSession.selectOne(NS + "updateCategoryValue", params);
if (rows == null || rows == 0) {
// update returns affected rows via selectOne workaround; use update method instead
sqlSession.update(NS + "updateCategoryValue", params);
}
Map<String, Object> fetchP = new HashMap<>();
fetchP.put("value_id", valueId);
return sqlSession.selectOne(NS + "getCategoryValueInfo", fetchP);
}
//
// Category Values Delete
//
@Transactional
public void deleteCategoryValue(Map<String, Object> params) {
long valueId = toLong(params.get("value_id"));
String companyCode = (String) params.get("company_code");
log.info("카테고리 값 삭제: valueId={}, companyCode={}", valueId, companyCode);
List<Map<String, Object>> childRows = sqlSession.selectList(NS + "getChildValueIdList", params);
List<Long> allIds = new ArrayList<>();
allIds.add(valueId);
childRows.forEach(r -> allIds.add(toLong(r.get("value_id"))));
log.info("삭제 대상 카테고리 값 수집 완료: 자신={}, 하위={}", valueId, childRows.size());
for (Long id : allIds) {
checkNotInUse(id, companyCode);
}
List<Long> reversed = new ArrayList<>(allIds);
Collections.reverse(reversed);
for (Long id : reversed) {
Map<String, Object> delP = new HashMap<>();
delP.put("value_id", id);
delP.put("company_code", companyCode);
sqlSession.delete(NS + "deleteValueById", delP);
}
log.info("카테고리 값 삭제 완료: totalDeleted={}", allIds.size());
}
@Transactional
public void bulkDeleteCategoryValues(Map<String, Object> params) {
log.info("카테고리 값 일괄 삭제: count={}, companyCode={}",
((List<?>) params.get("value_ids")).size(), params.get("company_code"));
sqlSession.update(NS + "bulkSoftDeleteValues", params);
}
@Transactional
public void reorderCategoryValues(Map<String, Object> params) {
List<?> rawIds = (List<?>) params.get("ordered_value_ids");
String companyCode = (String) params.get("company_code");
log.info("카테고리 값 순서 변경: count={}, companyCode={}", rawIds.size(), companyCode);
for (int i = 0; i < rawIds.size(); i++) {
Map<String, Object> p = new HashMap<>();
p.put("value_id", toLong(rawIds.get(i)));
p.put("value_order", i + 1);
p.put("company_code", companyCode);
sqlSession.update(NS + "updateValueOrder", p);
}
}
//
// Column Mapping
//
public Map<String, Object> getColumnMapping(Map<String, Object> params) {
log.info("컬럼 매핑 조회: tableName={}, menuObjid={}, companyCode={}",
params.get("table_name"), params.get("menu_objid"), params.get("company_code"));
List<Map<String, Object>> rows = sqlSession.selectList(NS + "getColumnMappingList", params);
Map<String, Object> mapping = new LinkedHashMap<>();
for (Map<String, Object> row : rows) {
mapping.put(String.valueOf(row.get("logical_column_name")),
String.valueOf(row.get("physical_column_name")));
}
log.info("컬럼 매핑 {}개 조회 완료", mapping.size());
return mapping;
}
@Transactional
public Map<String, Object> createColumnMapping(Map<String, Object> params) {
String tableName = (String) params.get("table_name");
String logicalColumnName = (String) params.get("logical_column_name");
String physicalColumnName = (String) params.get("physical_column_name");
log.info("컬럼 매핑 생성: tableName={}, logical={}, physical={}, companyCode={}",
tableName, logicalColumnName, physicalColumnName, params.get("company_code"));
Integer colExists = sqlSession.selectOne(NS + "checkPhysicalColumnExists", params);
if (colExists == null || colExists == 0) {
throw new IllegalArgumentException(
"테이블 " + tableName + "에 컬럼 " + physicalColumnName + "이(가) 존재하지 않습니다");
}
sqlSession.insert(NS + "upsertColumnMapping", params);
Map<String, Object> result = sqlSession.selectOne(NS + "getColumnMappingInfo", params);
log.info("컬럼 매핑 생성 완료: mappingId={}", result != null ? result.get("mapping_id") : "?");
return result;
}
public List<Map<String, Object>> getLogicalColumns(Map<String, Object> params) {
log.info("논리적 컬럼 목록 조회: tableName={}, menuObjid={}, companyCode={}",
params.get("table_name"), params.get("menu_objid"), params.get("company_code"));
return sqlSession.selectList(NS + "getLogicalColumnList", params);
}
@Transactional
public void deleteColumnMapping(Map<String, Object> params) {
int deleted = sqlSession.delete(NS + "deleteColumnMappingById", params);
if (deleted == 0) {
throw new IllegalArgumentException("컬럼 매핑을 찾을 수 없거나 권한이 없습니다");
}
log.info("컬럼 매핑 삭제 완료: mappingId={}", params.get("mapping_id"));
}
@Transactional
public int deleteColumnMappingsByColumn(Map<String, Object> params) {
int deleted = sqlSession.delete(NS + "deleteColumnMappingsByColumn", params);
log.info("테이블+컬럼 기준 매핑 삭제 완료: tableName={}, columnName={}, deletedCount={}",
params.get("table_name"), params.get("column_name"), deleted);
return deleted;
}
//
// Labels by Codes
//
public Map<String, Object> getCategoryLabelsByCodes(Map<String, Object> params) {
Object rawCodes = params.get("value_codes");
if (!(rawCodes instanceof List) || ((List<?>) rawCodes).isEmpty()) {
return new LinkedHashMap<>();
}
log.info("카테고리 코드로 라벨 조회: count={}, companyCode={}",
((List<?>) rawCodes).size(), params.get("company_code"));
List<Map<String, Object>> rows = sqlSession.selectList(NS + "getLabelListByCodes", params);
Map<String, Object> labels = new LinkedHashMap<>();
for (Map<String, Object> row : rows) {
String code = String.valueOf(row.get("value_code"));
if (!labels.containsKey(code)) {
labels.put(code, row.get("value_label"));
}
}
log.info("카테고리 라벨 {}개 조회 완료", labels.size());
return labels;
}
//
// Second-Level Menus
//
public List<Map<String, Object>> getSecondLevelMenus(Map<String, Object> params) {
log.info("2레벨 메뉴 목록 조회: companyCode={}", params.get("company_code"));
Integer hasCC = sqlSession.selectOne(NS + "checkMenuInfoHasCompanyCode", null);
params.put("has_company_code", hasCC != null && hasCC > 0);
log.info("menu_info.company_code 컬럼 존재 여부: {}", hasCC != null && hasCC > 0);
List<Map<String, Object>> menus = sqlSession.selectList(NS + "getSecondLevelMenuList", params);
log.info("2레벨 메뉴 {}개 조회 완료", menus.size());
return menus;
}
//
// private helpers
//
private void checkNotInUse(long valueId, String companyCode) {
Map<String, Object> p = new HashMap<>();
p.put("value_id", valueId);
p.put("company_code", companyCode);
Map<String, Object> valueInfo = sqlSession.selectOne(NS + "getCategoryValueUsageInfo", p);
if (valueInfo == null) {
throw new IllegalArgumentException("카테고리 값을 찾을 수 없습니다");
}
String tableName = String.valueOf(valueInfo.get("table_name"));
String columnName = String.valueOf(valueInfo.get("column_name"));
String valueCode = String.valueOf(valueInfo.get("value_code"));
String valueLabel = String.valueOf(valueInfo.get("value_label"));
String safeTable = sanitize(tableName);
String safeColumn = sanitize(columnName);
if (safeTable.isEmpty() || safeColumn.isEmpty()) return;
Integer tableExists = sqlSession.selectOne(NS + "checkTableExistsForUsage",
Map.of("table_name", safeTable));
if (tableExists == null || tableExists == 0) return;
Map<String, Object> countP = new HashMap<>();
countP.put("safe_table_name", safeTable);
countP.put("safe_column_name", safeColumn);
countP.put("value_code", valueCode);
countP.put("company_code", companyCode);
Integer count = sqlSession.selectOne(NS + "countValueUsageInTable", countP);
if (count != null && count > 0) {
List<Map<String, Object>> menus = sqlSession.selectList(NS + "getMenuListUsingTable",
Map.of("table_name", tableName, "company_code", companyCode));
StringBuilder msg = new StringBuilder();
msg.append("카테고리 \"").append(valueLabel).append("\"을(를) 삭제할 수 없습니다.\n");
msg.append("\n현재 ").append(count).append("개의 데이터에서 사용 중입니다.");
if (!menus.isEmpty()) {
String menuNames = menus.stream()
.map(m -> String.valueOf(m.get("menu_name")))
.collect(Collectors.joining(", "));
msg.append("\n\n다음 메뉴에서 사용 중입니다:\n").append(menuNames);
}
msg.append("\n\n메뉴에서 사용하는 카테고리 항목을 수정한 후 다시 삭제해주세요.");
throw new IllegalArgumentException(msg.toString());
}
}
private List<Map<String, Object>> buildHierarchy(
List<Map<String, Object>> values, Object parentId) {
List<Map<String, Object>> result = new ArrayList<>();
for (Map<String, Object> v : values) {
Object pid = v.get("parent_value_id");
if (Objects.equals(pid, parentId)) {
List<Map<String, Object>> children = buildHierarchy(values, v.get("value_id"));
v.put("children", children);
result.add(v);
}
}
return result;
}
private String sanitize(String name) {
if (name == null) return "";
return name.replaceAll("[^a-zA-Z0-9_]", "");
}
private long toLong(Object val) {
if (val == null) return 0L;
if (val instanceof Number) return ((Number) val).longValue();
try { return Long.parseLong(val.toString()); } catch (NumberFormatException e) { return 0L; }
}
}
@@ -1,6 +1,8 @@
package com.erp.service;
import com.erp.common.BaseService;
import com.erp.constants.InputTypeConstants;
import com.erp.constants.InputTypeContext;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -26,10 +28,14 @@ public class TableManagementService extends BaseService {
private static final String NS = "tableManagement.";
/** 사용자가 직접 선택 가능한 INPUT_TYPE 8종 (INSERT/UPDATE-type 검증용) */
private static final Set<String> USER_SELECTABLE_INPUT_TYPES = Set.of(
"text", "number", "date", "code", "entity",
"numbering", "file", "image"
/** 로그 테이블 컬럼 정의에 허용하는 PostgreSQL data_type 화이트리스트.
* information_schema.columns.data_type 값과 정확히 일치해야 한다. */
private static final Set<String> ALLOWED_LOG_COLUMN_TYPES = Set.of(
"varchar", "text", "char", "character", "character varying",
"integer", "bigint", "smallint", "numeric", "decimal", "real", "double precision",
"boolean", "date", "timestamp", "timestamp without time zone", "timestamp with time zone",
"time", "time without time zone", "time with time zone",
"uuid", "json", "jsonb", "bytea"
);
//
@@ -151,22 +157,40 @@ public class TableManagementService extends BaseService {
Map<String, Object> settings, String companyCode) {
ensureTableInLabels(tableName);
boolean inputTypeChanged = settings.containsKey("input_type");
String ctx = inputTypeChanged ? "user-update-type" : "user-update-other";
String inputType = normalizeInputType((String) settings.get("input_type"), ctx);
Object rawInputType = settings.get("input_type");
boolean inputTypeChanged = settings.containsKey("input_type") && rawInputType != null;
InputTypeContext ctx = inputTypeChanged
? InputTypeContext.USER_UPDATE_TYPE
: InputTypeContext.USER_UPDATE_OTHER;
String inputType = normalizeInputType((String) rawInputType, ctx);
Map<String, Object> params = new HashMap<>();
params.put("table_name", tableName);
params.put("column_name", columnName);
params.put("column_label", settings.get("column_label"));
params.put("input_type", inputType);
params.put("detail_settings", toJsonString(settings.get("detail_settings")));
params.put("code_category", "code".equals(inputType) ? settings.get("code_category") : null);
params.put("code_info", "code".equals(inputType) ? settings.get("code_info") : null);
params.put("code_value", "code".equals(inputType) ? settings.get("code_value") : null);
params.put("reference_table", "entity".equals(inputType) ? settings.get("reference_table") : null);
params.put("reference_column", "entity".equals(inputType) ? settings.get("reference_column") : null);
params.put("display_column", "entity".equals(inputType) ? settings.get("display_column") : null);
params.put("display_order", settings.getOrDefault("display_order", 0));
params.put("is_visible", settings.getOrDefault("is_visible", true));
// is_nullable: 'Y'/'N' 또는 null. null 이면 mapper COALESCE 기존 유지.
Object rawIsNullable = settings.get("is_nullable");
if (rawIsNullable != null) {
String s = rawIsNullable.toString();
// 프론트가 'YES'/'NO' 또는 'Y'/'N' 어느 쪽이든 보낼 있어 정규화
if ("NO".equalsIgnoreCase(s) || "N".equalsIgnoreCase(s) || "FALSE".equalsIgnoreCase(s)) {
params.put("is_nullable", "N");
} else if ("YES".equalsIgnoreCase(s) || "Y".equalsIgnoreCase(s) || "TRUE".equalsIgnoreCase(s)) {
params.put("is_nullable", "Y");
} else {
params.put("is_nullable", null);
}
} else {
params.put("is_nullable", null);
}
params.put("company_code", companyCode);
params.put("category_ref", "category".equals(inputType) ? settings.get("category_ref") : null);
sqlSession.update(NS + "upsertColumnSettings", params);
@@ -191,26 +215,28 @@ public class TableManagementService extends BaseService {
@Transactional
public void updateColumnWebType(String tableName, String columnName,
String webType, Map<String, Object> detailSettings) {
String webType, Map<String, Object> detailSettings,
String companyCode) {
String finalType = normalizeInputType(webType);
Map<String, Object> params = new HashMap<>();
params.put("table_name", tableName);
params.put("column_name", columnName);
params.put("input_type", finalType);
params.put("detail_settings", detailSettings != null ? toJsonString(detailSettings) : "{}");
params.put("company_code", "*");
// 멀티테넌트 격리: SUPER_ADMIN("*") 공통 설정, 외는 회사별 설정
params.put("company_code", companyCode != null ? companyCode : "*");
params.put("clear_entity", false);
params.put("clear_code", false);
params.put("clear_category", false);
sqlSession.update(NS + "upsertColumnInputType", params);
log.info("컬럼 웹타입 설정: {}.{} = {}", tableName, columnName, finalType);
log.info("컬럼 웹타입 설정: {}.{} = {} (company={})", tableName, columnName, finalType, companyCode);
}
@Transactional
public void updateColumnInputType(String tableName, String columnName,
String inputType, String companyCode,
Map<String, Object> detailSettings) {
String finalType = normalizeInputType(inputType, "user-update-type");
String finalType = normalizeInputType(inputType, InputTypeContext.USER_UPDATE_TYPE);
Map<String, Object> params = new HashMap<>();
params.put("table_name", tableName);
params.put("column_name", columnName);
@@ -374,12 +400,14 @@ public class TableManagementService extends BaseService {
String safeTable = sanitize(tableName);
List<String> violations = new ArrayList<>();
// N+N N+1 최적화: hasColumn information_schema 조회라 비싸. 루프 밖에서 번만 수행.
boolean hasCompanyCode = hasColumn(safeTable, "company_code");
for (Map<String, Object> col : uniqueCols) {
String colName = (String) col.get("column_name");
Object val = data.get(colName);
if (val == null) continue;
boolean hasCompanyCode = hasColumn(safeTable, "company_code");
String sql;
List<Object> sqlParams = new ArrayList<>();
@@ -463,6 +491,369 @@ public class TableManagementService extends BaseService {
return result;
}
//
// 동적 테이블 집계 (count / sum / avg / min / max / distinctCount)
//
private static final Set<String> AGG_TYPES = Set.of(
"count", "sum", "avg", "min", "max", "distinctCount"
);
private static final Set<String> FILTER_OPS = Set.of(
"=", "!=", ">", "<", ">=", "<=",
"like", "in", "notIn", "isNull", "isNotNull"
);
/**
* 단일 집계 계산.
*
* count column 없이도 동작 (COUNT(*))
* sum/avg/min/max column 필수
* distinctCount column 필수 (COUNT(DISTINCT col))
*/
public Map<String, Object> aggregateTableData(String tableName, Map<String, Object> options) {
String safeTable = sanitize(tableName);
if (safeTable.isBlank() || !checkTableExists(safeTable)) {
throw new IllegalArgumentException("테이블이 존재하지 않습니다: " + tableName);
}
String aggregation = options.get("aggregation") instanceof String s ? s : "count";
if (!AGG_TYPES.contains(aggregation)) {
throw new IllegalArgumentException("지원하지 않는 집계 타입: " + aggregation);
}
String columnName = options.get("columnName") instanceof String s ? s : null;
String safeColumn = columnName != null ? sanitize(columnName) : "";
boolean columnRequired = !"count".equals(aggregation);
if (columnRequired) {
if (safeColumn.isBlank()) {
throw new IllegalArgumentException(aggregation + " 은 columnName 이 필요합니다.");
}
if (!hasColumn(safeTable, safeColumn)) {
throw new IllegalArgumentException("컬럼이 존재하지 않습니다: " + tableName + "." + columnName);
}
} else if (!safeColumn.isBlank() && !hasColumn(safeTable, safeColumn)) {
// count + columnName 들어왔지만 실제 없는 컬럼이면 명확히 거절
throw new IllegalArgumentException("컬럼이 존재하지 않습니다: " + tableName + "." + columnName);
}
List<Map<String, Object>> filters = normalizeAggregateFilters(options.get("filters"));
List<Object> values = new ArrayList<>();
String where = buildAggregateWhere(safeTable, filters, values);
String selectExpr;
if ("count".equals(aggregation)) {
selectExpr = !safeColumn.isBlank()
? String.format("COUNT(\"%s\")", safeColumn)
: "COUNT(*)";
} else if ("distinctCount".equals(aggregation)) {
selectExpr = String.format("COUNT(DISTINCT \"%s\")", safeColumn);
} else {
// sum / avg / min / max 숫자 캐스팅 (avg numeric, 나머지는 컬럼 타입 그대로)
String upper = aggregation.toUpperCase();
if ("AVG".equals(upper) || "SUM".equals(upper)) {
selectExpr = String.format("%s(CAST(\"%s\" AS NUMERIC))", upper, safeColumn);
} else {
selectExpr = String.format("%s(\"%s\")", upper, safeColumn);
}
}
String sql = String.format("SELECT %s AS agg_value FROM \"%s\" main %s",
selectExpr, safeTable, where);
Number raw = jdbcTemplate.queryForObject(sql, Number.class, values.toArray());
double value = raw != null ? raw.doubleValue() : 0d;
Map<String, Object> result = new LinkedHashMap<>();
result.put("value", value);
return result;
}
private String buildAggregateWhere(String safeTable, List<Map<String, Object>> filters, List<Object> values) {
if (filters == null || filters.isEmpty()) return "";
List<String> clauses = new ArrayList<>();
for (Map<String, Object> f : filters) {
if (f == null) continue;
String col = f.get("column") instanceof String s ? s : null;
String op = f.get("operator") instanceof String s ? s : "=";
if (col == null || col.isBlank()) continue;
String safeCol = sanitize(col);
if (safeCol.isBlank() || !hasColumn(safeTable, safeCol)) continue;
if (!FILTER_OPS.contains(op)) continue;
Object val = f.get("value");
switch (op) {
case "isNull":
clauses.add(String.format("\"%s\" IS NULL", safeCol));
break;
case "isNotNull":
clauses.add(String.format("\"%s\" IS NOT NULL", safeCol));
break;
case "in":
case "notIn": {
List<Object> list = toList(val);
if (list.isEmpty()) continue;
String marks = list.stream().map(v -> "?").collect(Collectors.joining(", "));
String kw = "in".equals(op) ? "IN" : "NOT IN";
clauses.add(String.format("\"%s\" %s (%s)", safeCol, kw, marks));
values.addAll(list);
break;
}
case "like":
if (isEmptyAggregateFilterValue(val)) continue;
clauses.add(String.format("\"%s\"::text ILIKE ?", safeCol));
values.add("%" + val + "%");
break;
default:
if (isEmptyAggregateFilterValue(val)) continue;
clauses.add(String.format("\"%s\" %s ?", safeCol, op));
values.add(val);
}
}
return clauses.isEmpty() ? "" : "WHERE " + String.join(" AND ", clauses);
}
private List<Map<String, Object>> normalizeAggregateFilters(Object rawFilters) {
if (!(rawFilters instanceof List<?> rawList) || rawList.isEmpty()) {
return Collections.emptyList();
}
List<Map<String, Object>> out = new ArrayList<>();
for (Object item : rawList) {
if (item instanceof Map<?, ?> rawMap) {
Map<String, Object> normalized = new LinkedHashMap<>();
for (Map.Entry<?, ?> entry : rawMap.entrySet()) {
if (entry.getKey() instanceof String key) {
normalized.put(key, entry.getValue());
}
}
if (!normalized.isEmpty()) out.add(normalized);
}
}
return out;
}
private boolean isEmptyAggregateFilterValue(Object val) {
if (val == null) return true;
if (val instanceof String s) return s.isBlank();
if (val instanceof Collection<?> c) return c.isEmpty();
return false;
}
private List<Object> toList(Object val) {
if (val == null) return List.of();
if (val instanceof List<?> l) {
List<Object> out = new ArrayList<>();
for (Object o : l) {
if (o == null) continue;
if (o instanceof String s && s.isBlank()) continue;
out.add(o);
}
return out;
}
if (val instanceof String s) {
if (s.isBlank()) return List.of();
return Arrays.stream(s.split(","))
.map(String::trim)
.filter(p -> !p.isEmpty())
.map(p -> (Object) p)
.collect(Collectors.toList());
}
return List.of(val);
}
//
// 그룹별 집계 (Phase G.3 canonical chart )
//
/**
* groupBy 컬럼별로 집계 결과 반환. canonical chart 컴포넌트가 bar / line / donut /
* horizontalBar 모두에서 같은 endpoint 사용.
*
* body :
* { "groupBy": "status", "aggregation": "count", "filters": [...] }
* { "groupBy": "dept_code", "aggregation": "sum", "valueColumn": "amount", "limit": 12 }
*
* response:
* { "rows": [{ "group": "재직", "value": 35 }, { "group": "휴직", "value": 4 }] }
*/
public Map<String, Object> aggregateTableGroup(String tableName, Map<String, Object> options) {
String safeTable = sanitize(tableName);
if (safeTable.isBlank() || !checkTableExists(safeTable)) {
throw new IllegalArgumentException("테이블이 존재하지 않습니다: " + tableName);
}
String groupBy = options.get("groupBy") instanceof String s ? s : null;
String safeGroupBy = groupBy != null ? sanitize(groupBy) : "";
if (safeGroupBy.isBlank() || !hasColumn(safeTable, safeGroupBy)) {
throw new IllegalArgumentException("groupBy 컬럼이 존재하지 않습니다: " + tableName + "." + groupBy);
}
String aggregation = options.get("aggregation") instanceof String s ? s : "count";
if (!AGG_TYPES.contains(aggregation)) {
throw new IllegalArgumentException("지원하지 않는 집계 타입: " + aggregation);
}
String valueColumn = options.get("valueColumn") instanceof String s ? s : null;
if (valueColumn == null && options.get("columnName") instanceof String s) valueColumn = s;
String safeValueCol = valueColumn != null ? sanitize(valueColumn) : "";
boolean columnRequired = !"count".equals(aggregation);
if (columnRequired) {
if (safeValueCol.isBlank()) {
throw new IllegalArgumentException(aggregation + " 은 valueColumn 이 필요합니다.");
}
if (!hasColumn(safeTable, safeValueCol)) {
throw new IllegalArgumentException("valueColumn 이 존재하지 않습니다: " + tableName + "." + valueColumn);
}
} else if (!safeValueCol.isBlank() && !hasColumn(safeTable, safeValueCol)) {
throw new IllegalArgumentException("valueColumn 이 존재하지 않습니다: " + tableName + "." + valueColumn);
}
List<Map<String, Object>> filters = normalizeAggregateFilters(options.get("filters"));
int limit = toInt(options.get("limit"), 50);
if (limit < 1) limit = 50;
if (limit > 500) limit = 500;
String orderDir = options.get("orderDir") instanceof String s
&& ("asc".equalsIgnoreCase(s) || "desc".equalsIgnoreCase(s))
? s.toUpperCase()
: "DESC";
List<Object> values = new ArrayList<>();
String where = buildAggregateWhere(safeTable, filters, values);
String selectExpr;
if ("count".equals(aggregation)) {
selectExpr = !safeValueCol.isBlank()
? String.format("COUNT(\"%s\")", safeValueCol)
: "COUNT(*)";
} else if ("distinctCount".equals(aggregation)) {
selectExpr = String.format("COUNT(DISTINCT \"%s\")", safeValueCol);
} else {
String upper = aggregation.toUpperCase();
if ("AVG".equals(upper) || "SUM".equals(upper)) {
selectExpr = String.format("%s(CAST(\"%s\" AS NUMERIC))", upper, safeValueCol);
} else {
selectExpr = String.format("%s(\"%s\")", upper, safeValueCol);
}
}
String sql = String.format(
"SELECT \"%s\" AS group_value, %s AS agg_value " +
"FROM \"%s\" main %s " +
"GROUP BY \"%s\" " +
"ORDER BY agg_value %s NULLS LAST " +
"LIMIT %d",
safeGroupBy, selectExpr, safeTable, where, safeGroupBy, orderDir, limit);
List<Map<String, Object>> rawRows = jdbcTemplate.queryForList(sql, values.toArray());
List<Map<String, Object>> rows = new ArrayList<>();
for (Map<String, Object> r : rawRows) {
Object groupVal = r.get("group_value");
Object aggVal = r.get("agg_value");
double value = aggVal instanceof Number ? ((Number) aggVal).doubleValue() : 0d;
Map<String, Object> out = new LinkedHashMap<>();
out.put("group", groupVal);
out.put("value", value);
rows.add(out);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("rows", rows);
return result;
}
//
// 가벼운 select-rows (Phase G.3.1 card-list / grouped-table )
//
/**
* OptionFilter 호환 필터 + orderBy + limit/offset 임의 컬럼들의 row 들을 반환.
* `getTableData` 페이지네이션 + ILIKE search 묶여 있어 view 컴포넌트가
* 사용하기 무겁다. 메서드는 raw rows 깔끔하게 반환.
*
* body :
* { "columns": ["user_name", "dept_code"], "filters": [...], "limit": 50 }
* { "groupBy 없이 단순 다중 컬럼", "orderBy": [{ "column": "created_date", "direction": "desc" }] }
*
* response:
* { "rows": [{...}, {...}] }
*/
public Map<String, Object> selectTableRows(String tableName, Map<String, Object> options) {
String safeTable = sanitize(tableName);
if (safeTable.isBlank() || !checkTableExists(safeTable)) {
throw new IllegalArgumentException("테이블이 존재하지 않습니다: " + tableName);
}
@SuppressWarnings("unchecked")
List<Object> rawColumns = options.get("columns") instanceof List<?> raw
? (List<Object>) raw : Collections.emptyList();
List<String> safeColumns = new ArrayList<>();
for (Object c : rawColumns) {
if (!(c instanceof String s)) continue;
String safe = sanitize(s);
if (safe.isBlank()) continue;
if (!hasColumn(safeTable, safe)) continue;
safeColumns.add(safe);
}
String selectExpr;
if (safeColumns.isEmpty()) {
selectExpr = "main.*";
} else {
selectExpr = safeColumns.stream()
.map(c -> "\"" + c + "\"")
.collect(Collectors.joining(", "));
}
List<Map<String, Object>> filters = normalizeAggregateFilters(options.get("filters"));
List<Object> values = new ArrayList<>();
String where = buildAggregateWhere(safeTable, filters, values);
// orderBy: [{ column, direction }]
List<Map<String, Object>> orderBy = normalizeAggregateFilters(options.get("orderBy"));
List<String> orderClauses = new ArrayList<>();
for (Map<String, Object> ob : orderBy) {
if (ob == null) continue;
String col = ob.get("column") instanceof String s ? s : null;
if (col == null) continue;
String safeCol = sanitize(col);
if (safeCol.isBlank() || !hasColumn(safeTable, safeCol)) continue;
String dir = ob.get("direction") instanceof String s
&& "desc".equalsIgnoreCase(s) ? "DESC" : "ASC";
orderClauses.add(String.format("\"%s\" %s", safeCol, dir));
}
String order = "";
if (!orderClauses.isEmpty()) {
order = "ORDER BY " + String.join(", ", orderClauses);
} else if (hasColumn(safeTable, "created_date")) {
order = "ORDER BY main.created_date DESC";
}
int limit = toInt(options.get("limit"), 50);
if (limit < 1) limit = 50;
if (limit > 500) limit = 500;
int offset = toInt(options.get("offset"), 0);
if (offset < 0) offset = 0;
String sql = String.format(
"SELECT %s FROM \"%s\" main %s %s LIMIT %d OFFSET %d",
selectExpr, safeTable, where, order, limit, offset);
List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql, values.toArray());
Map<String, Object> result = new LinkedHashMap<>();
result.put("rows", rows);
return result;
}
@Transactional
public Map<String, Object> addTableData(String tableName, Map<String, Object> data) {
String safeTable = sanitize(tableName);
@@ -611,9 +1002,14 @@ public class TableManagementService extends BaseService {
@Transactional
public void createLogTable(String tableName, List<String> logColumns, boolean isActive) {
String logTableName = tableName + "_log";
String safeLog = sanitize(logTableName);
String safeOrig = sanitize(tableName);
if (safeOrig.isBlank()) {
throw new IllegalArgumentException("유효하지 않은 테이블명입니다.");
}
String safeLog = sanitize(safeOrig + "_log");
if (safeLog.isBlank()) {
throw new IllegalArgumentException("유효하지 않은 로그 테이블명입니다.");
}
// 원본 테이블 컬럼 정보 조회
Map<String, String> colTypes = getColumnTypes(safeOrig);
@@ -625,13 +1021,32 @@ public class TableManagementService extends BaseService {
colDefs.add("log_date TIMESTAMP DEFAULT NOW()");
colDefs.add("log_user VARCHAR(100)");
List<String> targetCols = (logColumns != null && !logColumns.isEmpty())
? logColumns.stream().map(this::sanitize).filter(c -> !c.isBlank()).collect(Collectors.toList())
List<String> requestedCols = (logColumns != null && !logColumns.isEmpty())
? logColumns
: new ArrayList<>(colTypes.keySet());
for (String col : targetCols) {
String type = colTypes.getOrDefault(col, "TEXT");
colDefs.add(String.format("\"%s\" %s", col, type));
// 실제 SQL 들어간 컬럼만 메타에 저장 (skip 것은 log_columns 설정에서도 빠짐)
List<String> persistedCols = new ArrayList<>();
for (String col : requestedCols) {
if (col == null) continue;
String safeCol = sanitize(col);
if (safeCol.isBlank()) continue; // sanitize 결과 식별자 차단
if (!colTypes.containsKey(col)) continue; // 원본 테이블에 없는 컬럼 skip
String rawType = colTypes.get(col);
String normalized = (rawType == null ? "" : rawType.toLowerCase(Locale.ROOT).trim());
if (!ALLOWED_LOG_COLUMN_TYPES.contains(normalized)) {
// 없는 type text fallback (안전 default)
log.warn("로그 테이블 컬럼 타입 화이트리스트 미일치 → text 로 대체: table={}, col={}, type={}",
safeOrig, safeCol, rawType);
normalized = "text";
}
colDefs.add(String.format("\"%s\" %s", safeCol, normalized));
persistedCols.add(safeCol);
}
if (persistedCols.isEmpty()) {
throw new IllegalArgumentException("log 생성할 컬럼이 없습니다.");
}
String createSql = String.format(
@@ -642,7 +1057,7 @@ public class TableManagementService extends BaseService {
Map<String, Object> params = new HashMap<>();
params.put("table_name", tableName);
params.put("is_active", isActive);
params.put("log_columns", String.join(",", targetCols));
params.put("log_columns", String.join(",", persistedCols));
sqlSession.update(NS + "upsertLogConfig", params);
log.info("로그 테이블 생성: {}", safeLog);
@@ -856,9 +1271,40 @@ public class TableManagementService extends BaseService {
}
/** SQL injection 방지용 식별자 정리 */
/**
* SQL 식별자(테이블/컬럼명) 살균.
* - 영숫자/언더스코어만 허용 (PostgreSQL identifier 규칙)
* - 문자열, 숫자로 시작, 63자 초과, SQL 예약어 거부 IllegalArgumentException
*
* 이렇게 가드해두지 않으면 동적 SQL 식별자가 들어가거나 예약어가 통과해
* 의도치 않은 컬럼에 접근하거나 SQL 문법 깨짐(500) 생김.
*/
private static final java.util.Set<String> SQL_RESERVED_WORDS = java.util.Set.of(
"user", "order", "group", "table", "column", "index", "select", "insert",
"update", "delete", "from", "where", "join", "on", "as", "and", "or", "not",
"null", "true", "false", "create", "alter", "drop", "primary", "key",
"foreign", "references", "constraint", "default", "unique", "check",
"view", "procedure", "function"
);
private String sanitize(String name) {
if (name == null) return "";
return name.replaceAll("[^a-zA-Z0-9_]", "");
if (name == null) {
throw new IllegalArgumentException("식별자가 null 입니다.");
}
String cleaned = name.replaceAll("[^a-zA-Z0-9_]", "");
if (cleaned.isEmpty()) {
throw new IllegalArgumentException("식별자가 비어있거나 유효하지 않습니다: " + name);
}
if (cleaned.length() > 63) {
throw new IllegalArgumentException("식별자가 63자를 초과합니다: " + cleaned);
}
if (Character.isDigit(cleaned.charAt(0))) {
throw new IllegalArgumentException("식별자는 숫자로 시작할 수 없습니다: " + cleaned);
}
if (SQL_RESERVED_WORDS.contains(cleaned.toLowerCase())) {
throw new IllegalArgumentException("'" + cleaned + "' 은 SQL 예약어라 식별자로 사용할 수 없습니다.");
}
return cleaned;
}
/** "direct" / "auto" → "text" 변환 (legacy 호출처 보호 — system-normalize 동작) */
@@ -872,19 +1318,18 @@ public class TableManagementService extends BaseService {
/**
* context 따라 INPUT_TYPE 정규화 검증.
* @param context "user-insert" | "user-update-type" | "user-update-other" | "system-normalize"
*/
private String normalizeInputType(String value, String context) {
if ("user-insert".equals(context) || "user-update-type".equals(context)) {
if (value == null || !USER_SELECTABLE_INPUT_TYPES.contains(value)) {
private String normalizeInputType(String value, InputTypeContext context) {
if (context == InputTypeContext.USER_INSERT || context == InputTypeContext.USER_UPDATE_TYPE) {
if (value == null || !InputTypeConstants.USER_SELECTABLE_INPUT_TYPES.contains(value)) {
throw new IllegalArgumentException(
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + USER_SELECTABLE_INPUT_TYPES
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + InputTypeConstants.USER_SELECTABLE_INPUT_TYPES
+ " (받은 값: " + value + ")"
);
}
return value;
}
// user-update-other / system-normalize: 기존 동작 그대로
// USER_UPDATE_OTHER / SYSTEM_NORMALIZE: 기존 동작 그대로
return normalizeInputType(value);
}
@@ -0,0 +1,7 @@
-- V021: BATCH_MAPPINGS.MAPPING_CONFIG JSONB 컬럼 추가
-- conditional 매핑(when/then/default) 규칙을 행 단위로 저장한다.
-- direct/fixed 매핑은 NULL. 메타 DB 뿐 아니라 모든 활성 테넌트 DB 에도
-- StartupSchemaMigrator 로 idempotent 하게 동일 ALTER 가 부팅 시 적용된다.
ALTER TABLE BATCH_MAPPINGS
ADD COLUMN IF NOT EXISTS MAPPING_CONFIG JSONB;
@@ -0,0 +1,22 @@
-- =================================================================
-- V022: DEPT_MANAGERS 테이블 (다중 결재/부서/조직장 매핑)
-- =================================================================
-- 기존 DEPT_INFO.APPROVAL_MANAGER / DEPT_MANAGER 단일 컬럼을 매핑 테이블로 다중화.
-- role: 'approval' | 'dept' | 'org_leader'. 부서 삭제(hard) 시 CASCADE 로 정리.
-- 멱등: IF NOT EXISTS 로 재실행 안전.
CREATE TABLE IF NOT EXISTS DEPT_MANAGERS (
DEPT_CODE VARCHAR(1024) NOT NULL,
USER_ID VARCHAR(50) NOT NULL,
ROLE VARCHAR(20) NOT NULL,
SORT_ORDER INTEGER NOT NULL DEFAULT 1,
CREATED_AT TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (DEPT_CODE, USER_ID, ROLE),
CONSTRAINT chk_dept_managers_role
CHECK (ROLE IN ('approval', 'dept', 'org_leader')),
CONSTRAINT fk_dept_managers_dept
FOREIGN KEY (DEPT_CODE) REFERENCES DEPT_INFO(DEPT_CODE) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_dept_managers_role
ON DEPT_MANAGERS (DEPT_CODE, ROLE, SORT_ORDER);
@@ -0,0 +1,12 @@
ALTER TABLE MENU_INFO
ADD COLUMN IF NOT EXISTS IS_SOLUTION_ONLY BOOLEAN DEFAULT FALSE NOT NULL;
COMMENT ON COLUMN MENU_INFO.IS_SOLUTION_ONLY IS '솔루션 사이트(solution.invyone.com 등 관리 호스트) 에서만 노출되는 메뉴. 테넌트 사이트에선 SQL 단계에서 제외.';
-- 솔루션 전용 메뉴 마킹
UPDATE MENU_INFO SET IS_SOLUTION_ONLY = TRUE
WHERE MENU_URL IN (
'/admin/sysMng/subdomainList',
'/admin/userMng/companyList',
'/admin/audit-log'
);
@@ -58,6 +58,9 @@
AND RMA.READ_YN = 'Y'
)
</if>
<if test='is_management_host == false'>
AND MENU.IS_SOLUTION_ONLY = FALSE
</if>
UNION ALL
@@ -105,6 +108,9 @@
AND RMA.READ_YN = 'Y'
)
</if>
<if test='is_management_host == false'>
AND S.IS_SOLUTION_ONLY = FALSE
</if>
)
SELECT
V.LEV
@@ -124,26 +130,8 @@
, COALESCE(V.LANG_KEY_DESC, '') AS LANG_KEY_DESC
, COALESCE(V.MENU_ICON, '') AS MENU_ICON
, COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME
, COALESCE(
(SELECT MLT.LANG_TEXT
FROM MULTI_LANG_KEY_MASTER MLKM
JOIN MULTI_LANG_TEXT MLT
ON MLKM.KEY_ID = MLT.KEY_ID
WHERE MLKM.LANG_KEY = V.LANG_KEY
AND MLT.LANG_CODE = #{user_lang}
LIMIT 1),
V.MENU_NAME_KOR
) AS TRANSLATED_NAME
, COALESCE(
(SELECT MLT.LANG_TEXT
FROM MULTI_LANG_KEY_MASTER MLKM
JOIN MULTI_LANG_TEXT MLT
ON MLKM.KEY_ID = MLT.KEY_ID
WHERE MLKM.LANG_KEY = V.LANG_KEY_DESC
AND MLT.LANG_CODE = #{user_lang}
LIMIT 1),
COALESCE(V.MENU_DESC, '')
) AS TRANSLATED_DESC
, COALESCE(MLT_NAME.LANG_TEXT, V.MENU_NAME_KOR) AS TRANSLATED_NAME
, COALESCE(MLT_DESC.LANG_TEXT, COALESCE(V.MENU_DESC, '')) AS TRANSLATED_DESC
, CASE UPPER(V.STATUS)
WHEN 'ACTIVE' THEN '활성화'
WHEN 'INACTIVE' THEN '비활성화'
@@ -152,6 +140,16 @@
FROM V_MENU V
LEFT JOIN COMPANY_MNG CM
ON V.COMPANY_CODE = CM.COMPANY_CODE
LEFT JOIN MULTI_LANG_KEY_MASTER MLKM_NAME
ON MLKM_NAME.LANG_KEY = V.LANG_KEY
LEFT JOIN MULTI_LANG_TEXT MLT_NAME
ON MLT_NAME.KEY_ID = MLKM_NAME.KEY_ID
AND MLT_NAME.LANG_CODE = #{user_lang}
LEFT JOIN MULTI_LANG_KEY_MASTER MLKM_DESC
ON MLKM_DESC.LANG_KEY = V.LANG_KEY_DESC
LEFT JOIN MULTI_LANG_TEXT MLT_DESC
ON MLT_DESC.KEY_ID = MLKM_DESC.KEY_ID
AND MLT_DESC.LANG_CODE = #{user_lang}
ORDER BY V.PATH, V.SEQ
</select>
@@ -187,6 +185,9 @@
AND MENU.COMPANY_CODE = #{company_code}
</otherwise>
</choose>
<if test='is_management_host == false'>
AND MENU.IS_SOLUTION_ONLY = FALSE
</if>
UNION ALL
@@ -212,6 +213,9 @@
ON S.PARENT_OBJ_ID = V.OBJID
WHERE S.OBJID != ALL(V.PATH)
AND S.STATUS = 'active'
<if test='is_management_host == false'>
AND S.IS_SOLUTION_ONLY = FALSE
</if>
)
SELECT
V.LEV
@@ -231,26 +235,8 @@
, COALESCE(V.LANG_KEY_DESC, '') AS LANG_KEY_DESC
, COALESCE(V.MENU_ICON, '') AS MENU_ICON
, COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME
, COALESCE(
(SELECT MLT.LANG_TEXT
FROM MULTI_LANG_KEY_MASTER MLKM
JOIN MULTI_LANG_TEXT MLT
ON MLKM.KEY_ID = MLT.KEY_ID
WHERE MLKM.LANG_KEY = V.LANG_KEY
AND MLT.LANG_CODE = #{user_lang}
LIMIT 1),
V.MENU_NAME_KOR
) AS TRANSLATED_NAME
, COALESCE(
(SELECT MLT.LANG_TEXT
FROM MULTI_LANG_KEY_MASTER MLKM
JOIN MULTI_LANG_TEXT MLT
ON MLKM.KEY_ID = MLT.KEY_ID
WHERE MLKM.LANG_KEY = V.LANG_KEY_DESC
AND MLT.LANG_CODE = #{user_lang}
LIMIT 1),
COALESCE(V.MENU_DESC, '')
) AS TRANSLATED_DESC
, COALESCE(MLT_NAME.LANG_TEXT, V.MENU_NAME_KOR) AS TRANSLATED_NAME
, COALESCE(MLT_DESC.LANG_TEXT, COALESCE(V.MENU_DESC, '')) AS TRANSLATED_DESC
, CASE UPPER(V.STATUS)
WHEN 'ACTIVE' THEN '활성화'
WHEN 'INACTIVE' THEN '비활성화'
@@ -259,6 +245,16 @@
FROM V_MENU V
LEFT JOIN COMPANY_MNG CM
ON V.COMPANY_CODE = CM.COMPANY_CODE
LEFT JOIN MULTI_LANG_KEY_MASTER MLKM_NAME
ON MLKM_NAME.LANG_KEY = V.LANG_KEY
LEFT JOIN MULTI_LANG_TEXT MLT_NAME
ON MLT_NAME.KEY_ID = MLKM_NAME.KEY_ID
AND MLT_NAME.LANG_CODE = #{user_lang}
LEFT JOIN MULTI_LANG_KEY_MASTER MLKM_DESC
ON MLKM_DESC.LANG_KEY = V.LANG_KEY_DESC
LEFT JOIN MULTI_LANG_TEXT MLT_DESC
ON MLT_DESC.KEY_ID = MLKM_DESC.KEY_ID
AND MLT_DESC.LANG_CODE = #{user_lang}
ORDER BY V.PATH, V.SEQ
</select>
@@ -365,26 +361,8 @@
, COALESCE(V.LANG_KEY_DESC, '') AS LANG_KEY_DESC
, COALESCE(V.MENU_ICON, '') AS MENU_ICON
, COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME
, COALESCE(
(SELECT MLT.LANG_TEXT
FROM MULTI_LANG_KEY_MASTER MLKM
JOIN MULTI_LANG_TEXT MLT
ON MLKM.KEY_ID = MLT.KEY_ID
WHERE MLKM.LANG_KEY = V.LANG_KEY
AND MLT.LANG_CODE = #{user_lang}
LIMIT 1),
V.MENU_NAME_KOR
) AS TRANSLATED_NAME
, COALESCE(
(SELECT MLT.LANG_TEXT
FROM MULTI_LANG_KEY_MASTER MLKM
JOIN MULTI_LANG_TEXT MLT
ON MLKM.KEY_ID = MLT.KEY_ID
WHERE MLKM.LANG_KEY = V.LANG_KEY_DESC
AND MLT.LANG_CODE = #{user_lang}
LIMIT 1),
COALESCE(V.MENU_DESC, '')
) AS TRANSLATED_DESC
, COALESCE(MLT_NAME.LANG_TEXT, V.MENU_NAME_KOR) AS TRANSLATED_NAME
, COALESCE(MLT_DESC.LANG_TEXT, COALESCE(V.MENU_DESC, '')) AS TRANSLATED_DESC
, CASE UPPER(V.STATUS)
WHEN 'ACTIVE' THEN '활성화'
WHEN 'INACTIVE' THEN '비활성화'
@@ -393,6 +371,16 @@
FROM V_MENU V
LEFT JOIN COMPANY_MNG CM
ON V.COMPANY_CODE = CM.COMPANY_CODE
LEFT JOIN MULTI_LANG_KEY_MASTER MLKM_NAME
ON MLKM_NAME.LANG_KEY = V.LANG_KEY
LEFT JOIN MULTI_LANG_TEXT MLT_NAME
ON MLT_NAME.KEY_ID = MLKM_NAME.KEY_ID
AND MLT_NAME.LANG_CODE = #{user_lang}
LEFT JOIN MULTI_LANG_KEY_MASTER MLKM_DESC
ON MLKM_DESC.LANG_KEY = V.LANG_KEY_DESC
LEFT JOIN MULTI_LANG_TEXT MLT_DESC
ON MLT_DESC.KEY_ID = MLKM_DESC.KEY_ID
AND MLT_DESC.LANG_CODE = #{user_lang}
ORDER BY V.PATH, V.SEQ
</select>
@@ -102,6 +102,117 @@
<include refid="common.companyCodeFilter"/>
</delete>
<!-- batch_mappings: 특정 batch_config_id 의 매핑 행들 조회 -->
<select id="getBatchMappingsByConfigId" parameterType="map" resultType="map">
SELECT
ID
, BATCH_CONFIG_ID
, COMPANY_CODE
, FROM_CONNECTION_TYPE
, FROM_CONNECTION_ID
, FROM_TABLE_NAME
, FROM_COLUMN_NAME
, FROM_COLUMN_TYPE
, FROM_API_URL
, FROM_API_KEY
, FROM_API_METHOD
, FROM_API_PARAM_TYPE
, FROM_API_PARAM_NAME
, FROM_API_PARAM_VALUE
, FROM_API_PARAM_SOURCE
, FROM_API_BODY
, TO_CONNECTION_TYPE
, TO_CONNECTION_ID
, TO_TABLE_NAME
, TO_COLUMN_NAME
, TO_COLUMN_TYPE
, TO_API_URL
, TO_API_KEY
, TO_API_METHOD
, TO_API_BODY
, MAPPING_ORDER
, MAPPING_TYPE
, MAPPING_CONFIG::TEXT AS MAPPING_CONFIG
, CREATED_BY
, CREATED_DATE
FROM BATCH_MAPPINGS
WHERE BATCH_CONFIG_ID = #{batch_config_id}::varchar
ORDER BY MAPPING_ORDER, ID
</select>
<!-- batch_mappings: 단건 INSERT (replace-all 패턴이라 INSERT/DELETE 만 사용) -->
<insert id="insertBatchMapping" parameterType="map" useGeneratedKeys="true" keyProperty="id" keyColumn="id">
INSERT INTO BATCH_MAPPINGS (
BATCH_CONFIG_ID
, COMPANY_CODE
, FROM_CONNECTION_TYPE
, FROM_CONNECTION_ID
, FROM_TABLE_NAME
, FROM_COLUMN_NAME
, FROM_COLUMN_TYPE
, FROM_API_URL
, FROM_API_KEY
, FROM_API_METHOD
, FROM_API_PARAM_TYPE
, FROM_API_PARAM_NAME
, FROM_API_PARAM_VALUE
, FROM_API_PARAM_SOURCE
, FROM_API_BODY
, TO_CONNECTION_TYPE
, TO_CONNECTION_ID
, TO_TABLE_NAME
, TO_COLUMN_NAME
, TO_COLUMN_TYPE
, TO_API_URL
, TO_API_KEY
, TO_API_METHOD
, TO_API_BODY
, MAPPING_ORDER
, MAPPING_TYPE
, MAPPING_CONFIG
, CREATED_BY
, CREATED_DATE
) VALUES (
#{batch_config_id}::varchar
, #{company_code}
, #{from_connection_type}
, #{from_connection_id}
, #{from_table_name}
, #{from_column_name}
, #{from_column_type}
, #{from_api_url}
, #{from_api_key}
, #{from_api_method}
, #{from_api_param_type}
, #{from_api_param_name}
, #{from_api_param_value}
, #{from_api_param_source}
, #{from_api_body}
, #{to_connection_type}
, #{to_connection_id}
, #{to_table_name}
, #{to_column_name}
, #{to_column_type}
, #{to_api_url}
, #{to_api_key}
, #{to_api_method}
, #{to_api_body}
, #{mapping_order}
, <choose>
<when test="mapping_type != null and mapping_type != ''">#{mapping_type}</when>
<otherwise>'direct'</otherwise>
</choose>
, #{mapping_config,jdbcType=OTHER}::jsonb
, #{created_by}
, NOW()
)
</insert>
<!-- batch_mappings: 특정 batch_config_id 의 매핑 전부 삭제 (replace-all 의 앞단계) -->
<delete id="deleteBatchMappingsByConfigId" parameterType="map">
DELETE FROM BATCH_MAPPINGS WHERE BATCH_CONFIG_ID = #{batch_config_id}::varchar
</delete>
<!-- 내부 DB 테이블 목록 조회 -->
<select id="getInternalTables" resultType="map">
SELECT
@@ -5,7 +5,7 @@
<sql id="batchExecutionLogSearchCondition">
<if test="batch_config_id != null">
AND bel.batch_config_id = #{batch_config_id}
AND bel.batch_config_id = #{batch_config_id}::varchar
</if>
<if test="execution_status != null and execution_status != ''">
AND bel.execution_status = #{execution_status}
@@ -84,7 +84,7 @@
<select id="getBatchExecutionLogLatest" parameterType="map" resultType="map">
SELECT * FROM batch_execution_logs
WHERE batch_config_id = #{batch_config_id}
WHERE batch_config_id = #{batch_config_id}::varchar
ORDER BY start_time DESC
LIMIT 1
@@ -106,7 +106,7 @@
WHERE 1=1
<if test="batch_config_id != null">
AND batch_config_id = #{batch_config_id}
AND batch_config_id = #{batch_config_id}::varchar
</if>
<if test="start_date != null and start_date != ''">
AND start_time &gt;= #{start_date}::timestamp
@@ -123,7 +123,7 @@
total_records, success_records, failed_records,
error_message, error_details, server_name, process_id
) VALUES (
#{batch_config_id}, #{company_code}, #{execution_status},
#{batch_config_id}::varchar, #{company_code}, #{execution_status},
COALESCE(#{start_time}::timestamp, NOW()),
#{end_time}::timestamp,
#{duration_ms},
@@ -15,14 +15,14 @@
execution_today AS (
SELECT COUNT(*) AS today_count,
SUM(CASE WHEN execution_status = 'FAILED' THEN 1 ELSE 0 END) AS today_failed
FROM batch_execution_log
FROM batch_execution_logs
WHERE DATE(start_time) = CURRENT_DATE
<include refid="common.companyCodeFilter"/>
),
execution_yesterday AS (
SELECT COUNT(*) AS yesterday_count,
SUM(CASE WHEN execution_status = 'FAILED' THEN 1 ELSE 0 END) AS yesterday_failed
FROM batch_execution_log
FROM batch_execution_logs
WHERE DATE(start_time) = CURRENT_DATE - INTERVAL '1 day'
<include refid="common.companyCodeFilter"/>
)
@@ -77,9 +77,9 @@
SUM(CASE WHEN execution_status = 'SUCCESS' THEN 1 ELSE 0 END) AS success_count,
SUM(CASE WHEN execution_status = 'FAILED' THEN 1 ELSE 0 END) AS failed_count
FROM batch_execution_log
FROM batch_execution_logs
WHERE batch_config_id = #{batch_config_id}
WHERE batch_config_id = #{batch_config_id}::varchar
AND start_time >= NOW() - INTERVAL '24 hours'
GROUP BY DATE_TRUNC('hour', start_time)
@@ -87,6 +87,32 @@
ORDER BY hour_slot
</select>
<!-- 글로벌 스파크라인: 회사 전체 배치의 최근 24시간 1시간 단위 실행 집계 (빈 슬롯 포함 24개 고정) -->
<select id="getBatchManagementGlobalSparklineData" parameterType="map" resultType="map">
WITH hours AS (
SELECT generate_series(
DATE_TRUNC('hour', NOW() - INTERVAL '23 hours'),
DATE_TRUNC('hour', NOW()),
INTERVAL '1 hour'
) AS hour_slot
),
filtered_logs AS (
SELECT DATE_TRUNC('hour', start_time) AS hour_slot,
execution_status
FROM batch_execution_logs
WHERE start_time >= NOW() - INTERVAL '24 hours'
<include refid="common.companyCodeFilter"/>
)
SELECT h.hour_slot,
COUNT(l.execution_status) AS total_count,
COALESCE(SUM(CASE WHEN l.execution_status = 'SUCCESS' THEN 1 ELSE 0 END), 0) AS success_count,
COALESCE(SUM(CASE WHEN l.execution_status = 'FAILED' THEN 1 ELSE 0 END), 0) AS failed_count
FROM hours h
LEFT JOIN filtered_logs l ON l.hour_slot = h.hour_slot
GROUP BY h.hour_slot
ORDER BY h.hour_slot
</select>
<!-- 최근 실행 로그 목록 (최대 20건) -->
<select id="getBatchManagementRecentLogList" parameterType="map" resultType="map">
SELECT id,
@@ -100,9 +126,9 @@
failed_records,
error_message
FROM batch_execution_log
FROM batch_execution_logs
WHERE batch_config_id = #{batch_config_id}
WHERE batch_config_id = #{batch_config_id}::varchar
ORDER BY start_time DESC
LIMIT 20
@@ -1,128 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cascadingAutoFill">
<sql id="cascadingAutoFillGroupSearchCondition">
<if test="keyword != null and keyword != ''">
AND (G.GROUP_NAME ILIKE '%' || #{keyword} || '%')
</if>
<if test="is_active != null and is_active != ''">
AND G.IS_ACTIVE = #{is_active}
</if>
</sql>
<select id="getCascadingAutoFillGroupList" parameterType="map" resultType="map">
SELECT G.*, COUNT(M.MAPPING_ID) AS MAPPING_COUNT
FROM CASCADING_AUTO_FILL_GROUP G
LEFT JOIN CASCADING_AUTO_FILL_MAPPING M
ON G.GROUP_CODE = M.GROUP_CODE AND G.COMPANY_CODE = M.COMPANY_CODE
WHERE 1=1
<if test="company_code != null and company_code != &quot;*&quot;">
AND (G.COMPANY_CODE = #{company_code} OR G.COMPANY_CODE = '*')
</if>
<include refid="cascadingAutoFillGroupSearchCondition"/>
GROUP BY G.GROUP_ID
ORDER BY G.GROUP_NAME
<include refid="common.pagination"/>
</select>
<select id="getCascadingAutoFillGroupListCnt" parameterType="map" resultType="int">
SELECT COUNT(DISTINCT G.GROUP_ID)
FROM CASCADING_AUTO_FILL_GROUP G
WHERE 1=1
<if test="company_code != null and company_code != &quot;*&quot;">
AND (G.COMPANY_CODE = #{company_code} OR G.COMPANY_CODE = '*')
</if>
<include refid="cascadingAutoFillGroupSearchCondition"/>
</select>
<select id="getCascadingAutoFillGroupByCode" parameterType="map" resultType="map">
SELECT *
FROM CASCADING_AUTO_FILL_GROUP
WHERE GROUP_CODE = #{group_code}
<if test="company_code != null and company_code != &quot;*&quot;">
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
<if test="is_active != null">
AND IS_ACTIVE = #{is_active}
</if>
</select>
<select id="getCascadingAutoFillMappingList" parameterType="map" resultType="map">
SELECT *
FROM CASCADING_AUTO_FILL_MAPPING
WHERE GROUP_CODE = #{group_code}
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
ORDER BY SORT_ORDER, MAPPING_ID
</select>
<select id="getCascadingAutoFillGroupCount" parameterType="map" resultType="int">
SELECT COUNT(*)
FROM CASCADING_AUTO_FILL_GROUP
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</select>
<insert id="insertCascadingAutoFillGroup" parameterType="map" useGeneratedKeys="true" keyProperty="groupId">
INSERT INTO CASCADING_AUTO_FILL_GROUP (
GROUP_CODE, GROUP_NAME, DESCRIPTION,
MASTER_TABLE, MASTER_VALUE_COLUMN, MASTER_LABEL_COLUMN,
COMPANY_CODE, IS_ACTIVE, CREATED_DATE
) VALUES (
#{group_code},
#{group_name},
#{description, jdbcType=VARCHAR},
#{master_table},
#{master_value_column},
#{master_label_column, jdbcType=VARCHAR},
#{company_code},
#{is_active, jdbcType=VARCHAR},
CURRENT_TIMESTAMP
)
</insert>
<insert id="insertCascadingAutoFillMapping" parameterType="map" useGeneratedKeys="true" keyProperty="mappingId">
INSERT INTO CASCADING_AUTO_FILL_MAPPING (
GROUP_CODE, COMPANY_CODE, SOURCE_COLUMN, TARGET_FIELD, TARGET_LABEL,
IS_EDITABLE, IS_REQUIRED, DEFAULT_VALUE, SORT_ORDER
) VALUES (
#{group_code},
#{company_code},
#{source_column},
#{target_field},
#{target_label, jdbcType=VARCHAR},
#{is_editable, jdbcType=VARCHAR},
#{is_required, jdbcType=VARCHAR},
#{default_value, jdbcType=VARCHAR},
#{sort_order}
)
</insert>
<update id="updateCascadingAutoFillGroup" parameterType="map">
UPDATE CASCADING_AUTO_FILL_GROUP SET
GROUP_NAME = COALESCE(#{group_name, jdbcType=VARCHAR}, GROUP_NAME),
DESCRIPTION = COALESCE(#{description, jdbcType=VARCHAR}, DESCRIPTION),
MASTER_TABLE = COALESCE(#{master_table, jdbcType=VARCHAR}, MASTER_TABLE),
MASTER_VALUE_COLUMN = COALESCE(#{master_value_column, jdbcType=VARCHAR}, MASTER_VALUE_COLUMN),
MASTER_LABEL_COLUMN = COALESCE(#{master_label_column, jdbcType=VARCHAR}, MASTER_LABEL_COLUMN),
IS_ACTIVE = COALESCE(#{is_active, jdbcType=VARCHAR}, IS_ACTIVE),
UPDATED_DATE = CURRENT_TIMESTAMP
WHERE GROUP_CODE = #{group_code}
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</update>
<delete id="deleteCascadingAutoFillMappings" parameterType="map">
DELETE FROM CASCADING_AUTO_FILL_MAPPING
WHERE GROUP_CODE = #{group_code}
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</delete>
<delete id="deleteCascadingAutoFillGroup" parameterType="map">
DELETE FROM CASCADING_AUTO_FILL_GROUP
WHERE GROUP_CODE = #{group_code}
<if test="company_code != null and company_code != &quot;*&quot;">
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</delete>
</mapper>
@@ -1,100 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cascadingCondition">
<sql id="cascadingConditionSearchCondition">
<if test="keyword != null and keyword != ''">
AND CONDITION_NAME ILIKE '%' || #{keyword} || '%'
</if>
<if test="is_active != null and is_active != ''">
AND IS_ACTIVE = #{is_active}
</if>
<if test="relation_code != null and relation_code != ''">
AND RELATION_CODE = #{relation_code}
</if>
<if test="relation_type != null and relation_type != ''">
AND RELATION_TYPE = #{relation_type}
</if>
</sql>
<select id="getCascadingConditionList" parameterType="map" resultType="map">
SELECT *
FROM CASCADING_CONDITION
WHERE 1=1
<include refid="common.companyCodeFilter"/>
<include refid="cascadingConditionSearchCondition"/>
<choose>
<when test="sort_column != null and sort_column != ''">
ORDER BY ${sortColumn}
<if test="sort_direction != null and sort_direction != ''">
${sortDirection}
</if>
</when>
<otherwise>
ORDER BY RELATION_CODE, PRIORITY, CONDITION_NAME
</otherwise>
</choose>
<include refid="common.pagination"/>
</select>
<select id="getCascadingConditionListCnt" parameterType="map" resultType="int">
SELECT COUNT(*)
FROM CASCADING_CONDITION
WHERE 1=1
<include refid="common.companyCodeFilter"/>
<include refid="cascadingConditionSearchCondition"/>
</select>
<select id="getCascadingConditionInfo" parameterType="map" resultType="map">
SELECT *
FROM CASCADING_CONDITION
WHERE CONDITION_ID = #{condition_id}
<include refid="common.companyCodeFilter"/>
</select>
<insert id="insertCascadingCondition" parameterType="map" useGeneratedKeys="true" keyProperty="conditionId">
INSERT INTO CASCADING_CONDITION (
RELATION_TYPE, RELATION_CODE, CONDITION_NAME,
CONDITION_FIELD, CONDITION_OPERATOR, CONDITION_VALUE,
FILTER_COLUMN, FILTER_VALUES, PRIORITY,
COMPANY_CODE, IS_ACTIVE, CREATED_DATE
) VALUES (
COALESCE(#{relation_type}, 'RELATION'), #{relation_code}, #{condition_name},
#{condition_field}, COALESCE(#{condition_operator}, 'EQ'), #{condition_value},
#{filter_column}, #{filter_values}, COALESCE(#{priority}, 0),
#{company_code}, COALESCE(#{is_active}, 'Y'), CURRENT_TIMESTAMP
)
</insert>
<update id="updateCascadingCondition" parameterType="map">
UPDATE CASCADING_CONDITION SET
CONDITION_NAME = COALESCE(#{condition_name}, CONDITION_NAME),
CONDITION_FIELD = COALESCE(#{condition_field}, CONDITION_FIELD),
CONDITION_OPERATOR = COALESCE(#{condition_operator}, CONDITION_OPERATOR),
CONDITION_VALUE = COALESCE(#{condition_value}, CONDITION_VALUE),
FILTER_COLUMN = COALESCE(#{filter_column}, FILTER_COLUMN),
FILTER_VALUES = COALESCE(#{filter_values}, FILTER_VALUES),
PRIORITY = COALESCE(#{priority}, PRIORITY),
IS_ACTIVE = COALESCE(#{is_active}, IS_ACTIVE),
UPDATED_DATE = CURRENT_TIMESTAMP
WHERE CONDITION_ID = #{condition_id}
<include refid="common.companyCodeFilter"/>
</update>
<delete id="deleteCascadingCondition" parameterType="map">
DELETE FROM CASCADING_CONDITION
WHERE CONDITION_ID = #{condition_id}
<include refid="common.companyCodeFilter"/>
</delete>
<select id="getCascadingConditionsByRelationCode" parameterType="map" resultType="map">
SELECT *
FROM CASCADING_CONDITION
WHERE RELATION_CODE = #{relation_code}
AND IS_ACTIVE = 'Y'
<include refid="common.companyCodeFilter"/>
ORDER BY PRIORITY DESC
</select>
</mapper>
@@ -1,219 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cascadingHierarchy">
<sql id="cascadingHierarchyGroupSearchCondition">
<if test="keyword != null and keyword != ''">
AND (G.GROUP_NAME ILIKE '%' || #{keyword} || '%')
</if>
<if test="is_active != null and is_active != ''">
AND G.IS_ACTIVE = #{is_active}
</if>
<if test="hierarchy_type != null and hierarchy_type != ''">
AND G.HIERARCHY_TYPE = #{hierarchy_type}
</if>
</sql>
<select id="getCascadingHierarchyGroupList" parameterType="map" resultType="map">
SELECT G.*,
(SELECT COUNT(*)
FROM CASCADING_HIERARCHY_LEVEL L
WHERE L.GROUP_CODE = G.GROUP_CODE AND L.COMPANY_CODE = G.COMPANY_CODE) AS LEVEL_COUNT
FROM CASCADING_HIERARCHY_GROUP G
WHERE 1=1
<if test="company_code != null and company_code != &quot;*&quot;">
AND (G.COMPANY_CODE = #{company_code} OR G.COMPANY_CODE = '*')
</if>
<include refid="cascadingHierarchyGroupSearchCondition"/>
ORDER BY G.GROUP_NAME
<include refid="common.pagination"/>
</select>
<select id="getCascadingHierarchyGroupListCnt" parameterType="map" resultType="int">
SELECT COUNT(DISTINCT G.GROUP_ID)
FROM CASCADING_HIERARCHY_GROUP G
WHERE 1=1
<if test="company_code != null and company_code != &quot;*&quot;">
AND (G.COMPANY_CODE = #{company_code} OR G.COMPANY_CODE = '*')
</if>
<include refid="cascadingHierarchyGroupSearchCondition"/>
</select>
<select id="getCascadingHierarchyGroupByCode" parameterType="map" resultType="map">
SELECT *
FROM CASCADING_HIERARCHY_GROUP
WHERE GROUP_CODE = #{group_code}
<if test="company_code != null and company_code != &quot;*&quot;">
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</select>
<select id="getCascadingHierarchyGroupCount" parameterType="map" resultType="int">
SELECT COUNT(*)
FROM CASCADING_HIERARCHY_GROUP
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</select>
<select id="getCascadingHierarchyLevelList" parameterType="map" resultType="map">
SELECT *
FROM CASCADING_HIERARCHY_LEVEL
WHERE GROUP_CODE = #{group_code}
<if test="company_code != null and company_code != &quot;*&quot;">
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
ORDER BY LEVEL_ORDER
</select>
<select id="getCascadingHierarchyLevelInfo" parameterType="map" resultType="map">
SELECT *
FROM CASCADING_HIERARCHY_LEVEL
WHERE LEVEL_ID = #{level_id}
<if test="company_code != null and company_code != &quot;*&quot;">
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</select>
<select id="getCascadingHierarchyLevelForOptions" parameterType="map" resultType="map">
SELECT L.*, G.HIERARCHY_TYPE
FROM CASCADING_HIERARCHY_LEVEL L
JOIN CASCADING_HIERARCHY_GROUP G
ON L.GROUP_CODE = G.GROUP_CODE AND L.COMPANY_CODE = G.COMPANY_CODE
WHERE L.GROUP_CODE = #{group_code}
AND L.LEVEL_ORDER = #{level_order}
AND L.IS_ACTIVE = 'Y'
<if test="company_code != null and company_code != &quot;*&quot;">
AND (L.COMPANY_CODE = #{company_code} OR L.COMPANY_CODE = '*')
</if>
</select>
<insert id="insertCascadingHierarchyGroup" parameterType="map" useGeneratedKeys="true" keyProperty="groupId">
INSERT INTO CASCADING_HIERARCHY_GROUP (
GROUP_CODE, GROUP_NAME, DESCRIPTION, HIERARCHY_TYPE,
MAX_LEVELS, IS_FIXED_LEVELS,
SELF_REF_TABLE, SELF_REF_ID_COLUMN, SELF_REF_PARENT_COLUMN,
SELF_REF_VALUE_COLUMN, SELF_REF_LABEL_COLUMN, SELF_REF_LEVEL_COLUMN, SELF_REF_ORDER_COLUMN,
BOM_TABLE, BOM_PARENT_COLUMN, BOM_CHILD_COLUMN,
BOM_ITEM_TABLE, BOM_ITEM_ID_COLUMN, BOM_ITEM_LABEL_COLUMN, BOM_QTY_COLUMN, BOM_LEVEL_COLUMN,
EMPTY_MESSAGE, NO_OPTIONS_MESSAGE, LOADING_MESSAGE,
COMPANY_CODE, IS_ACTIVE, CREATED_BY, CREATED_DATE
) VALUES (
#{group_code},
#{group_name},
#{description, jdbcType=VARCHAR},
#{hierarchy_type},
#{max_levels, jdbcType=INTEGER},
#{is_fixed_levels, jdbcType=VARCHAR},
#{self_ref_table, jdbcType=VARCHAR},
#{self_ref_id_column, jdbcType=VARCHAR},
#{self_ref_parent_column, jdbcType=VARCHAR},
#{self_ref_value_column, jdbcType=VARCHAR},
#{self_ref_label_column, jdbcType=VARCHAR},
#{self_ref_level_column, jdbcType=VARCHAR},
#{self_ref_order_column, jdbcType=VARCHAR},
#{bom_table, jdbcType=VARCHAR},
#{bom_parent_column, jdbcType=VARCHAR},
#{bom_child_column, jdbcType=VARCHAR},
#{bom_item_table, jdbcType=VARCHAR},
#{bom_item_id_column, jdbcType=VARCHAR},
#{bom_item_label_column, jdbcType=VARCHAR},
#{bom_qty_column, jdbcType=VARCHAR},
#{bom_level_column, jdbcType=VARCHAR},
#{empty_message, jdbcType=VARCHAR},
#{no_options_message, jdbcType=VARCHAR},
#{loading_message, jdbcType=VARCHAR},
#{company_code},
'Y',
#{created_by, jdbcType=VARCHAR},
CURRENT_TIMESTAMP
)
</insert>
<insert id="insertCascadingHierarchyLevel" parameterType="map" useGeneratedKeys="true" keyProperty="levelId">
INSERT INTO CASCADING_HIERARCHY_LEVEL (
GROUP_CODE, COMPANY_CODE, LEVEL_ORDER, LEVEL_NAME, LEVEL_CODE,
TABLE_NAME, VALUE_COLUMN, LABEL_COLUMN, PARENT_KEY_COLUMN,
FILTER_COLUMN, FILTER_VALUE, ORDER_COLUMN, ORDER_DIRECTION,
PLACEHOLDER, IS_REQUIRED, IS_SEARCHABLE, IS_ACTIVE, CREATED_DATE
) VALUES (
#{group_code},
#{company_code},
#{level_order},
#{level_name},
#{level_code, jdbcType=VARCHAR},
#{table_name},
#{value_column},
#{label_column},
#{parent_key_column, jdbcType=VARCHAR},
#{filter_column, jdbcType=VARCHAR},
#{filter_value, jdbcType=VARCHAR},
#{order_column, jdbcType=VARCHAR},
#{order_direction, jdbcType=VARCHAR},
#{placeholder, jdbcType=VARCHAR},
#{is_required, jdbcType=VARCHAR},
#{is_searchable, jdbcType=VARCHAR},
'Y',
CURRENT_TIMESTAMP
)
</insert>
<update id="updateCascadingHierarchyGroup" parameterType="map">
UPDATE CASCADING_HIERARCHY_GROUP SET
GROUP_NAME = COALESCE(#{group_name, jdbcType=VARCHAR}, GROUP_NAME),
DESCRIPTION = COALESCE(#{description, jdbcType=VARCHAR}, DESCRIPTION),
MAX_LEVELS = COALESCE(#{max_levels, jdbcType=INTEGER}, MAX_LEVELS),
IS_FIXED_LEVELS = COALESCE(#{is_fixed_levels, jdbcType=VARCHAR}, IS_FIXED_LEVELS),
EMPTY_MESSAGE = COALESCE(#{empty_message, jdbcType=VARCHAR}, EMPTY_MESSAGE),
NO_OPTIONS_MESSAGE = COALESCE(#{no_options_message, jdbcType=VARCHAR}, NO_OPTIONS_MESSAGE),
LOADING_MESSAGE = COALESCE(#{loading_message, jdbcType=VARCHAR}, LOADING_MESSAGE),
IS_ACTIVE = COALESCE(#{is_active, jdbcType=VARCHAR}, IS_ACTIVE),
UPDATED_BY = #{updated_by, jdbcType=VARCHAR},
UPDATED_DATE = CURRENT_TIMESTAMP
WHERE GROUP_CODE = #{group_code}
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</update>
<update id="updateCascadingHierarchyLevel" parameterType="map">
UPDATE CASCADING_HIERARCHY_LEVEL SET
LEVEL_NAME = COALESCE(#{level_name, jdbcType=VARCHAR}, LEVEL_NAME),
TABLE_NAME = COALESCE(#{table_name, jdbcType=VARCHAR}, TABLE_NAME),
VALUE_COLUMN = COALESCE(#{value_column, jdbcType=VARCHAR}, VALUE_COLUMN),
LABEL_COLUMN = COALESCE(#{label_column, jdbcType=VARCHAR}, LABEL_COLUMN),
PARENT_KEY_COLUMN = COALESCE(#{parent_key_column, jdbcType=VARCHAR}, PARENT_KEY_COLUMN),
FILTER_COLUMN = COALESCE(#{filter_column, jdbcType=VARCHAR}, FILTER_COLUMN),
FILTER_VALUE = COALESCE(#{filter_value, jdbcType=VARCHAR}, FILTER_VALUE),
ORDER_COLUMN = COALESCE(#{order_column, jdbcType=VARCHAR}, ORDER_COLUMN),
ORDER_DIRECTION = COALESCE(#{order_direction, jdbcType=VARCHAR}, ORDER_DIRECTION),
PLACEHOLDER = COALESCE(#{placeholder, jdbcType=VARCHAR}, PLACEHOLDER),
IS_REQUIRED = COALESCE(#{is_required, jdbcType=VARCHAR}, IS_REQUIRED),
IS_SEARCHABLE = COALESCE(#{is_searchable, jdbcType=VARCHAR}, IS_SEARCHABLE),
IS_ACTIVE = COALESCE(#{is_active, jdbcType=VARCHAR}, IS_ACTIVE),
UPDATED_DATE = CURRENT_TIMESTAMP
WHERE LEVEL_ID = #{level_id}
</update>
<delete id="deleteCascadingHierarchyLevels" parameterType="map">
DELETE FROM CASCADING_HIERARCHY_LEVEL
WHERE GROUP_CODE = #{group_code}
<if test="company_code != null and company_code != &quot;*&quot;">
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</delete>
<delete id="deleteCascadingHierarchyLevel" parameterType="map">
DELETE FROM CASCADING_HIERARCHY_LEVEL
WHERE LEVEL_ID = #{level_id}
<if test="company_code != null and company_code != &quot;*&quot;">
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</delete>
<delete id="deleteCascadingHierarchyGroup" parameterType="map">
DELETE FROM CASCADING_HIERARCHY_GROUP
WHERE GROUP_CODE = #{group_code}
<if test="company_code != null and company_code != &quot;*&quot;">
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</delete>
</mapper>
@@ -1,145 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cascadingMutualExclusion">
<sql id="cascadingMutualExclusionSearchCondition">
<if test="is_active != null and is_active != ''">
AND IS_ACTIVE = #{is_active}
</if>
<if test="keyword != null and keyword != ''">
AND EXCLUSION_NAME ILIKE '%' || #{keyword} || '%'
</if>
</sql>
<select id="getCascadingMutualExclusionList" parameterType="map" resultType="map">
SELECT
EXCLUSION_ID
, EXCLUSION_CODE
, EXCLUSION_NAME
, FIELD_NAMES
, SOURCE_TABLE
, VALUE_COLUMN
, LABEL_COLUMN
, EXCLUSION_TYPE
, ERROR_MESSAGE
, COMPANY_CODE
, IS_ACTIVE
, CREATED_DATE
FROM CASCADING_MUTUAL_EXCLUSION
WHERE 1=1
<include refid="common.companyCodeFilter"/>
<include refid="cascadingMutualExclusionSearchCondition"/>
ORDER BY CREATED_DATE DESC
<include refid="common.pagination"/>
</select>
<select id="getCascadingMutualExclusionListCnt" parameterType="map" resultType="int">
SELECT
COUNT(*)
FROM CASCADING_MUTUAL_EXCLUSION
WHERE 1=1
<include refid="common.companyCodeFilter"/>
<include refid="cascadingMutualExclusionSearchCondition"/>
</select>
<select id="getCascadingMutualExclusionInfo" parameterType="map" resultType="map">
SELECT
EXCLUSION_ID
, EXCLUSION_CODE
, EXCLUSION_NAME
, FIELD_NAMES
, SOURCE_TABLE
, VALUE_COLUMN
, LABEL_COLUMN
, EXCLUSION_TYPE
, ERROR_MESSAGE
, COMPANY_CODE
, IS_ACTIVE
, CREATED_DATE
FROM CASCADING_MUTUAL_EXCLUSION
WHERE EXCLUSION_ID = #{id}
<include refid="common.companyCodeFilter"/>
</select>
<!-- 코드로 단건 조회 (is_active = 'Y') -->
<select id="getCascadingMutualExclusionByCode" parameterType="map" resultType="map">
SELECT
EXCLUSION_ID
, EXCLUSION_CODE
, EXCLUSION_NAME
, FIELD_NAMES
, SOURCE_TABLE
, VALUE_COLUMN
, LABEL_COLUMN
, EXCLUSION_TYPE
, ERROR_MESSAGE
, COMPANY_CODE
, IS_ACTIVE
FROM CASCADING_MUTUAL_EXCLUSION
WHERE EXCLUSION_CODE = #{code}
AND IS_ACTIVE = 'Y'
<include refid="common.companyCodeFilter"/>
LIMIT 1
</select>
<!-- 코드 자동 생성용 카운트 -->
<select id="getCascadingMutualExclusionCount" parameterType="map" resultType="int">
SELECT
COUNT(*)
FROM CASCADING_MUTUAL_EXCLUSION
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</select>
<insert id="insertCascadingMutualExclusion" parameterType="map"
useGeneratedKeys="true" keyProperty="exclusionId" keyColumn="exclusion_id">
INSERT INTO CASCADING_MUTUAL_EXCLUSION (
EXCLUSION_CODE
, EXCLUSION_NAME
, FIELD_NAMES
, SOURCE_TABLE
, VALUE_COLUMN
, LABEL_COLUMN
, EXCLUSION_TYPE
, ERROR_MESSAGE
, COMPANY_CODE
, IS_ACTIVE
, CREATED_DATE
) VALUES (
#{exclusion_code, jdbcType=VARCHAR}
, #{exclusion_name, jdbcType=VARCHAR}
, #{field_names, jdbcType=VARCHAR}
, #{source_table, jdbcType=VARCHAR}
, #{value_column, jdbcType=VARCHAR}
, #{label_column, jdbcType=VARCHAR}
, #{exclusion_type, jdbcType=VARCHAR}
, #{error_message, jdbcType=VARCHAR}
, #{company_code}
, 'Y'
, CURRENT_TIMESTAMP
)
</insert>
<update id="updateCascadingMutualExclusion" parameterType="map">
UPDATE CASCADING_MUTUAL_EXCLUSION
SET
EXCLUSION_NAME = COALESCE(#{exclusion_name, jdbcType=VARCHAR}, EXCLUSION_NAME)
, FIELD_NAMES = COALESCE(#{field_names, jdbcType=VARCHAR}, FIELD_NAMES)
, SOURCE_TABLE = COALESCE(#{source_table, jdbcType=VARCHAR}, SOURCE_TABLE)
, VALUE_COLUMN = COALESCE(#{value_column, jdbcType=VARCHAR}, VALUE_COLUMN)
, LABEL_COLUMN = COALESCE(#{label_column, jdbcType=VARCHAR}, LABEL_COLUMN)
, EXCLUSION_TYPE = COALESCE(#{exclusion_type, jdbcType=VARCHAR}, EXCLUSION_TYPE)
, ERROR_MESSAGE = COALESCE(#{error_message, jdbcType=VARCHAR}, ERROR_MESSAGE)
, IS_ACTIVE = COALESCE(#{is_active, jdbcType=VARCHAR}, IS_ACTIVE)
WHERE EXCLUSION_ID = #{id}
<include refid="common.companyCodeFilter"/>
</update>
<!-- 하드 삭제 (Node.js와 동일) -->
<delete id="deleteCascadingMutualExclusion" parameterType="map">
DELETE FROM CASCADING_MUTUAL_EXCLUSION
WHERE EXCLUSION_ID = #{id}
<include refid="common.companyCodeFilter"/>
</delete>
</mapper>
@@ -1,160 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cascadingRelation">
<sql id="cascadingRelationColumns">
RELATION_ID
, RELATION_CODE
, RELATION_NAME
, DESCRIPTION
, PARENT_TABLE
, PARENT_VALUE_COLUMN
, PARENT_LABEL_COLUMN
, CHILD_TABLE
, CHILD_FILTER_COLUMN
, CHILD_VALUE_COLUMN
, CHILD_LABEL_COLUMN
, CHILD_ORDER_COLUMN
, CHILD_ORDER_DIRECTION
, EMPTY_PARENT_MESSAGE
, NO_OPTIONS_MESSAGE
, LOADING_MESSAGE
, CLEAR_ON_PARENT_CHANGE
, COMPANY_CODE
, IS_ACTIVE
, CREATED_BY
, CREATED_DATE
, UPDATED_BY
, UPDATED_DATE
</sql>
<sql id="cascadingRelationSearchCondition">
<if test="is_active != null and is_active != ''">
AND IS_ACTIVE = #{is_active}
</if>
<if test="keyword != null and keyword != ''">
AND (RELATION_NAME ILIKE '%' || #{keyword} || '%'
OR RELATION_CODE ILIKE '%' || #{keyword} || '%')
</if>
</sql>
<select id="getCascadingRelationList" parameterType="map" resultType="map">
SELECT <include refid="cascadingRelationColumns"/>
FROM CASCADING_RELATION
WHERE 1=1
<include refid="common.companyCodeFilter"/>
<include refid="cascadingRelationSearchCondition"/>
ORDER BY CREATED_DATE DESC
<include refid="common.pagination"/>
</select>
<select id="getCascadingRelationListCnt" parameterType="map" resultType="int">
SELECT COUNT(*)
FROM CASCADING_RELATION
WHERE 1=1
<include refid="common.companyCodeFilter"/>
<include refid="cascadingRelationSearchCondition"/>
</select>
<select id="getCascadingRelationInfo" parameterType="map" resultType="map">
SELECT <include refid="cascadingRelationColumns"/>
FROM CASCADING_RELATION
WHERE RELATION_ID = #{id}
<include refid="common.companyCodeFilter"/>
</select>
<!-- 코드로 단건 조회 (is_active = 'Y' 조건 포함) -->
<select id="getCascadingRelationByCode" parameterType="map" resultType="map">
SELECT <include refid="cascadingRelationColumns"/>
FROM CASCADING_RELATION
WHERE RELATION_CODE = #{code}
AND IS_ACTIVE = 'Y'
<include refid="common.companyCodeFilter"/>
LIMIT 1
</select>
<insert id="insertCascadingRelation" parameterType="map"
useGeneratedKeys="true" keyProperty="relationId" keyColumn="relation_id">
INSERT INTO CASCADING_RELATION (
RELATION_CODE
, RELATION_NAME
, DESCRIPTION
, PARENT_TABLE
, PARENT_VALUE_COLUMN
, PARENT_LABEL_COLUMN
, CHILD_TABLE
, CHILD_FILTER_COLUMN
, CHILD_VALUE_COLUMN
, CHILD_LABEL_COLUMN
, CHILD_ORDER_COLUMN
, CHILD_ORDER_DIRECTION
, EMPTY_PARENT_MESSAGE
, NO_OPTIONS_MESSAGE
, LOADING_MESSAGE
, CLEAR_ON_PARENT_CHANGE
, COMPANY_CODE
, IS_ACTIVE
, CREATED_BY
, CREATED_DATE
) VALUES (
#{relation_code, jdbcType=VARCHAR}
, #{relation_name, jdbcType=VARCHAR}
, #{description, jdbcType=VARCHAR}
, #{parent_table, jdbcType=VARCHAR}
, #{parent_value_column, jdbcType=VARCHAR}
, #{parent_label_column, jdbcType=VARCHAR}
, #{child_table, jdbcType=VARCHAR}
, #{child_filter_column, jdbcType=VARCHAR}
, #{child_value_column, jdbcType=VARCHAR}
, #{child_label_column, jdbcType=VARCHAR}
, #{child_order_column, jdbcType=VARCHAR}
, #{child_order_direction, jdbcType=VARCHAR}
, #{empty_parent_message, jdbcType=VARCHAR}
, #{no_options_message, jdbcType=VARCHAR}
, #{loading_message, jdbcType=VARCHAR}
, #{clear_on_parent_change, jdbcType=VARCHAR}
, #{company_code}
, #{is_active, jdbcType=VARCHAR}
, #{user_id, jdbcType=VARCHAR}
, CURRENT_TIMESTAMP
)
</insert>
<update id="updateCascadingRelation" parameterType="map">
UPDATE CASCADING_RELATION
SET
RELATION_NAME = COALESCE(#{relation_name, jdbcType=VARCHAR}, RELATION_NAME)
, DESCRIPTION = COALESCE(#{description, jdbcType=VARCHAR}, DESCRIPTION)
, PARENT_TABLE = COALESCE(#{parent_table, jdbcType=VARCHAR}, PARENT_TABLE)
, PARENT_VALUE_COLUMN = COALESCE(#{parent_value_column, jdbcType=VARCHAR}, PARENT_VALUE_COLUMN)
, PARENT_LABEL_COLUMN = COALESCE(#{parent_label_column, jdbcType=VARCHAR}, PARENT_LABEL_COLUMN)
, CHILD_TABLE = COALESCE(#{child_table, jdbcType=VARCHAR}, CHILD_TABLE)
, CHILD_FILTER_COLUMN = COALESCE(#{child_filter_column, jdbcType=VARCHAR}, CHILD_FILTER_COLUMN)
, CHILD_VALUE_COLUMN = COALESCE(#{child_value_column, jdbcType=VARCHAR}, CHILD_VALUE_COLUMN)
, CHILD_LABEL_COLUMN = COALESCE(#{child_label_column, jdbcType=VARCHAR}, CHILD_LABEL_COLUMN)
, CHILD_ORDER_COLUMN = COALESCE(#{child_order_column, jdbcType=VARCHAR}, CHILD_ORDER_COLUMN)
, CHILD_ORDER_DIRECTION = COALESCE(#{child_order_direction, jdbcType=VARCHAR}, CHILD_ORDER_DIRECTION)
, EMPTY_PARENT_MESSAGE = COALESCE(#{empty_parent_message, jdbcType=VARCHAR}, EMPTY_PARENT_MESSAGE)
, NO_OPTIONS_MESSAGE = COALESCE(#{no_options_message, jdbcType=VARCHAR}, NO_OPTIONS_MESSAGE)
, LOADING_MESSAGE = COALESCE(#{loading_message, jdbcType=VARCHAR}, LOADING_MESSAGE)
, CLEAR_ON_PARENT_CHANGE = COALESCE(#{clear_on_parent_change, jdbcType=VARCHAR}, CLEAR_ON_PARENT_CHANGE)
, IS_ACTIVE = COALESCE(#{is_active, jdbcType=VARCHAR}, IS_ACTIVE)
, UPDATED_BY = #{user_id, jdbcType=VARCHAR}
, UPDATED_DATE = CURRENT_TIMESTAMP
WHERE RELATION_ID = #{id}
<include refid="common.companyCodeFilter"/>
</update>
<!-- 소프트 삭제: is_active = 'N' -->
<update id="deleteCascadingRelation" parameterType="map">
UPDATE CASCADING_RELATION
SET
IS_ACTIVE = 'N'
, UPDATED_BY = #{user_id, jdbcType=VARCHAR}
, UPDATED_DATE = CURRENT_TIMESTAMP
WHERE RELATION_ID = #{id}
<include refid="common.companyCodeFilter"/>
</update>
</mapper>
@@ -1,182 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="categoryTree">
<!-- 공통 컬럼 -->
<sql id="categoryValueColumns">
value_id, table_name, column_name, value_code, value_label, value_order,
parent_value_id, depth, path, description, color, icon,
is_active, is_default, company_code,
CREATED_DATE, UPDATED_DATE, created_by, updated_by
</sql>
<!-- 카테고리 플랫 리스트 조회 (트리/플랫 모두 사용) -->
<select id="getCategoryTreeList" parameterType="map" resultType="map">
SELECT
<include refid="categoryValueColumns"/>
FROM category_values
WHERE (company_code = #{company_code} OR company_code = '*')
AND table_name = #{table_name}
AND column_name = #{column_name}
ORDER BY depth ASC, value_order ASC, value_label ASC
</select>
<!-- 카테고리 값 단건 조회 -->
<select id="getCategoryTreeInfo" parameterType="map" resultType="map">
SELECT
<include refid="categoryValueColumns"/>
FROM category_values
WHERE (company_code = #{company_code} OR company_code = '*')
AND value_id = #{value_id}
</select>
<!-- 카테고리 값 생성 -->
<insert id="insertCategoryTree" parameterType="map"
useGeneratedKeys="true" keyProperty="valueId" keyColumn="value_id">
INSERT INTO category_values (
table_name, column_name, value_code, value_label, value_order,
parent_value_id, depth, path, description, color, icon,
is_active, is_default, company_code, created_by, updated_by
) VALUES (
#{table_name}, #{column_name}, #{value_code}, #{value_label}, #{value_order},
#{parent_value_id}, #{depth}, #{path}, #{description}, #{color}, #{icon},
#{is_active}, #{is_default}, #{company_code}, #{created_by}, #{created_by}
)
</insert>
<!-- 카테고리 값 수정 (COALESCE로 부분 업데이트) -->
<update id="updateCategoryTree" parameterType="map">
UPDATE category_values
SET
value_code = COALESCE(#{value_code}, value_code),
value_label = COALESCE(#{value_label}, value_label),
value_order = COALESCE(#{value_order}, value_order),
parent_value_id = #{parent_value_id},
depth = #{depth},
path = #{path},
description = COALESCE(#{description}, description),
color = COALESCE(#{color}, color),
icon = COALESCE(#{icon}, icon),
is_active = COALESCE(#{is_active}, is_active),
is_default = COALESCE(#{is_default}, is_default),
UPDATED_DATE = NOW(),
updated_by = #{updated_by}
WHERE (company_code = #{company_code} OR company_code = '*')
AND value_id = #{value_id}
</update>
<!-- 카테고리 값 삭제 -->
<delete id="deleteCategoryTree" parameterType="map">
DELETE FROM category_values
WHERE (company_code = #{company_code} OR company_code = '*')
AND value_id = #{value_id}
</delete>
<!-- 자식 카테고리 수 조회 -->
<select id="getCategoryTreeChildrenCnt" parameterType="map" resultType="int">
SELECT COUNT(*) FROM category_values
WHERE parent_value_id = #{value_id}
AND (company_code = #{company_code} OR company_code = '*')
</select>
<!-- 테이블 존재 여부 확인 (0 또는 1 반환) -->
<select id="checkTableExists" parameterType="map" resultType="int">
SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = #{table_name}
</select>
<!-- 컬럼 존재 여부 확인 (0 또는 1 반환) -->
<select id="checkColumnExists" parameterType="map" resultType="int">
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = #{table_name}
AND column_name = #{column_name}
</select>
<!-- 카테고리 값 사용 건수 조회 (company_code 필터 포함) -->
<select id="countCategoryUsageWithCompany" parameterType="map" resultType="int">
<![CDATA[
SELECT COUNT(*) FROM "${tableName}"
WHERE (company_code = #{company_code} OR company_code = '*')
AND (#{value_code} = ANY(string_to_array("${columnName}"::text, ','))
OR "${columnName}"::text = #{value_code})
]]>
</select>
<!-- 카테고리 값 사용 건수 조회 (company_code 필터 없음) -->
<select id="countCategoryUsage" parameterType="map" resultType="int">
<![CDATA[
SELECT COUNT(*) FROM "${tableName}"
WHERE #{value_code} = ANY(string_to_array("${columnName}"::text, ','))
OR "${columnName}"::text = #{value_code}
]]>
</select>
<!-- 직계 자식 목록 조회 (path 업데이트용) -->
<select id="getCategoryTreeChildrenList" parameterType="map" resultType="map">
SELECT value_id, value_label
FROM category_values
WHERE (company_code = #{company_code} OR company_code = '*')
AND parent_value_id = #{parent_value_id}
</select>
<!-- 자식 path 단건 업데이트 -->
<update id="updateCategoryTreeChildPath" parameterType="map">
UPDATE category_values
SET path = #{path}, UPDATED_DATE = NOW()
WHERE value_id = #{value_id}
</update>
<!-- 테이블의 카테고리 컬럼 목록 조회 -->
<select id="getCategoryTreeColumnList" parameterType="map" resultType="map">
SELECT DISTINCT column_name, column_label
FROM table_type_columns
WHERE table_name = #{table_name}
AND input_type = 'category'
AND (company_code = #{company_code} OR company_code = '*')
ORDER BY column_name
</select>
<!-- 전체 카테고리 키 목록 조회 (table_name + column_name 조합) -->
<select id="getCategoryTreeKeyList" parameterType="map" resultType="map">
SELECT DISTINCT
cv.table_name,
cv.column_name,
COALESCE(tl.table_label, cv.table_name) AS table_label,
COALESCE(ttc.column_label, cv.column_name) AS column_label
FROM category_values cv
LEFT JOIN table_labels tl ON tl.table_name = cv.table_name
LEFT JOIN table_type_columns ttc ON ttc.table_name = cv.table_name
AND ttc.column_name = cv.column_name
AND ttc.company_code = '*'
WHERE (cv.company_code = #{company_code} OR cv.company_code = '*')
OR cv.company_code = '*'
ORDER BY cv.table_name, cv.column_name
</select>
</mapper>
@@ -1,179 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="categoryValueCascading">
<sql id="groupSearchCondition">
<if test="keyword != null and keyword != ''">
AND (RELATION_NAME ILIKE '%' || #{keyword} || '%' OR RELATION_CODE ILIKE '%' || #{keyword} || '%')
</if>
<if test="is_active != null and is_active != ''">
AND IS_ACTIVE = #{is_active}
</if>
</sql>
<select id="getCategoryValueCascadingGroupList" parameterType="map" resultType="map">
SELECT
*
FROM CATEGORY_VALUE_CASCADING_GROUP
WHERE 1=1
<include refid="common.companyCodeFilter"/>
<include refid="groupSearchCondition"/>
<choose>
<when test="sort_column != null and sort_column != ''">
ORDER BY ${sortColumn}
<if test="sort_direction != null and sort_direction != ''">
${sortDirection}
</if>
</when>
<otherwise>
ORDER BY RELATION_NAME ASC
</otherwise>
</choose>
<include refid="common.pagination"/>
</select>
<select id="getCategoryValueCascadingGroupListCnt" parameterType="map" resultType="int">
SELECT
COUNT(*)
FROM CATEGORY_VALUE_CASCADING_GROUP
WHERE 1=1
<include refid="common.companyCodeFilter"/>
<include refid="groupSearchCondition"/>
</select>
<select id="getCategoryValueCascadingGroupInfo" parameterType="map" resultType="map">
SELECT
*
FROM CATEGORY_VALUE_CASCADING_GROUP
WHERE GROUP_ID = #{group_id}
<include refid="common.companyCodeFilter"/>
</select>
<select id="getCategoryValueCascadingGroupByCode" parameterType="map" resultType="map">
SELECT
*
FROM CATEGORY_VALUE_CASCADING_GROUP
WHERE RELATION_CODE = #{code}
AND IS_ACTIVE = 'Y'
<include refid="common.companyCodeFilter"/>
LIMIT 1
</select>
<insert id="insertCategoryValueCascadingGroup" parameterType="map" useGeneratedKeys="true" keyProperty="groupId">
INSERT INTO CATEGORY_VALUE_CASCADING_GROUP (
RELATION_CODE
, RELATION_NAME
, DESCRIPTION
, PARENT_TABLE_NAME
, PARENT_COLUMN_NAME
, PARENT_MENU_OBJID
, CHILD_TABLE_NAME
, CHILD_COLUMN_NAME
, CHILD_MENU_OBJID
, CLEAR_ON_PARENT_CHANGE
, SHOW_GROUP_LABEL
, EMPTY_PARENT_MESSAGE
, NO_OPTIONS_MESSAGE
, COMPANY_CODE
, IS_ACTIVE
, CREATED_BY
, CREATED_DATE
) VALUES (
#{relation_code}
, #{relation_name}
, #{description}
, #{parent_table_name}
, #{parent_column_name}
, #{parent_menu_objid}
, #{child_table_name}
, #{child_column_name}
, #{child_menu_objid}
, COALESCE(#{clear_on_parent_change}, 'Y')
, COALESCE(#{show_group_label}, 'Y')
, COALESCE(#{empty_parent_message}, '상위 항목을 먼저 선택하세요')
, COALESCE(#{no_options_message}, '선택 가능한 항목이 없습니다')
, #{company_code}
, 'Y'
, #{created_by}
, NOW()
)
</insert>
<update id="updateCategoryValueCascadingGroup" parameterType="map">
UPDATE CATEGORY_VALUE_CASCADING_GROUP
SET
RELATION_NAME = COALESCE(#{relation_name}, RELATION_NAME)
, DESCRIPTION = COALESCE(#{description}, DESCRIPTION)
, PARENT_TABLE_NAME = COALESCE(#{parent_table_name}, PARENT_TABLE_NAME)
, PARENT_COLUMN_NAME = COALESCE(#{parent_column_name}, PARENT_COLUMN_NAME)
, PARENT_MENU_OBJID = COALESCE(#{parent_menu_objid}, PARENT_MENU_OBJID)
, CHILD_TABLE_NAME = COALESCE(#{child_table_name}, CHILD_TABLE_NAME)
, CHILD_COLUMN_NAME = COALESCE(#{child_column_name}, CHILD_COLUMN_NAME)
, CHILD_MENU_OBJID = COALESCE(#{child_menu_objid}, CHILD_MENU_OBJID)
, CLEAR_ON_PARENT_CHANGE = COALESCE(#{clear_on_parent_change}, CLEAR_ON_PARENT_CHANGE)
, SHOW_GROUP_LABEL = COALESCE(#{show_group_label}, SHOW_GROUP_LABEL)
, EMPTY_PARENT_MESSAGE = COALESCE(#{empty_parent_message}, EMPTY_PARENT_MESSAGE)
, NO_OPTIONS_MESSAGE = COALESCE(#{no_options_message}, NO_OPTIONS_MESSAGE)
, IS_ACTIVE = COALESCE(#{is_active}, IS_ACTIVE)
, UPDATED_BY = #{updated_by}
, UPDATED_DATE = NOW()
WHERE GROUP_ID = #{group_id}
<include refid="common.companyCodeFilter"/>
</update>
<update id="deleteCategoryValueCascadingGroup" parameterType="map">
UPDATE CATEGORY_VALUE_CASCADING_GROUP
SET
IS_ACTIVE = 'N'
, UPDATED_BY = #{updated_by}
, UPDATED_DATE = NOW()
WHERE GROUP_ID = #{group_id}
<include refid="common.companyCodeFilter"/>
</update>
<select id="getCategoryValueCascadingMappingsByGroupId" parameterType="map" resultType="map">
SELECT
MAPPING_ID
, PARENT_VALUE_CODE
, PARENT_VALUE_LABEL
, CHILD_VALUE_CODE
, CHILD_VALUE_LABEL
, DISPLAY_ORDER
, IS_ACTIVE
FROM CATEGORY_VALUE_CASCADING_MAPPING
WHERE GROUP_ID = #{group_id}
AND IS_ACTIVE = 'Y'
ORDER BY PARENT_VALUE_CODE, DISPLAY_ORDER, CHILD_VALUE_LABEL
</select>
<delete id="deleteCategoryValueCascadingMappingsByGroupId" parameterType="map">
DELETE FROM CATEGORY_VALUE_CASCADING_MAPPING
WHERE GROUP_ID = #{group_id}
</delete>
<insert id="insertCategoryValueCascadingMapping" parameterType="map" useGeneratedKeys="true" keyProperty="mappingId">
INSERT INTO CATEGORY_VALUE_CASCADING_MAPPING (
GROUP_ID
, PARENT_VALUE_CODE
, PARENT_VALUE_LABEL
, CHILD_VALUE_CODE
, CHILD_VALUE_LABEL
, DISPLAY_ORDER
, COMPANY_CODE
, IS_ACTIVE
, CREATED_DATE
) VALUES (
#{group_id}
, #{parent_value_code}
, #{parent_value_label}
, #{child_value_code}
, #{child_value_label}
, COALESCE(#{display_order}, 0)
, #{company_code}
, 'Y'
, NOW()
)
</insert>
</mapper>
@@ -1,30 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="codeMerge">
<!--
columnName 컬럼과 company_code 컬럼을 함께 가진 public BASE TABLE 목록 조회
테이블명은 information_schema 검증값이므로 동적 SQL 사용 시 안전
-->
<select id="getTablesWithColumn" parameterType="map" resultType="map">
SELECT DISTINCT t.table_name
FROM information_schema.columns c
JOIN information_schema.tables t
ON c.table_name = t.table_name
WHERE c.column_name = #{column_name}
AND t.table_schema = 'public'
AND t.table_type = 'BASE TABLE'
AND EXISTS (
SELECT 1 FROM information_schema.columns c2
WHERE c2.table_name = t.table_name
AND c2.column_name = 'company_code'
)
ORDER BY t.table_name
</select>
</mapper>
@@ -4,455 +4,436 @@
<mapper namespace="commonCode">
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- code_category -->
<!-- CODE_INFO — 1레벨 그룹 마스터 -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<select id="getCommonCodeCategoryList" parameterType="map" resultType="map">
<select id="getCodeInfoList" parameterType="map" resultType="map">
SELECT
category_code,
category_name,
category_name_eng,
description,
sort_order,
is_active,
menu_objid,
company_code,
created_by,
updated_by,
created_date,
updated_date
CODE_INFO
, CODE_NAME
, CODE_NAME_ENG
, DESCRIPTION
, SORT_ORDER
, IS_ACTIVE
, MENU_OBJID
, COMPANY_CODE
, CREATED_BY
, UPDATED_BY
, CREATED_DATE
, UPDATED_DATE
FROM code_category
FROM CODE_INFO
WHERE 1=1
<include refid="common.companyCodeFilter"/>
<if test="search != null and search != ''">
AND (
LOWER(category_code) LIKE LOWER(CONCAT('%', #{search}, '%'))
OR LOWER(category_name) LIKE LOWER(CONCAT('%', #{search}, '%'))
OR LOWER(COALESCE(category_name_eng, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
LOWER(CODE_INFO) LIKE LOWER(CONCAT('%', #{search}, '%'))
OR LOWER(CODE_NAME) LIKE LOWER(CONCAT('%', #{search}, '%'))
OR LOWER(COALESCE(CODE_NAME_ENG, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
)
</if>
<if test="is_active != null">
AND is_active = #{is_active}
</if>
<if test="menu_objid != null">
AND menu_objid = #{menu_objid}
AND IS_ACTIVE = #{is_active}
</if>
ORDER BY sort_order ASC, category_code ASC
ORDER BY SORT_ORDER ASC, CODE_INFO ASC
<include refid="common.pagination"/>
</select>
<select id="getCommonCodeCategoryListCnt" parameterType="map" resultType="int">
<select id="getCodeInfoListCnt" parameterType="map" resultType="int">
SELECT COUNT(*)
FROM code_category
FROM CODE_INFO
WHERE 1=1
<include refid="common.companyCodeFilter"/>
<if test="search != null and search != ''">
AND (
LOWER(category_code) LIKE LOWER(CONCAT('%', #{search}, '%'))
OR LOWER(category_name) LIKE LOWER(CONCAT('%', #{search}, '%'))
OR LOWER(COALESCE(category_name_eng, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
LOWER(CODE_INFO) LIKE LOWER(CONCAT('%', #{search}, '%'))
OR LOWER(CODE_NAME) LIKE LOWER(CONCAT('%', #{search}, '%'))
OR LOWER(COALESCE(CODE_NAME_ENG, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
)
</if>
<if test="is_active != null">
AND is_active = #{is_active}
</if>
<if test="menu_objid != null">
AND menu_objid = #{menu_objid}
AND IS_ACTIVE = #{is_active}
</if>
</select>
<select id="getCommonCodeCategoryInfo" parameterType="map" resultType="map">
<select id="getCodeInfoInfo" parameterType="map" resultType="map">
SELECT
category_code,
category_name,
category_name_eng,
description,
sort_order,
is_active,
menu_objid,
company_code,
created_by,
updated_by,
created_date,
updated_date
CODE_INFO
, CODE_NAME
, CODE_NAME_ENG
, DESCRIPTION
, SORT_ORDER
, IS_ACTIVE
, MENU_OBJID
, COMPANY_CODE
, CREATED_BY
, UPDATED_BY
, CREATED_DATE
, UPDATED_DATE
FROM code_category
FROM CODE_INFO
WHERE category_code = #{category_code}
WHERE CODE_INFO = #{code_info}
<include refid="common.companyCodeFilter"/>
</select>
<insert id="insertCommonCodeCategory" parameterType="map">
INSERT INTO code_category (
category_code,
category_name,
category_name_eng,
description,
sort_order,
is_active,
menu_objid,
company_code,
created_by,
updated_by,
created_date,
updated_date
<insert id="insertCodeInfo" parameterType="map">
INSERT INTO CODE_INFO (
CODE_INFO
, CODE_NAME
, CODE_NAME_ENG
, DESCRIPTION
, SORT_ORDER
, IS_ACTIVE
, MENU_OBJID
, COMPANY_CODE
, CREATED_BY
, UPDATED_BY
, CREATED_DATE
, UPDATED_DATE
) VALUES (
#{category_code},
#{category_name},
#{category_name_eng},
#{description},
#{sort_order},
#{is_active},
#{menu_objid},
#{company_code},
#{created_by},
#{updated_by},
NOW(),
NOW()
#{code_info}
, #{code_name}
, #{code_name_eng}
, #{description}
, #{sort_order}
, #{is_active}
, #{menu_objid}
, #{company_code}
, #{created_by}
, #{updated_by}
, NOW()
, NOW()
)
</insert>
<update id="updateCommonCodeCategory" parameterType="map">
UPDATE code_category
<update id="updateCodeInfo" parameterType="map">
UPDATE CODE_INFO
<set>
<if test="category_name != null">category_name = #{category_name},</if>
<if test="category_name_eng != null">category_name_eng = #{category_name_eng},</if>
<if test="description != null">description = #{description},</if>
<if test="sort_order != null">sort_order = #{sort_order},</if>
<if test="is_active != null">is_active = #{is_active},</if>
updated_by = #{updated_by},
updated_date = NOW()
<if test="code_name != null">CODE_NAME = #{code_name},</if>
<if test="code_name_eng != null">CODE_NAME_ENG = #{code_name_eng},</if>
<if test="description != null">DESCRIPTION = #{description},</if>
<if test="sort_order != null">SORT_ORDER = #{sort_order},</if>
<if test="is_active != null">IS_ACTIVE = #{is_active},</if>
<if test="menu_objid != null">MENU_OBJID = #{menu_objid},</if>
UPDATED_BY = #{updated_by},
UPDATED_DATE = NOW()
</set>
WHERE category_code = #{category_code}
WHERE CODE_INFO = #{code_info}
<include refid="common.companyCodeFilter"/>
</update>
<delete id="deleteCommonCodeCategory" parameterType="map">
DELETE FROM code_category
<delete id="deleteCodeInfo" parameterType="map">
DELETE FROM CODE_INFO
WHERE category_code = #{category_code}
WHERE CODE_INFO = #{code_info}
<include refid="common.companyCodeFilter"/>
</delete>
<select id="getCommonCodeCategoryDuplicateCnt" parameterType="map" resultType="int">
<select id="getCodeInfoDuplicateCnt" parameterType="map" resultType="int">
SELECT COUNT(*)
FROM code_category
FROM CODE_INFO
WHERE category_code = #{category_code}
WHERE CODE_INFO = #{code_info}
<include refid="common.companyCodeFilter"/>
</select>
<select id="getCommonCodeCategoryDuplicateByField" parameterType="map" resultType="int">
<select id="getCodeInfoDuplicateByField" parameterType="map" resultType="int">
SELECT COUNT(*)
FROM code_category
FROM CODE_INFO
WHERE 1=1
<include refid="common.companyCodeFilter"/>
<choose>
<when test="field == 'categoryCode'">AND category_code = #{value}</when>
<when test="field == 'categoryName'">AND category_name = #{value}</when>
<when test="field == 'categoryNameEng'">AND category_name_eng = #{value}</when>
<otherwise>AND category_code = #{value}</otherwise>
<when test='field == "code_info"'>AND CODE_INFO = #{value}</when>
<when test='field == "code_name"'>AND CODE_NAME = #{value}</when>
<when test='field == "code_name_eng"'>AND CODE_NAME_ENG = #{value}</when>
<otherwise>AND CODE_INFO = #{value}</otherwise>
</choose>
<if test="exclude_code != null and exclude_code != ''">
AND category_code != #{exclude_code}
AND CODE_INFO != #{exclude_code}
</if>
</select>
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- code_info -->
<!-- CODE_DETAIL — 2레벨 ~ 무한대 트리 -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<select id="getCommonCodeList" parameterType="map" resultType="map">
<select id="getCodeDetailList" parameterType="map" resultType="map">
SELECT
code_category,
code_value,
code_name,
code_name_eng,
description,
sort_order,
is_active,
menu_objid,
company_code,
parent_code_value,
depth,
created_by,
updated_by,
created_date,
updated_date
CODE_DETAIL_ID
, CODE_INFO
, PARENT_DETAIL_ID
, CODE_VALUE
, CODE_NAME
, CODE_NAME_ENG
, DESCRIPTION
, DEPTH
, SORT_ORDER
, IS_ACTIVE
, COMPANY_CODE
, CREATED_BY
, UPDATED_BY
, CREATED_DATE
, UPDATED_DATE
FROM code_info
FROM CODE_DETAIL
WHERE code_category = #{category_code}
WHERE CODE_INFO = #{code_info}
<include refid="common.companyCodeFilter"/>
<choose>
<when test="parent_detail_id != null">
AND PARENT_DETAIL_ID = #{parent_detail_id}
</when>
<when test="only_roots != null and only_roots == true">
AND PARENT_DETAIL_ID IS NULL
</when>
</choose>
<if test="search != null and search != ''">
AND (
LOWER(code_value) LIKE LOWER(CONCAT('%', #{search}, '%'))
OR LOWER(code_name) LIKE LOWER(CONCAT('%', #{search}, '%'))
OR LOWER(COALESCE(code_name_eng, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
LOWER(CODE_VALUE) LIKE LOWER(CONCAT('%', #{search}, '%'))
OR LOWER(CODE_NAME) LIKE LOWER(CONCAT('%', #{search}, '%'))
OR LOWER(COALESCE(CODE_NAME_ENG, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
)
</if>
<if test="is_active != null">
AND is_active = #{is_active}
AND IS_ACTIVE = #{is_active}
</if>
ORDER BY sort_order ASC, code_value ASC
ORDER BY DEPTH ASC, SORT_ORDER ASC, CODE_VALUE ASC
<include refid="common.pagination"/>
</select>
<select id="getCommonCodeListCnt" parameterType="map" resultType="int">
<select id="getCodeDetailListCnt" parameterType="map" resultType="int">
SELECT COUNT(*)
FROM code_info
FROM CODE_DETAIL
WHERE code_category = #{category_code}
WHERE CODE_INFO = #{code_info}
<include refid="common.companyCodeFilter"/>
<choose>
<when test="parent_detail_id != null">
AND PARENT_DETAIL_ID = #{parent_detail_id}
</when>
<when test="only_roots != null and only_roots == true">
AND PARENT_DETAIL_ID IS NULL
</when>
</choose>
<if test="search != null and search != ''">
AND (
LOWER(code_value) LIKE LOWER(CONCAT('%', #{search}, '%'))
OR LOWER(code_name) LIKE LOWER(CONCAT('%', #{search}, '%'))
OR LOWER(COALESCE(code_name_eng, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
LOWER(CODE_VALUE) LIKE LOWER(CONCAT('%', #{search}, '%'))
OR LOWER(CODE_NAME) LIKE LOWER(CONCAT('%', #{search}, '%'))
OR LOWER(COALESCE(CODE_NAME_ENG, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
)
</if>
<if test="is_active != null">
AND is_active = #{is_active}
AND IS_ACTIVE = #{is_active}
</if>
</select>
<select id="getCommonCodeInfo" parameterType="map" resultType="map">
<select id="getCodeDetailInfo" parameterType="map" resultType="map">
SELECT
code_category,
code_value,
code_name,
code_name_eng,
description,
sort_order,
is_active,
menu_objid,
company_code,
parent_code_value,
depth,
created_by,
updated_by,
created_date,
updated_date
CODE_DETAIL_ID
, CODE_INFO
, PARENT_DETAIL_ID
, CODE_VALUE
, CODE_NAME
, CODE_NAME_ENG
, DESCRIPTION
, DEPTH
, SORT_ORDER
, IS_ACTIVE
, COMPANY_CODE
, CREATED_BY
, UPDATED_BY
, CREATED_DATE
, UPDATED_DATE
FROM code_info
FROM CODE_DETAIL
WHERE code_category = #{category_code}
AND code_value = #{code_value}
WHERE CODE_DETAIL_ID = #{code_detail_id}
<include refid="common.companyCodeFilter"/>
</select>
<insert id="insertCommonCode" parameterType="map">
INSERT INTO code_info (
code_category,
code_value,
code_name,
code_name_eng,
description,
sort_order,
is_active,
menu_objid,
company_code,
parent_code_value,
depth,
created_by,
updated_by,
created_date,
updated_date
<!--
그룹 전체 트리 — 재귀 CTE 로 평탄화.
depth 오름차순 → sort_order 오름차순 → code_value 오름차순.
-->
<select id="getCodeDetailTree" parameterType="map" resultType="map">
WITH RECURSIVE TREE AS (
SELECT
CODE_DETAIL_ID
, CODE_INFO
, PARENT_DETAIL_ID
, CODE_VALUE
, CODE_NAME
, CODE_NAME_ENG
, DESCRIPTION
, DEPTH
, SORT_ORDER
, IS_ACTIVE
, COMPANY_CODE
, CREATED_BY
, UPDATED_BY
, CREATED_DATE
, UPDATED_DATE
, ARRAY[SORT_ORDER, CODE_DETAIL_ID] AS PATH
FROM CODE_DETAIL
WHERE CODE_INFO = #{code_info}
AND PARENT_DETAIL_ID IS NULL
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
UNION ALL
SELECT
C.CODE_DETAIL_ID
, C.CODE_INFO
, C.PARENT_DETAIL_ID
, C.CODE_VALUE
, C.CODE_NAME
, C.CODE_NAME_ENG
, C.DESCRIPTION
, C.DEPTH
, C.SORT_ORDER
, C.IS_ACTIVE
, C.COMPANY_CODE
, C.CREATED_BY
, C.UPDATED_BY
, C.CREATED_DATE
, C.UPDATED_DATE
, TREE.PATH || ARRAY[C.SORT_ORDER, C.CODE_DETAIL_ID]
FROM CODE_DETAIL C
INNER JOIN TREE ON C.PARENT_DETAIL_ID = TREE.CODE_DETAIL_ID
WHERE C.CODE_INFO = #{code_info}
<if test='company_code != null and company_code != "*"'>
AND (C.COMPANY_CODE = #{company_code} OR C.COMPANY_CODE = '*')
</if>
)
SELECT
CODE_DETAIL_ID
, CODE_INFO
, PARENT_DETAIL_ID
, CODE_VALUE
, CODE_NAME
, CODE_NAME_ENG
, DESCRIPTION
, DEPTH
, SORT_ORDER
, IS_ACTIVE
, COMPANY_CODE
, CREATED_BY
, UPDATED_BY
, CREATED_DATE
, UPDATED_DATE
FROM TREE
ORDER BY PATH
</select>
<insert id="insertCodeDetail" parameterType="map" useGeneratedKeys="true" keyProperty="code_detail_id" keyColumn="code_detail_id">
INSERT INTO CODE_DETAIL (
CODE_INFO
, PARENT_DETAIL_ID
, CODE_VALUE
, CODE_NAME
, CODE_NAME_ENG
, DESCRIPTION
, DEPTH
, SORT_ORDER
, IS_ACTIVE
, COMPANY_CODE
, CREATED_BY
, UPDATED_BY
, CREATED_DATE
, UPDATED_DATE
) VALUES (
#{category_code},
#{code_value},
#{code_name},
#{code_name_eng},
#{description},
#{sort_order},
#{is_active},
#{menu_objid},
#{company_code},
#{parent_code_value},
#{depth},
#{created_by},
#{updated_by},
NOW(),
NOW()
#{code_info}
, #{parent_detail_id}
, #{code_value}
, #{code_name}
, #{code_name_eng}
, #{description}
, #{depth}
, #{sort_order}
, #{is_active}
, #{company_code}
, #{created_by}
, #{updated_by}
, NOW()
, NOW()
)
</insert>
<update id="updateCommonCode" parameterType="map">
UPDATE code_info
<update id="updateCodeDetail" parameterType="map">
UPDATE CODE_DETAIL
<set>
<if test="code_name != null">code_name = #{code_name},</if>
<if test="code_name_eng != null">code_name_eng = #{code_name_eng},</if>
<if test="description != null">description = #{description},</if>
<if test="sort_order != null">sort_order = #{sort_order},</if>
<if test="is_active != null">is_active = #{is_active},</if>
<if test="parent_code_value != null">parent_code_value = #{parent_code_value},</if>
<if test="depth != null">depth = #{depth},</if>
updated_by = #{updated_by},
updated_date = NOW()
<if test="code_value != null">CODE_VALUE = #{code_value},</if>
<if test="code_name != null">CODE_NAME = #{code_name},</if>
<if test="code_name_eng != null">CODE_NAME_ENG = #{code_name_eng},</if>
<if test="description != null">DESCRIPTION = #{description},</if>
<if test="sort_order != null">SORT_ORDER = #{sort_order},</if>
<if test="is_active != null">IS_ACTIVE = #{is_active},</if>
<if test="reparent != null and reparent == true">
PARENT_DETAIL_ID = #{parent_detail_id},
DEPTH = #{depth},
</if>
UPDATED_BY = #{updated_by},
UPDATED_DATE = NOW()
</set>
WHERE code_category = #{category_code}
AND code_value = #{code_value}
WHERE CODE_DETAIL_ID = #{code_detail_id}
<include refid="common.companyCodeFilter"/>
</update>
<delete id="deleteCommonCode" parameterType="map">
DELETE FROM code_info
<delete id="deleteCodeDetail" parameterType="map">
DELETE FROM CODE_DETAIL
WHERE code_category = #{category_code}
AND code_value = #{code_value}
WHERE CODE_DETAIL_ID = #{code_detail_id}
<include refid="common.companyCodeFilter"/>
</delete>
<update id="updateCommonCodeSortOrder" parameterType="map">
UPDATE code_info
SET sort_order = #{sort_order},
updated_date = NOW()
WHERE code_category = #{category_code}
AND code_value = #{code_value}
<include refid="common.companyCodeFilter"/>
</update>
<select id="getCommonCodeDuplicateCnt" parameterType="map" resultType="int">
<select id="getCodeDetailDuplicateCnt" parameterType="map" resultType="int">
SELECT COUNT(*)
FROM code_info
FROM CODE_DETAIL
WHERE code_category = #{category_code}
AND code_value = #{code_value}
WHERE CODE_INFO = #{code_info}
AND CODE_VALUE = #{code_value}
<include refid="common.companyCodeFilter"/>
<if test="exclude_id != null">
AND CODE_DETAIL_ID != #{exclude_id}
</if>
</select>
<select id="getCommonCodeDuplicateByField" parameterType="map" resultType="int">
<select id="getCodeDetailChildrenCnt" parameterType="map" resultType="int">
SELECT COUNT(*)
FROM code_info
FROM CODE_DETAIL
WHERE code_category = #{category_code}
<include refid="common.companyCodeFilter"/>
<choose>
<when test="field == 'codeValue'">AND code_value = #{value}</when>
<when test="field == 'codeName'">AND code_name = #{value}</when>
<when test="field == 'codeNameEng'">AND code_name_eng = #{value}</when>
<otherwise>AND code_value = #{value}</otherwise>
</choose>
<if test="exclude_code != null and exclude_code != ''">
AND code_value != #{exclude_code}
</if>
</select>
<select id="getCommonCodeParentDepth" parameterType="map" resultType="int">
SELECT COALESCE(depth, 0)
FROM code_info
WHERE code_category = #{category_code}
AND code_value = #{code_value}
WHERE PARENT_DETAIL_ID = #{code_detail_id}
<include refid="common.companyCodeFilter"/>
</select>
<select id="getCommonCodeChildrenCnt" parameterType="map" resultType="int">
SELECT COUNT(*)
<select id="getCodeDetailParentDepth" parameterType="map" resultType="int">
SELECT COALESCE(DEPTH, 1)
FROM code_info
FROM CODE_DETAIL
WHERE code_category = #{category_code}
AND parent_code_value = #{code_value}
WHERE CODE_DETAIL_ID = #{code_detail_id}
<include refid="common.companyCodeFilter"/>
</select>
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- 계층 / 트리 / 옵션 -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<select id="getCommonCodeHierarchicalList" parameterType="map" resultType="map">
SELECT
code_category,
code_value,
code_name,
code_name_eng,
description,
sort_order,
is_active,
menu_objid,
company_code,
parent_code_value,
depth,
created_by,
updated_by,
created_date,
updated_date
FROM code_info
WHERE code_category = #{category_code}
<include refid="common.companyCodeFilter"/>
<if test="is_active != null">
AND is_active = #{is_active}
</if>
<if test="parent_code_value != null">
AND parent_code_value = #{parent_code_value}
</if>
<if test="depth != null">
AND depth = #{depth}
</if>
ORDER BY depth ASC, sort_order ASC, code_value ASC
</select>
<select id="getCommonCodeTreeList" parameterType="map" resultType="map">
SELECT
code_category,
code_value,
code_name,
code_name_eng,
description,
sort_order,
is_active,
menu_objid,
company_code,
parent_code_value,
depth,
created_by,
updated_by,
created_date,
updated_date
FROM code_info
WHERE code_category = #{category_code}
<include refid="common.companyCodeFilter"/>
ORDER BY depth ASC, sort_order ASC, code_value ASC
</select>
<select id="getCommonCodeOptionList" parameterType="map" resultType="map">
SELECT
code_value,
code_name,
code_name_eng
FROM code_info
WHERE code_category = #{category_code}
<include refid="common.companyCodeFilter"/>
<if test="is_active != null">
AND is_active = #{is_active}
</if>
ORDER BY sort_order ASC, code_value ASC
</select>
</mapper>
@@ -23,13 +23,23 @@
D.SORT_ORDER,
D.STATUS,
D.DELETED_AT,
COUNT(DISTINCT UD.USER_ID) AS MEMBER_COUNT
COUNT(DISTINCT UD.USER_ID) AS MEMBER_COUNT,
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = D.DEPT_CODE AND m.ROLE = 'approval')::TEXT AS APPROVAL_MANAGERS,
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = D.DEPT_CODE AND m.ROLE = 'dept')::TEXT AS DEPT_MANAGERS,
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = D.DEPT_CODE AND m.ROLE = 'org_leader')::TEXT AS ORG_LEADERS
FROM DEPT_INFO D
LEFT JOIN USER_DEPT UD ON D.DEPT_CODE = UD.DEPT_CODE
WHERE (D.COMPANY_CODE = #{company_code} OR D.COMPANY_CODE = '*')
<if test="include_deleted == null or include_deleted == false">
AND D.DELETED_AT IS NULL
</if>
<if test="base_date != null and base_date != ''">
AND (D.START_DATE IS NULL OR D.START_DATE &lt;= #{base_date}::date)
AND (D.END_DATE IS NULL OR D.END_DATE &gt;= #{base_date}::date)
</if>
GROUP BY
D.DEPT_CODE, D.DEPT_NAME, D.COMPANY_CODE, D.PARENT_DEPT_CODE,
D.SHORT_NAME, D.DEPT_TYPE, D.ORG_SYSTEM, D.APPROVAL_MANAGER, D.DEPT_MANAGER,
@@ -57,7 +67,13 @@
END_DATE,
SORT_ORDER,
STATUS,
DELETED_AT
DELETED_AT,
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'approval')::TEXT AS APPROVAL_MANAGERS,
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'dept')::TEXT AS DEPT_MANAGERS,
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'org_leader')::TEXT AS ORG_LEADERS
FROM DEPT_INFO
WHERE DEPT_CODE = #{dept_code}
AND DELETED_AT IS NULL
@@ -82,7 +98,13 @@
END_DATE,
SORT_ORDER,
STATUS,
DELETED_AT
DELETED_AT,
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'approval')::TEXT AS APPROVAL_MANAGERS,
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'dept')::TEXT AS DEPT_MANAGERS,
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'org_leader')::TEXT AS ORG_LEADERS
FROM DEPT_INFO
WHERE DEPT_CODE = #{dept_code}
</select>
@@ -302,4 +324,27 @@
AND DEPT_CODE = #{dept_code}
</update>
<!-- 부서별 관리자 매핑 (role 단위 sync 용) — 전체 삭제 -->
<delete id="deleteDeptManagersByDeptAndRole" parameterType="map">
DELETE FROM DEPT_MANAGERS
WHERE DEPT_CODE = #{dept_code}
AND ROLE = #{role}
</delete>
<!-- 부서별 관리자 매핑 — bulk insert. parameterType=map, list 와 role 전달. -->
<insert id="insertDeptManagers" parameterType="map">
INSERT INTO DEPT_MANAGERS (DEPT_CODE, USER_ID, ROLE, SORT_ORDER) VALUES
<foreach collection="user_ids" item="uid" index="idx" separator=",">
(#{dept_code}, #{uid}, #{role}, #{idx} + 1)
</foreach>
</insert>
<!-- 사용자 ID 들이 같은 회사(또는 글로벌 *) 에 실존하는지 검증 — cross-tenant injection 방지 -->
<select id="selectValidUserIds" parameterType="map" resultType="string">
SELECT USER_ID FROM USER_INFO
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
AND USER_ID IN
<foreach collection="user_ids" item="u" open="(" separator="," close=")">#{u}</foreach>
</select>
</mapper>
@@ -70,7 +70,7 @@
FROM code_info
WHERE code_category = #{code_category}
WHERE code_info = #{code_info}
AND is_active = 'Y'
<if test='company_code != null and company_code != "*"'>
AND (company_code = #{company_code} OR company_code = '*')
@@ -38,17 +38,17 @@
</select>
<!-- ================================================================
정적 쿼리: table_type_columns의 code_category 조회
정적 쿼리: table_type_columns의 code_info 조회
================================================================ -->
<select id="getCodeCategoryInfo" parameterType="map" resultType="map">
SELECT code_category
SELECT code_info
FROM table_type_columns
WHERE table_name = #{table_name}
AND column_name = #{column_name}
AND code_category IS NOT NULL
AND code_info IS NOT NULL
LIMIT 1
</select>
@@ -62,7 +62,7 @@
FROM code_info
WHERE code_category = #{code_category}
WHERE code_info = #{code_info}
AND code_value IN
<foreach collection="rawValues" item="v" open="(" separator="," close=")">
#{v}
@@ -81,7 +81,7 @@
, E.CREATED_DATE
, E.UPDATED_DATE
FROM EXTERNAL_DB_CONNECTIONS E
WHERE E.ID = #{id}
WHERE E.ID = #{id}::varchar
</select>
<!-- 단건 조회 (비밀번호 포함 - 내부 전용) -->
@@ -109,14 +109,14 @@
, CREATED_DATE
, UPDATED_DATE
FROM EXTERNAL_DB_CONNECTIONS
WHERE ID = #{id}
WHERE ID = #{id}::varchar
</select>
<!-- 비밀번호만 조회 -->
<select id="getExternalDbConnectionPassword" parameterType="map" resultType="map">
SELECT PASSWORD
FROM EXTERNAL_DB_CONNECTIONS
WHERE ID = #{id}
WHERE ID = #{id}::varchar
</select>
<!-- 이름+회사 중복 확인 -->
@@ -134,7 +134,7 @@
FROM EXTERNAL_DB_CONNECTIONS
WHERE CONNECTION_NAME = #{connection_name}
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
AND ID != #{exclude_id}
AND ID != #{exclude_id}::varchar
LIMIT 1
</select>
@@ -166,13 +166,13 @@
, #{description}
, #{db_type}
, #{host}
, #{port}
, #{port}::varchar
, #{database_name}
, #{username}
, #{password}
, #{connection_timeout}
, #{query_timeout}
, #{max_connections}
, #{connection_timeout}::varchar
, #{query_timeout}::varchar
, #{max_connections}::varchar
, #{ssl_enabled}
, #{ssl_cert_path}
, #{connection_options}::JSONB
@@ -193,13 +193,13 @@
<if test="description != null">DESCRIPTION = #{description},</if>
<if test="db_type != null">DB_TYPE = #{db_type},</if>
<if test="host != null">HOST = #{host},</if>
<if test="port != null">PORT = #{port},</if>
<if test="port != null">PORT = #{port}::varchar,</if>
<if test="database_name != null">DATABASE_NAME = #{database_name},</if>
<if test="username != null">USERNAME = #{username},</if>
<if test="password != null">PASSWORD = #{password},</if>
<if test="connection_timeout != null">CONNECTION_TIMEOUT = #{connection_timeout},</if>
<if test="query_timeout != null">QUERY_TIMEOUT = #{query_timeout},</if>
<if test="max_connections != null">MAX_CONNECTIONS = #{max_connections},</if>
<if test="connection_timeout != null">CONNECTION_TIMEOUT = #{connection_timeout}::varchar,</if>
<if test="query_timeout != null">QUERY_TIMEOUT = #{query_timeout}::varchar,</if>
<if test="max_connections != null">MAX_CONNECTIONS = #{max_connections}::varchar,</if>
<if test="ssl_enabled != null">SSL_ENABLED = #{ssl_enabled},</if>
<if test="ssl_cert_path != null">SSL_CERT_PATH = #{ssl_cert_path},</if>
<if test="connection_options != null">CONNECTION_OPTIONS = #{connection_options}::JSONB,</if>
@@ -208,13 +208,13 @@
<if test="updated_by != null">UPDATED_BY = #{updated_by},</if>
UPDATED_DATE = NOW()
</set>
WHERE ID = #{id}
WHERE ID = #{id}::varchar
</update>
<!-- 삭제 -->
<delete id="deleteExternalDbConnection" parameterType="map">
DELETE FROM EXTERNAL_DB_CONNECTIONS
WHERE ID = #{id}
WHERE ID = #{id}::varchar
<if test="company_code != null and company_code != &quot;*&quot;">
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
@@ -69,7 +69,7 @@
SELECT
<include refid="selectColumns"/>
FROM EXTERNAL_REST_API_CONNECTIONS E
WHERE E.ID = #{id}
WHERE E.ID = #{id}::varchar
<include refid="common.companyCodeFilter"/>
</select>
@@ -133,14 +133,14 @@
<if test="save_to_history != null">SAVE_TO_HISTORY = #{save_to_history},</if>
<if test="updated_by != null">UPDATED_BY = #{updated_by},</if>
</set>
WHERE ID = #{id}
WHERE ID = #{id}::varchar
<include refid="common.companyCodeFilter"/>
</update>
<!-- 연결 삭제 -->
<delete id="deleteExternalRestApiConnection" parameterType="map">
DELETE FROM EXTERNAL_REST_API_CONNECTIONS
WHERE ID = #{id}
WHERE ID = #{id}::varchar
<include refid="common.companyCodeFilter"/>
</delete>
@@ -151,7 +151,7 @@
LAST_TEST_DATE = NOW()
, LAST_TEST_RESULT = #{last_test_result}
, LAST_TEST_MESSAGE = #{last_test_message}
WHERE ID = #{id}
WHERE ID = #{id}::varchar
</update>
<!-- DB 토큰 조회 (db-token auth type) -->
@@ -67,7 +67,7 @@
, REFERENCE_TABLE
, REFERENCE_COLUMN
, DISPLAY_COLUMN
, CODE_CATEGORY
, CODE_INFO
, CODE_VALUE
, COMPANY_CODE
FROM TABLE_TYPE_COLUMNS
@@ -16,8 +16,8 @@
category_column AS category_column,
category_value_id AS category_value_id,
created_by AS created_by,
CREATED_DATE AS CREATED_DATE,
UPDATED_DATE AS UPDATED_DATE
created_at AS created_at,
updated_at AS updated_at
</sql>
<sql id="partColumns">
@@ -42,7 +42,7 @@
<otherwise>AND (company_code = #{company_code} OR company_code = '*')</otherwise>
</choose>
ORDER BY CREATED_DATE DESC
ORDER BY created_at DESC
</select>
<select id="getRuleById" parameterType="map" resultType="map">
@@ -61,19 +61,19 @@
INSERT INTO numbering_rules (
rule_id, rule_name, description, separator, reset_period,
current_sequence, table_name, column_name, company_code,
category_column, category_value_id, created_by, CREATED_DATE, UPDATED_DATE
category_column, category_value_id, created_by, created_at, updated_at
) VALUES (
#{rule_id},
#{rule_name},
#{description, jdbcType=VARCHAR},
#{separator, jdbcType=VARCHAR},
#{reset_period, jdbcType=VARCHAR},
#{current_sequence, jdbcType=INTEGER},
#{current_sequence, jdbcType=VARCHAR},
#{table_name, jdbcType=VARCHAR},
#{column_name, jdbcType=VARCHAR},
#{company_code},
#{category_column, jdbcType=VARCHAR},
#{category_value_id, jdbcType=INTEGER},
#{category_value_id, jdbcType=VARCHAR},
#{created_by, jdbcType=VARCHAR},
NOW(), NOW()
)
@@ -89,8 +89,8 @@
table_name = COALESCE(#{table_name, jdbcType=VARCHAR}, table_name),
column_name = COALESCE(#{column_name, jdbcType=VARCHAR}, column_name),
category_column = COALESCE(#{category_column, jdbcType=VARCHAR}, category_column),
category_value_id = COALESCE(#{category_value_id, jdbcType=INTEGER}, category_value_id),
UPDATED_DATE = NOW()
category_value_id = COALESCE(#{category_value_id, jdbcType=VARCHAR}, category_value_id),
updated_at = NOW()
WHERE rule_id = #{rule_id}
AND (company_code = #{company_code} OR company_code = '*')
@@ -122,7 +122,7 @@
<insert id="insertRulePart" parameterType="map">
INSERT INTO numbering_rule_parts (
rule_id, part_order, part_type, generation_method,
auto_config, manual_config, company_code, CREATED_DATE
auto_config, manual_config, company_code, created_at
) VALUES (
#{rule_id},
#{order},
@@ -164,7 +164,17 @@
<update id="updateCurrentSequenceInRule" parameterType="map">
UPDATE numbering_rules
SET current_sequence = GREATEST(COALESCE(current_sequence, '0'), #{current_sequence}),
UPDATED_DATE = NOW()
updated_at = NOW()
WHERE rule_id = #{rule_id}
AND (company_code = #{company_code} OR company_code = '*')
</update>
<!-- admin 전용: GREATEST 없이 직접 SET. 임의 값 (0 포함) 으로 내릴 수 있음 -->
<update id="setCurrentSequenceInRule" parameterType="map">
UPDATE numbering_rules
SET current_sequence = #{current_sequence},
updated_at = NOW()
WHERE rule_id = #{rule_id}
AND (company_code = #{company_code} OR company_code = '*')
@@ -183,7 +193,7 @@
<otherwise>AND (company_code = #{company_code} OR company_code = '*')</otherwise>
</choose>
ORDER BY CREATED_DATE DESC
ORDER BY created_at DESC
</select>
<select id="getAvailableRulesForScreen" parameterType="map" resultType="map">
@@ -200,7 +210,7 @@
AND table_name = #{table_name}
</if>
ORDER BY CREATED_DATE DESC
ORDER BY created_at DESC
</select>
<select id="getRuleByColumn" parameterType="map" resultType="map">
@@ -218,8 +228,8 @@
r.category_value_id AS category_value_id,
cv.value_label AS category_value_label,
r.created_by AS created_by,
r.CREATED_DATE AS CREATED_DATE,
r.UPDATED_DATE AS UPDATED_DATE
r.created_at AS created_at,
r.updated_at AS updated_at
FROM numbering_rules r
@@ -247,8 +257,8 @@
r.category_value_id AS category_value_id,
cv.value_label AS category_value_label,
r.created_by AS created_by,
r.CREATED_DATE AS CREATED_DATE,
r.UPDATED_DATE AS UPDATED_DATE
r.created_at AS created_at,
r.updated_at AS updated_at
FROM numbering_rules r
@@ -259,7 +269,7 @@
AND (r.column_name IS NULL OR r.column_name = '')
AND r.category_value_id IS NULL
ORDER BY r.UPDATED_DATE DESC
ORDER BY r.updated_at DESC
LIMIT 1
</select>
@@ -280,7 +290,7 @@
WHERE (company_code = #{company_code} OR company_code = '*')
ORDER BY CREATED_DATE
ORDER BY created_at
</select>
<select id="getRulePartsForCopy" parameterType="map" resultType="map">
@@ -704,11 +704,19 @@
</foreach>
AND SL.PROPERTIES->'componentConfig'->'action'->>'type' = 'save'
AND SL.PROPERTIES->'componentConfig'->'action'->>'targetScreenId' IS NULL
<!-- table-like 화면 (canonical 'table' / legacy 'table-list' / hidden 'v2-table-list')
중 체크박스가 활성화된 것이 있으면 제외.
체크박스 config 경로가 두 가지로 구분된다:
- legacy table-list / v2-table-list : componentConfig.checkbox.enabled (boolean)
- canonical table : componentConfig.showCheckbox (boolean) -->
AND NOT EXISTS (
SELECT 1 FROM SCREEN_LAYOUTS SL_LIST
WHERE SL_LIST.SCREEN_ID = SD.SCREEN_ID
AND SL_LIST.PROPERTIES->>'componentType' = 'table-list'
AND (SL_LIST.PROPERTIES->'componentConfig'->'checkbox'->>'enabled')::BOOLEAN = TRUE
AND SL_LIST.PROPERTIES->>'componentType' IN ('table', 'table-list', 'v2-table-list')
AND (
(SL_LIST.PROPERTIES->'componentConfig'->'checkbox'->>'enabled')::BOOLEAN = TRUE
OR (SL_LIST.PROPERTIES->'componentConfig'->>'showCheckbox')::BOOLEAN = TRUE
)
)
AND NOT EXISTS (
SELECT 1 FROM SCREEN_LAYOUTS SL_MODAL
@@ -1091,12 +1091,12 @@
<select id="selectCodeCategoryForCopy" parameterType="map" resultType="map">
SELECT
*
FROM CODE_CATEGORY
FROM CODE_INFO
WHERE (COMPANY_CODE = #{source_company_code} OR COMPANY_CODE = '*')
</select>
<insert id="upsertCodeCategory" parameterType="map">
INSERT INTO CODE_CATEGORY (
INSERT INTO CODE_INFO (
CATEGORY_CODE
, CATEGORY_NAME
, COMPANY_CODE
@@ -1117,26 +1117,26 @@
*
FROM CODE_INFO
WHERE (COMPANY_CODE = #{source_company_code} OR COMPANY_CODE = '*')
AND CODE_CATEGORY = #{code_category}
AND CODE_INFO = #{code_info}
</select>
<insert id="upsertCodeInfo" parameterType="map">
INSERT INTO CODE_INFO (
CODE_CATEGORY
CODE_INFO
, CODE_VALUE
, CODE_NAME
, COMPANY_CODE
, SORT_ORDER
, IS_ACTIVE
) VALUES (
#{code_category}
#{code_info}
, #{code_value}
, #{code_name}
, #{target_company_code}
, #{sort_order}
, #{is_active}
)
ON CONFLICT (CODE_CATEGORY, CODE_VALUE, COMPANY_CODE) DO UPDATE SET
ON CONFLICT (CODE_INFO, CODE_VALUE, COMPANY_CODE) DO UPDATE SET
CODE_NAME = EXCLUDED.CODE_NAME
, SORT_ORDER = EXCLUDED.SORT_ORDER
, IS_ACTIVE = EXCLUDED.IS_ACTIVE
@@ -1359,7 +1359,7 @@
COLUMN_NAME
, INPUT_TYPE
, COLUMN_LABEL
, CODE_CATEGORY
, CODE_INFO
, REFERENCE_TABLE
, REFERENCE_COLUMN
, DISPLAY_COLUMN
@@ -1,470 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="tableCategoryValue">
<!-- ══════════════════════════════════════════════════════════════
Category Columns
══════════════════════════════════════════════════════════════ -->
<select id="getCategoryColumnList" parameterType="map" resultType="map">
SELECT
TC.TABLE_NAME
, TC.COLUMN_NAME
, TC.COLUMN_NAME AS column_label
, COUNT(CV.VALUE_ID) AS value_count
FROM TABLE_TYPE_COLUMNS TC
LEFT JOIN CATEGORY_VALUES CV
ON TC.TABLE_NAME = CV.TABLE_NAME
AND TC.COLUMN_NAME = CV.COLUMN_NAME
AND CV.IS_ACTIVE = TRUE
<if test='company_code != null and company_code != "*"'>
AND (CV.COMPANY_CODE = #{company_code} OR CV.COMPANY_CODE = '*')
</if>
WHERE TC.TABLE_NAME = #{table_name}
AND TC.INPUT_TYPE = 'category'
GROUP BY TC.TABLE_NAME, TC.COLUMN_NAME, TC.DISPLAY_ORDER
ORDER BY TC.DISPLAY_ORDER, TC.COLUMN_NAME
</select>
<select id="getAllCategoryColumnList" parameterType="map" resultType="map">
SELECT
TC.TABLE_NAME
, TC.COLUMN_NAME
, TC.COLUMN_NAME AS column_label
, COALESCE(CV_COUNT.cnt, 0) AS value_count
FROM (
SELECT DISTINCT TABLE_NAME, COLUMN_NAME, MIN(DISPLAY_ORDER) AS display_order
FROM TABLE_TYPE_COLUMNS
WHERE INPUT_TYPE = 'category'
GROUP BY TABLE_NAME, COLUMN_NAME
) TC
LEFT JOIN (
SELECT TABLE_NAME, COLUMN_NAME, COUNT(*) AS cnt
FROM CATEGORY_VALUES
WHERE IS_ACTIVE = TRUE
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
GROUP BY TABLE_NAME, COLUMN_NAME
) CV_COUNT
ON TC.TABLE_NAME = CV_COUNT.TABLE_NAME
AND TC.COLUMN_NAME = CV_COUNT.COLUMN_NAME
ORDER BY TC.TABLE_NAME, TC.DISPLAY_ORDER, TC.COLUMN_NAME
</select>
<!-- ══════════════════════════════════════════════════════════════
Category Values — Read
══════════════════════════════════════════════════════════════ -->
<select id="getCategoryValueList" parameterType="map" resultType="map">
SELECT
VALUE_ID
, TABLE_NAME
, COLUMN_NAME
, VALUE_CODE
, VALUE_LABEL
, VALUE_ORDER
, PARENT_VALUE_ID
, DEPTH
, DESCRIPTION
, COLOR
, ICON
, IS_ACTIVE
, IS_DEFAULT
, COMPANY_CODE
, MENU_OBJID
, CREATED_DATE
, UPDATED_DATE
, CREATED_BY
, UPDATED_BY
FROM CATEGORY_VALUES
WHERE TABLE_NAME = #{table_name}
AND COLUMN_NAME = #{column_name}
<choose>
<when test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</when>
<otherwise>
AND COMPANY_CODE = '*'
</otherwise>
</choose>
<if test="include_inactive == null or include_inactive == false">
AND IS_ACTIVE = TRUE
</if>
ORDER BY VALUE_ORDER, VALUE_LABEL
</select>
<select id="getCategoryValueInfo" parameterType="map" resultType="map">
SELECT
VALUE_ID
, TABLE_NAME
, COLUMN_NAME
, VALUE_CODE
, VALUE_LABEL
, VALUE_ORDER
, PARENT_VALUE_ID
, DEPTH
, DESCRIPTION
, COLOR
, ICON
, IS_ACTIVE
, IS_DEFAULT
, COMPANY_CODE
, MENU_OBJID
, CREATED_DATE
, UPDATED_DATE
, CREATED_BY
, UPDATED_BY
FROM CATEGORY_VALUES
WHERE VALUE_ID = #{value_id}
</select>
<!-- 사용 여부 확인용: table_name, column_name, value_code, value_label 반환 -->
<select id="getCategoryValueUsageInfo" parameterType="map" resultType="map">
SELECT
TABLE_NAME
, COLUMN_NAME
, VALUE_CODE
, VALUE_LABEL
FROM CATEGORY_VALUES
WHERE VALUE_ID = #{value_id}
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</select>
<!-- 수정 시 라벨 중복 체크용: table_name, column_name, company_code 반환 -->
<select id="getCategoryValueLabelInfo" parameterType="map" resultType="map">
SELECT
TABLE_NAME
, COLUMN_NAME
, COMPANY_CODE
FROM CATEGORY_VALUES
WHERE VALUE_ID = #{value_id}
</select>
<!-- ══════════════════════════════════════════════════════════════
Category Values — Write
══════════════════════════════════════════════════════════════ -->
<select id="countDuplicateCode" parameterType="map" resultType="int">
SELECT COUNT(*) FROM CATEGORY_VALUES
WHERE TABLE_NAME = #{table_name}
AND COLUMN_NAME = #{column_name}
AND VALUE_CODE = #{value_code}
AND MENU_OBJID = #{menu_objid}
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</select>
<select id="countDuplicateLabel" parameterType="map" resultType="int">
SELECT COUNT(*) FROM CATEGORY_VALUES
WHERE TABLE_NAME = #{table_name}
AND COLUMN_NAME = #{column_name}
AND VALUE_LABEL = #{value_label}
AND IS_ACTIVE = TRUE
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</select>
<!-- 수정 시 자기 자신 제외 라벨 중복 체크 (항상 company_code 필터) -->
<select id="countDuplicateLabelExcludeSelf" parameterType="map" resultType="int">
SELECT COUNT(*) FROM CATEGORY_VALUES
WHERE TABLE_NAME = #{table_name}
AND COLUMN_NAME = #{column_name}
AND VALUE_LABEL = #{value_label}
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
AND IS_ACTIVE = TRUE
AND VALUE_ID != #{value_id}
</select>
<insert id="insertCategoryValue" parameterType="map"
useGeneratedKeys="true" keyProperty="valueId" keyColumn="value_id">
INSERT INTO CATEGORY_VALUES (
TABLE_NAME, COLUMN_NAME, VALUE_CODE, VALUE_LABEL, VALUE_ORDER,
PARENT_VALUE_ID, DEPTH, DESCRIPTION, COLOR, ICON,
IS_ACTIVE, IS_DEFAULT, COMPANY_CODE, MENU_OBJID, CREATED_BY
) VALUES (
#{table_name}, #{column_name}, #{value_code}, #{value_label}, #{value_order},
#{parent_value_id}, #{depth}, #{description}, #{color}, #{icon},
#{is_active}, #{is_default}, #{company_code}, #{menu_objid}, #{user_id}
)
</insert>
<update id="updateCategoryValue" parameterType="map">
UPDATE CATEGORY_VALUES
<set>
<if test="value_label != null">VALUE_LABEL = #{value_label},</if>
<if test="value_order != null">VALUE_ORDER = #{value_order},</if>
<if test="description != null">DESCRIPTION = #{description},</if>
<if test="color != null">COLOR = #{color},</if>
<if test="icon != null">ICON = #{icon},</if>
<if test="is_active != null">IS_ACTIVE = #{is_active},</if>
<if test="is_default != null">IS_DEFAULT = #{is_default},</if>
UPDATED_DATE = NOW(),
UPDATED_BY = #{user_id}
</set>
WHERE VALUE_ID = #{value_id}
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</update>
<!-- ══════════════════════════════════════════════════════════════
Category Values — Delete
══════════════════════════════════════════════════════════════ -->
<select id="checkTableExistsForUsage" parameterType="map" resultType="int">
SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = #{table_name}
</select>
<!--
동적 테이블 쿼리: safeTableName, safeColumnName 은 서비스에서
[a-zA-Z0-9_] 로 sanitize 후 전달. ${} 는 리터럴 치환.
-->
<select id="countValueUsageInTable" parameterType="map" resultType="int">
SELECT COUNT(*)
FROM ${safeTableName}
WHERE ${safeColumnName} = #{value_code}
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</select>
<select id="getMenuListUsingTable" parameterType="map" resultType="map">
SELECT DISTINCT
MI.OBJID AS menu_objid
, MI.MENU_NAME_KOR AS menu_name
, MI.MENU_URL
FROM MENU_INFO MI
INNER JOIN SCREEN_MENU_ASSIGNMENTS SMA ON SMA.MENU_OBJID = MI.OBJID
INNER JOIN SCREEN_DEFINITIONS SD ON SD.SCREEN_ID = SMA.SCREEN_ID
WHERE SD.TABLE_NAME = #{table_name}
AND (MI.COMPANY_CODE = #{company_code} OR MI.COMPANY_CODE = '*')
ORDER BY MI.MENU_NAME_KOR
</select>
<!-- 재귀 CTE 로 모든 하위 value_id 수집 -->
<select id="getChildValueIdList" parameterType="map" resultType="map">
WITH RECURSIVE category_tree AS (
SELECT VALUE_ID
FROM CATEGORY_VALUES
WHERE PARENT_VALUE_ID = #{value_id}
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
UNION ALL
SELECT CV.VALUE_ID
FROM CATEGORY_VALUES CV
INNER JOIN category_tree CT ON CV.PARENT_VALUE_ID = CT.VALUE_ID
<if test='company_code != null and company_code != "*"'>
WHERE (CV.COMPANY_CODE = #{company_code} OR CV.COMPANY_CODE = '*')
</if>
)
SELECT VALUE_ID FROM category_tree
</select>
<delete id="deleteValueById" parameterType="map">
DELETE FROM CATEGORY_VALUES
WHERE VALUE_ID = #{value_id}
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</delete>
<!-- ══════════════════════════════════════════════════════════════
Bulk / Reorder
══════════════════════════════════════════════════════════════ -->
<update id="bulkSoftDeleteValues" parameterType="map">
UPDATE CATEGORY_VALUES
SET IS_ACTIVE = FALSE,
UPDATED_DATE = NOW(),
UPDATED_BY = #{user_id}
WHERE VALUE_ID IN
<foreach collection="valueIds" item="id" open="(" separator="," close=")">
#{id}
</foreach>
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</update>
<update id="updateValueOrder" parameterType="map">
UPDATE CATEGORY_VALUES
SET VALUE_ORDER = #{value_order},
UPDATED_DATE = NOW()
WHERE VALUE_ID = #{value_id}
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</update>
<!-- ══════════════════════════════════════════════════════════════
Column Mapping
══════════════════════════════════════════════════════════════ -->
<select id="getColumnMappingList" parameterType="map" resultType="map">
SELECT
LOGICAL_COLUMN_NAME
, PHYSICAL_COLUMN_NAME
FROM CATEGORY_COLUMN_MAPPING
WHERE TABLE_NAME = #{table_name}
AND MENU_OBJID = #{menu_objid}
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</select>
<select id="getLogicalColumnList" parameterType="map" resultType="map">
SELECT
MAPPING_ID
, LOGICAL_COLUMN_NAME
, PHYSICAL_COLUMN_NAME
, DESCRIPTION
FROM CATEGORY_COLUMN_MAPPING
WHERE TABLE_NAME = #{table_name}
AND MENU_OBJID = #{menu_objid}
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
ORDER BY LOGICAL_COLUMN_NAME
</select>
<select id="checkPhysicalColumnExists" parameterType="map" resultType="int">
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = #{table_name}
AND column_name = #{physical_column_name}
</select>
<!-- UPSERT: ON CONFLICT (table_name, logical_column_name, menu_objid, company_code) -->
<insert id="upsertColumnMapping" parameterType="map">
INSERT INTO CATEGORY_COLUMN_MAPPING (
TABLE_NAME, LOGICAL_COLUMN_NAME, PHYSICAL_COLUMN_NAME,
MENU_OBJID, COMPANY_CODE, DESCRIPTION, CREATED_BY, UPDATED_BY
) VALUES (
#{table_name}, #{logical_column_name}, #{physical_column_name},
#{menu_objid}, #{company_code}, #{description}, #{user_id}, #{user_id}
)
ON CONFLICT (table_name, logical_column_name, menu_objid, company_code)
DO UPDATE SET
PHYSICAL_COLUMN_NAME = EXCLUDED.PHYSICAL_COLUMN_NAME,
DESCRIPTION = EXCLUDED.DESCRIPTION,
UPDATED_DATE = NOW(),
UPDATED_BY = EXCLUDED.UPDATED_BY
</insert>
<select id="getColumnMappingInfo" parameterType="map" resultType="map">
SELECT *
FROM CATEGORY_COLUMN_MAPPING
WHERE TABLE_NAME = #{table_name}
AND LOGICAL_COLUMN_NAME = #{logical_column_name}
AND MENU_OBJID = #{menu_objid}
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</select>
<delete id="deleteColumnMappingById" parameterType="map">
DELETE FROM CATEGORY_COLUMN_MAPPING
WHERE MAPPING_ID = #{mapping_id}
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</delete>
<delete id="deleteColumnMappingsByColumn" parameterType="map">
DELETE FROM CATEGORY_COLUMN_MAPPING
WHERE TABLE_NAME = #{table_name}
AND LOGICAL_COLUMN_NAME = #{column_name}
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</delete>
<!-- ══════════════════════════════════════════════════════════════
Labels by Codes
══════════════════════════════════════════════════════════════ -->
<select id="getLabelListByCodes" parameterType="map" resultType="map">
SELECT DISTINCT
VALUE_CODE
, VALUE_LABEL
FROM CATEGORY_VALUES
WHERE VALUE_CODE IN
<foreach collection="valueCodes" item="code" open="(" separator="," close=")">
#{code}
</foreach>
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</select>
<!-- ══════════════════════════════════════════════════════════════
Second-Level Menus
══════════════════════════════════════════════════════════════ -->
<select id="checkMenuInfoHasCompanyCode" resultType="int">
SELECT COUNT(*) FROM information_schema.columns
WHERE table_name = 'menu_info'
AND column_name = 'company_code'
</select>
<select id="getSecondLevelMenuList" parameterType="map" resultType="map">
SELECT
M1.OBJID AS menu_objid
, M1.MENU_NAME_KOR AS menu_name
, M0.MENU_NAME_KOR AS parent_menu_name
, M1.SCREEN_CODE AS screen_code
FROM MENU_INFO M1
INNER JOIN MENU_INFO M0 ON M1.PARENT_OBJ_ID = M0.OBJID
WHERE M1.MENU_TYPE = '1'
AND M1.STATUS = 'active'
AND M0.PARENT_OBJ_ID = '0'
<if test='has_company_code and company_code != null and company_code != "*"'>
AND (M1.COMPANY_CODE = #{company_code} OR M1.COMPANY_CODE = '*')
</if>
ORDER BY M0.SEQ, M1.SEQ
</select>
</mapper>
@@ -57,7 +57,7 @@
, C.CHARACTER_MAXIMUM_LENGTH AS MAX_LENGTH
, C.NUMERIC_PRECISION
, C.NUMERIC_SCALE
, CL.CODE_CATEGORY
, CL.CODE_INFO
, CL.CODE_VALUE
, CL.REFERENCE_TABLE
, CL.REFERENCE_COLUMN
@@ -110,7 +110,7 @@
, C.CHARACTER_MAXIMUM_LENGTH AS MAX_LENGTH
, C.NUMERIC_PRECISION
, C.NUMERIC_SCALE
, COALESCE(TTC.CODE_CATEGORY, CL.CODE_CATEGORY) AS CODE_CATEGORY
, COALESCE(TTC.CODE_INFO, CL.CODE_INFO) AS CODE_INFO
, COALESCE(TTC.CODE_VALUE, CL.CODE_VALUE) AS CODE_VALUE
, COALESCE(TTC.REFERENCE_TABLE, CL.REFERENCE_TABLE) AS REFERENCE_TABLE
, COALESCE(TTC.REFERENCE_COLUMN, CL.REFERENCE_COLUMN) AS REFERENCE_COLUMN
@@ -253,7 +253,7 @@
, DESCRIPTION
, DISPLAY_ORDER
, IS_VISIBLE
, CODE_CATEGORY
, CODE_INFO
, CODE_VALUE
, REFERENCE_TABLE
, REFERENCE_COLUMN
@@ -275,7 +275,7 @@
, COLUMN_LABEL
, INPUT_TYPE
, DETAIL_SETTINGS
, CODE_CATEGORY
, CODE_INFO
, CODE_VALUE
, REFERENCE_TABLE
, REFERENCE_COLUMN
@@ -293,14 +293,14 @@
, #{column_label}
, #{input_type}
, #{detail_settings}::JSONB
, #{code_category}
, #{code_info}
, #{code_value}
, #{reference_table}
, #{reference_column}
, #{display_column}
, #{display_order}
, #{is_visible}
, 'Y'
, COALESCE(#{is_nullable}, 'Y')
, #{company_code}
, #{category_ref}
, NOW()
@@ -311,13 +311,14 @@
COLUMN_LABEL = COALESCE(EXCLUDED.COLUMN_LABEL, TABLE_TYPE_COLUMNS.COLUMN_LABEL)
, INPUT_TYPE = COALESCE(EXCLUDED.INPUT_TYPE, TABLE_TYPE_COLUMNS.INPUT_TYPE)
, DETAIL_SETTINGS = COALESCE(EXCLUDED.DETAIL_SETTINGS, TABLE_TYPE_COLUMNS.DETAIL_SETTINGS)
, CODE_CATEGORY = EXCLUDED.CODE_CATEGORY
, CODE_INFO = EXCLUDED.CODE_INFO
, CODE_VALUE = EXCLUDED.CODE_VALUE
, REFERENCE_TABLE = EXCLUDED.REFERENCE_TABLE
, REFERENCE_COLUMN = EXCLUDED.REFERENCE_COLUMN
, DISPLAY_COLUMN = EXCLUDED.DISPLAY_COLUMN
, DISPLAY_ORDER = COALESCE(EXCLUDED.DISPLAY_ORDER, TABLE_TYPE_COLUMNS.DISPLAY_ORDER)
, IS_VISIBLE = COALESCE(EXCLUDED.IS_VISIBLE, TABLE_TYPE_COLUMNS.IS_VISIBLE)
, IS_NULLABLE = COALESCE(EXCLUDED.IS_NULLABLE, TABLE_TYPE_COLUMNS.IS_NULLABLE)
, CATEGORY_REF = EXCLUDED.CATEGORY_REF
, UPDATED_DATE = NOW()
</insert>
@@ -354,7 +355,7 @@
, REFERENCE_TABLE = CASE WHEN #{clear_entity} = 'true' THEN NULL ELSE TABLE_TYPE_COLUMNS.REFERENCE_TABLE END
, REFERENCE_COLUMN= CASE WHEN #{clear_entity} = 'true' THEN NULL ELSE TABLE_TYPE_COLUMNS.REFERENCE_COLUMN END
, DISPLAY_COLUMN = CASE WHEN #{clear_entity} = 'true' THEN NULL ELSE TABLE_TYPE_COLUMNS.DISPLAY_COLUMN END
, CODE_CATEGORY = CASE WHEN #{clear_code} = 'true' THEN NULL ELSE TABLE_TYPE_COLUMNS.CODE_CATEGORY END
, CODE_INFO = CASE WHEN #{clear_code} = 'true' THEN NULL ELSE TABLE_TYPE_COLUMNS.CODE_INFO END
, CODE_VALUE = CASE WHEN #{clear_code} = 'true' THEN NULL ELSE TABLE_TYPE_COLUMNS.CODE_VALUE END
, CATEGORY_REF = CASE WHEN #{clear_category} = 'true' THEN NULL ELSE TABLE_TYPE_COLUMNS.CATEGORY_REF END
, UPDATED_DATE = NOW()
@@ -389,7 +390,7 @@
<select id="getTablePrimaryKeyList" parameterType="map" resultType="map">
SELECT
TC.CONNAME AS constraint_name
, ARRAY_AGG(A.ATTNAME ORDER BY X.N) AS columns
, ARRAY_AGG(A.ATTNAME ORDER BY X.N)::text AS columns
FROM PG_CONSTRAINT TC
JOIN PG_CLASS C
ON TC.CONRELID = C.OID
@@ -411,7 +412,7 @@
SELECT
I.RELNAME AS index_name
, IX.INDISUNIQUE AS is_unique
, ARRAY_AGG(A.ATTNAME ORDER BY X.N) AS columns
, ARRAY_AGG(A.ATTNAME ORDER BY X.N)::text AS columns
FROM PG_INDEX IX
JOIN PG_CLASS T
ON IX.INDRELID = T.OID
@@ -667,15 +668,15 @@
SET
PROPERTIES = JSONB_SET(
JSONB_SET(
SL.PROPERTIES,
SL.PROPERTIES::JSONB,
'{widgetType}', TO_JSONB(#{component_id}::TEXT)
),
'{componentType}', TO_JSONB(#{component_id}::TEXT)
)
)::TEXT
FROM SCREEN_DEFINITIONS SD
WHERE SL.SCREEN_ID = SD.SCREEN_ID
AND SL.PROPERTIES->>'tableName' = #{table_name}
AND SL.PROPERTIES->>'columnName' = #{column_name}
AND SL.PROPERTIES::JSONB->>'tableName' = #{table_name}
AND SL.PROPERTIES::JSONB->>'columnName' = #{column_name}
AND ((SD.COMPANY_CODE = #{company_code} OR SD.COMPANY_CODE = '*') OR #{company_code} = '*')
</update>
@@ -0,0 +1,224 @@
package com.erp.batch;
import org.junit.jupiter.api.Test;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
/**
* Phase 3 검증 vexplor_rps L550~617 알고리즘 1:1 이식 결과가 정상 동작하는지.
*
* 외부 의존 없는 순수 함수만 검증.
*/
class MappingTransformerTest {
// evaluateConditional
@Test
void evaluateConditional_단순_매칭() {
MappingTransformer.ConditionalConfig cfg = new MappingTransformer.ConditionalConfig();
cfg.rules.add(new MappingTransformer.ConditionalRule("1", "Y"));
cfg.rules.add(new MappingTransformer.ConditionalRule("0", "N"));
cfg.defaultValue = "?";
assertEquals("Y", MappingTransformer.evaluateConditional("1", cfg));
assertEquals("N", MappingTransformer.evaluateConditional("0", cfg));
assertEquals("?", MappingTransformer.evaluateConditional("9", cfg)); // 매칭 없음 default
}
@Test
void evaluateConditional_null_cfg_안전() {
assertNull(MappingTransformer.evaluateConditional("anything", null));
}
@Test
void evaluateConditional_빈_rules_default만() {
MappingTransformer.ConditionalConfig cfg = new MappingTransformer.ConditionalConfig();
cfg.defaultValue = "fallback";
assertEquals("fallback", MappingTransformer.evaluateConditional("anything", cfg));
}
// parseConditionalConfig (JSONB normalize)
@Test
void parseConditionalConfig_Map_입력() {
Map<String, Object> raw = new LinkedHashMap<>();
raw.put("rules", List.of(Map.of("when", "1", "then", "Y")));
raw.put("default", "?");
MappingTransformer.ConditionalConfig cfg = MappingTransformer.parseConditionalConfig(raw);
assertEquals(1, cfg.rules.size());
assertEquals("1", cfg.rules.get(0).when);
assertEquals("Y", cfg.rules.get(0).then);
assertEquals("?", cfg.defaultValue);
}
@Test
void parseConditionalConfig_String_JSON_입력() {
String json = "{\"rules\":[{\"when\":\"J01\",\"then\":\"active\"}],\"default\":\"\"}";
MappingTransformer.ConditionalConfig cfg = MappingTransformer.parseConditionalConfig(json);
assertEquals(1, cfg.rules.size());
assertEquals("J01", cfg.rules.get(0).when);
assertEquals("active", cfg.rules.get(0).then);
assertEquals("", cfg.defaultValue);
}
@Test
void parseConditionalConfig_null_빈cfg() {
MappingTransformer.ConditionalConfig cfg = MappingTransformer.parseConditionalConfig(null);
assertNotNull(cfg);
assertTrue(cfg.rules.isEmpty());
}
@Test
void parseConditionalConfig_손상된_JSON_빈cfg() {
MappingTransformer.ConditionalConfig cfg = MappingTransformer.parseConditionalConfig("{not json");
assertNotNull(cfg);
assertTrue(cfg.rules.isEmpty());
}
// getValueByPath ( 표기법)
@Test
void getValueByPath_단순_키() {
Map<String, Object> obj = Map.of("name", "alice");
assertEquals("alice", MappingTransformer.getValueByPath(obj, "name"));
}
@Test
void getValueByPath_중첩_경로() {
Map<String, Object> obj = Map.of("response", Map.of("access_token", "xyz"));
assertEquals("xyz", MappingTransformer.getValueByPath(obj, "response.access_token"));
}
@Test
void getValueByPath_없는_경로_null() {
Map<String, Object> obj = Map.of("name", "alice");
assertNull(MappingTransformer.getValueByPath(obj, "missing.path"));
assertNull(MappingTransformer.getValueByPath(obj, "name.deeper"));
}
@Test
void getValueByPath_null_obj_안전() {
assertNull(MappingTransformer.getValueByPath(null, "anything"));
}
// partitionFixed
@Test
void partitionFixed_분리() {
List<Map<String, Object>> mappings = List.of(
Map.of("mapping_type", "direct", "to_column_name", "a"),
Map.of("mapping_type", "fixed", "to_column_name", "b"),
Map.of("mapping_type", "conditional", "to_column_name", "c")
);
MappingTransformer.Partition p = MappingTransformer.partitionFixed(mappings);
assertEquals(2, p.nonFixed.size());
assertEquals(1, p.fixed.size());
assertEquals("b", p.fixed.get(0).get("to_column_name"));
}
// transformRow (통합)
@Test
void transformRow_direct_매핑() {
Map<String, Object> row = Map.of("user_id", "alice", "email", "a@x.com");
List<Map<String, Object>> nonFixed = List.of(
Map.of("mapping_type", "direct",
"from_column_name", "user_id",
"to_column_name", "USER_ID"),
Map.of("mapping_type", "direct",
"from_column_name", "email",
"to_column_name", "EMAIL_ADDR")
);
Map<String, Object> mapped = MappingTransformer.transformRow(
row, nonFixed, List.of(), "internal", "COMPANY_1");
assertEquals("alice", mapped.get("USER_ID"));
assertEquals("a@x.com", mapped.get("EMAIL_ADDR"));
assertEquals("COMPANY_1", mapped.get("company_code")); // 자동 주입
}
@Test
void transformRow_conditional_매핑_1을_Y로() {
Map<String, Object> row = Map.of("active_flag", "1");
List<Map<String, Object>> nonFixed = List.of(
new HashMap<>(Map.of(
"mapping_type", "conditional",
"from_column_name", "active_flag",
"to_column_name", "IS_ACTIVE",
"mapping_config", Map.of(
"rules", List.of(
Map.of("when", "1", "then", "Y"),
Map.of("when", "0", "then", "N")),
"default", "?")))
);
Map<String, Object> mapped = MappingTransformer.transformRow(
row, nonFixed, List.of(), "internal", null);
assertEquals("Y", mapped.get("IS_ACTIVE"));
}
@Test
void transformRow_conditional_매핑_default_폴백() {
Map<String, Object> row = Map.of("active_flag", "9"); // 어떤 룰에도 매칭
List<Map<String, Object>> nonFixed = List.of(
new HashMap<>(Map.of(
"mapping_type", "conditional",
"from_column_name", "active_flag",
"to_column_name", "IS_ACTIVE",
"mapping_config", Map.of(
"rules", List.of(Map.of("when", "1", "then", "Y")),
"default", "?")))
);
Map<String, Object> mapped = MappingTransformer.transformRow(
row, nonFixed, List.of(), "internal", null);
assertEquals("?", mapped.get("IS_ACTIVE"));
}
@Test
void transformRow_fixed_매핑_적용() {
Map<String, Object> row = Map.of("user_id", "alice");
List<Map<String, Object>> nonFixed = List.of(
Map.of("mapping_type", "direct",
"from_column_name", "user_id",
"to_column_name", "USER_ID")
);
List<Map<String, Object>> fixed = List.of(
Map.of("mapping_type", "fixed",
"from_column_name", "BATCH_001",
"to_column_name", "SOURCE_BATCH")
);
Map<String, Object> mapped = MappingTransformer.transformRow(
row, nonFixed, fixed, "internal", null);
assertEquals("alice", mapped.get("USER_ID"));
assertEquals("BATCH_001", mapped.get("SOURCE_BATCH"));
}
@Test
void transformRow_점_표기법_API_응답() {
Map<String, Object> row = Map.of(
"user", Map.of("profile", Map.of("name", "박창현"))
);
List<Map<String, Object>> nonFixed = List.of(
Map.of("mapping_type", "direct",
"from_column_name", "user.profile.name",
"to_column_name", "USER_NAME")
);
Map<String, Object> mapped = MappingTransformer.transformRow(
row, nonFixed, List.of(), "internal", null);
assertEquals("박창현", mapped.get("USER_NAME"));
}
@Test
void transformRow_to_가_restapi_면_company_code_자동주입_안함() {
Map<String, Object> row = Map.of("user_id", "alice");
List<Map<String, Object>> nonFixed = List.of(
Map.of("mapping_type", "direct",
"from_column_name", "user_id",
"to_column_name", "USER_ID")
);
Map<String, Object> mapped = MappingTransformer.transformRow(
row, nonFixed, List.of(), "restapi", "COMPANY_1");
assertFalse(mapped.containsKey("company_code"));
}
}
+125
View File
@@ -0,0 +1,125 @@
# 087 마이그레이션 — BATCH_MAPPINGS.MAPPING_CONFIG JSONB 추가
작성일: 2026-05-13
작성자: hjjeong
관련: `notes/hjjeong/2026-05-12-batch-pipeline-current-state.md` (Phase 1)
## 목적
vexplor_rps 의 conditional 매핑(파이프라인) 기능을 INVYONE 으로 이식하기 위한 첫 단계.
`BATCH_MAPPINGS` 행마다 매핑 규칙(when/then/default) 을 JSONB 로 저장할 컬럼 추가.
- `mapping_type='direct'` / `'fixed'``MAPPING_CONFIG` 는 NULL
- `mapping_type='conditional'``MAPPING_CONFIG``{"rules":[{"when":"1","then":"Y"}],"default":"?"}` 형태 저장
Phase 2 (frontend ConditionalEditor + API 확장) 와 Phase 3 (Backend MappingTransformer) 가
이 컬럼을 읽고 쓰는 전제로 동작한다.
## 스키마
### BATCH_MAPPINGS ALTER
| 컬럼 | 타입 | 제약 | 설명 |
|---|---|---|---|
| `MAPPING_CONFIG` | JSONB | NULL 허용 | conditional 평가 규칙. direct/fixed 면 NULL |
저장 포맷(`mapping_type='conditional'`):
```json
{
"rules": [
{ "when": "1", "then": "Y" },
{ "when": "0", "then": "N" }
],
"default": "?"
}
```
## SQL
```sql
-- =================================================================
-- 087: BATCH_MAPPINGS.MAPPING_CONFIG JSONB 추가 (idempotent)
-- =================================================================
ALTER TABLE BATCH_MAPPINGS
ADD COLUMN IF NOT EXISTS MAPPING_CONFIG JSONB;
```
부팅 시 `StartupSchemaMigrator` 가 메타 DB + 모든 활성 테넌트 DB 에 동일 ALTER 를
`IF NOT EXISTS` 로 적용하므로 일반적으로는 별도 수동 실행이 필요 없음.
별도 환경(콜드 백업 복원 등)에서 수동 실행이 필요할 때 위 SQL 한 줄을 그대로 사용.
## 사전 점검
```sql
-- A. 컬럼 사전 상태
SELECT column_name, data_type FROM information_schema.columns
WHERE table_name = 'batch_mappings' AND column_name = 'mapping_config';
-- 빈 결과여야 정상. 이미 있으면 ALTER 의 IF NOT EXISTS 가 안전.
-- B. 기존 데이터 행수 (마이그레이션 영향 범위 확인)
SELECT COUNT(*) FROM BATCH_MAPPINGS;
-- 컬럼만 추가하므로 기존 행은 MAPPING_CONFIG = NULL 로 유지됨.
```
## 사후 검증
```sql
-- C. 컬럼 추가 확인
SELECT column_name, data_type FROM information_schema.columns
WHERE table_name = 'batch_mappings' AND column_name = 'mapping_config';
-- 기대: data_type = 'jsonb'
-- D. JSONB 동작 확인 (테스트)
BEGIN;
UPDATE BATCH_MAPPINGS
SET MAPPING_CONFIG = '{"rules":[{"when":"1","then":"Y"}],"default":"?"}'::jsonb
WHERE ID = (SELECT ID FROM BATCH_MAPPINGS LIMIT 1);
SELECT MAPPING_CONFIG->'rules'->0->>'when' AS sample
FROM BATCH_MAPPINGS
WHERE MAPPING_CONFIG IS NOT NULL
LIMIT 1;
-- 기대: sample = '1'
ROLLBACK;
```
## 실행
```bash
# 1) 메타 DB
psql -h <host> -U postgres -d invyone -f RUN_087.sql
# 2) 각 테넌트 DB (StartupSchemaMigrator 가 부팅 시 자동 적용하므로 통상 생략 가능)
for db in $(psql -tA -d invyone -c "SELECT db_name FROM company_mng WHERE db_status='active'"); do
echo "=== $db ==="
psql -h <host> -U postgres -d "$db" -f RUN_087.sql
done
```
`RUN_087.sql` 은 위 "SQL" 섹션의 ALTER 한 줄을 그대로 담은 파일입니다.
## 롤백
```sql
-- MAPPING_CONFIG 컬럼 제거 (저장된 conditional 규칙은 함께 삭제됨)
ALTER TABLE BATCH_MAPPINGS DROP COLUMN IF EXISTS MAPPING_CONFIG;
```
## 적용 환경 체크리스트
- [ ] 로컬 docker `naengangi-pg` (메타 + 활성 테넌트 전부)
- [ ] wace 개발서버 PostgreSQL
- [ ] 운영 메타 DB (`invyone`)
- [ ] 운영 각 테넌트 DB (loop or 부팅 시 자동)
## 관련 코드
- Flyway: `backend-spring/src/main/resources/db/migration/V021__add_batch_mappings_mapping_config.sql`
- StartupSchemaMigrator: `backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java` (마지막 항목)
- Mapper: `backend-spring/src/main/resources/mapper/batch.xml`
- `getBatchMappingsByConfigId` 의 SELECT 절: `MAPPING_CONFIG::TEXT AS MAPPING_CONFIG`
- `insertBatchMapping` 의 VALUES 절: `#{mapping_config,jdbcType=OTHER}::jsonb`
- Service: `backend-spring/src/main/java/com/erp/service/BatchService.java`
- `syncMappings()``stringifyJsonField(row, "mapping_config")` 로 직렬화 후 INSERT
- `attachMappings()``parseJsonField(row, "mapping_config")` 로 SELECT 결과 역직렬화
+133
View File
@@ -0,0 +1,133 @@
# 088 마이그레이션 — DEPT_MANAGERS 테이블 추가 (다중 관리자 + 조직장)
작성일: 2026-05-14
작성자: johngreen
관련: RPS 더존 ERP UJA1040 레퍼런스 대비 누락 기능 (A 단계 — 다중 관리자 + 조직장)
## 목적
부서별로 결재 관리자 / 부서 관리자 / 조직장을 각각 **다중 등록 (최대 10명)** 할 수 있도록 매핑 테이블 신설.
- 기존 `DEPT_INFO.APPROVAL_MANAGER` / `DEPT_INFO.DEPT_MANAGER` 컬럼은 단일 `user_id` 만 저장 가능
- 신규 `DEPT_MANAGERS` 매핑 테이블이 SoT(source of truth). `ROLE` 컬럼으로 3 종류 구분
- `approval` = 결재 관리자 (자동 결재라인 등록 시 호출)
- `dept` = 부서 관리자 (행정 책임자)
- `org_leader` = 조직장 (본인 부서 + 하위 부서의 경비/근태 조회·승인 권한)
- 기존 단일 컬럼은 **호환 위해 일단 유지**. 향후 cleanup PR 에서 제거 예정
## 스키마
### DEPT_MANAGERS (신규)
| 컬럼 | 타입 | 제약 | 설명 |
|---|---|---|---|
| `DEPT_CODE` | VARCHAR(1024) | NOT NULL, FK → DEPT_INFO ON DELETE CASCADE | 부서 코드 |
| `USER_ID` | VARCHAR(50) | NOT NULL | 사용자 ID |
| `ROLE` | VARCHAR(20) | NOT NULL, CHECK | `approval` \| `dept` \| `org_leader` |
| `SORT_ORDER` | INTEGER | NOT NULL DEFAULT 1 | 표시 순서 |
| `CREATED_AT` | TIMESTAMP | NOT NULL DEFAULT NOW() | 등록 시각 |
PK: `(DEPT_CODE, USER_ID, ROLE)` — 같은 사용자가 같은 부서에 같은 role 로 중복 등록 차단.
인덱스: `(DEPT_CODE, ROLE, SORT_ORDER)` — 부서별 role 조회 + 정렬 가속.
## SQL
```sql
-- =================================================================
-- 088: DEPT_MANAGERS 테이블 (idempotent)
-- =================================================================
CREATE TABLE IF NOT EXISTS DEPT_MANAGERS (
DEPT_CODE VARCHAR(1024) NOT NULL,
USER_ID VARCHAR(50) NOT NULL,
ROLE VARCHAR(20) NOT NULL,
SORT_ORDER INTEGER NOT NULL DEFAULT 1,
CREATED_AT TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (DEPT_CODE, USER_ID, ROLE),
CONSTRAINT chk_dept_managers_role
CHECK (ROLE IN ('approval', 'dept', 'org_leader')),
CONSTRAINT fk_dept_managers_dept
FOREIGN KEY (DEPT_CODE) REFERENCES DEPT_INFO(DEPT_CODE) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_dept_managers_role
ON DEPT_MANAGERS (DEPT_CODE, ROLE, SORT_ORDER);
```
부팅 시 `StartupSchemaMigrator` 가 메타 DB + 모든 활성 테넌트 DB 에 동일 DDL 을 `IF NOT EXISTS` 로 적용하므로 일반적으로는 별도 수동 실행이 필요 없음.
## 사전 점검
```sql
-- A. 테이블 사전 상태
SELECT table_name FROM information_schema.tables WHERE table_name = 'dept_managers';
-- 빈 결과여야 정상. 이미 있으면 CREATE 의 IF NOT EXISTS 가 안전.
-- B. DEPT_INFO 행수 (FK 영향 범위)
SELECT COUNT(*) FROM DEPT_INFO;
```
## 사후 검증
```sql
-- C. 테이블 추가 확인
SELECT column_name, data_type, character_maximum_length
FROM information_schema.columns
WHERE table_name = 'dept_managers'
ORDER BY ordinal_position;
-- 기대: 5 행 (DEPT_CODE/USER_ID/ROLE/SORT_ORDER/CREATED_AT)
-- D. CHECK 제약 확인
SELECT constraint_name, check_clause FROM information_schema.check_constraints
WHERE constraint_name = 'chk_dept_managers_role';
-- 기대: ROLE IN ('approval', 'dept', 'org_leader')
-- E. FK 동작 확인 (테스트)
BEGIN;
INSERT INTO DEPT_MANAGERS (DEPT_CODE, USER_ID, ROLE)
VALUES ('NON_EXISTENT_DEPT', 'tester', 'approval');
-- 기대: FK 위반 에러 (foreign key constraint "fk_dept_managers_dept")
ROLLBACK;
```
## 실행
```bash
# 1) 메타 DB
psql -h <host> -U postgres -d invyone -f RUN_088.sql
# 2) 각 테넌트 DB (StartupSchemaMigrator 가 부팅 시 자동 적용하므로 통상 생략 가능)
for db in $(psql -tA -d invyone -c "SELECT db_name FROM company_mng WHERE db_status='active'"); do
echo "=== $db ==="
psql -h <host> -U postgres -d "$db" -f RUN_088.sql
done
```
## 롤백
```sql
-- DEPT_MANAGERS 테이블 제거 (저장된 다중 관리자 매핑 함께 삭제됨)
DROP INDEX IF EXISTS idx_dept_managers_role;
DROP TABLE IF EXISTS DEPT_MANAGERS;
```
롤백 후엔 백엔드/프론트가 단일 `APPROVAL_MANAGER` / `DEPT_MANAGER` 컬럼만 사용하는 이전 동작으로 자연스럽게 복귀 (호환 컬럼 유지하기 때문).
## 적용 환경 체크리스트
- [ ] 로컬 docker `naengangi-pg` (관련 없음 — invyone DB 는 wace/운영에만 존재)
- [ ] wace 개발서버 PostgreSQL
- [ ] 운영 메타 DB (`invyone`)
- [ ] 운영 각 테넌트 DB (loop or 부팅 시 자동)
## 관련 코드
- Flyway: `backend-spring/src/main/resources/db/migration/V022__create_dept_managers.sql`
- StartupSchemaMigrator: `backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java` (마지막 항목으로 추가)
- Mapper: `backend-spring/src/main/resources/mapper/department.xml`
- `selectDepartments` / `selectDepartmentByCode` 의 SELECT 절에 `APPROVAL_MANAGERS`/`DEPT_MANAGERS`/`ORG_LEADERS` json_agg 컬럼 추가
- 신규 query: `insertDeptManagers`, `deleteDeptManagersByDept`
- Service: `DepartmentService.java`
- `createDepartment` / `updateDepartment` 가 body 의 `approval_managers[]`/`dept_managers[]`/`org_leaders[]` 배열을 `DEPT_MANAGERS` 에 sync (트랜잭션, 최대 10명 검증)
- Frontend: `frontend/app/(main)/admin/userMng/deptMngList/page.tsx`
- BasicInfoForm 에 다중 chip UI + ManagerPicker 모달
+143
View File
@@ -0,0 +1,143 @@
# 089 마이그레이션 — IS_SOLUTION_ONLY 메뉴 플래그 + TABLE_TYPE_COLUMNS.CODE_CATEGORY rename
작성일: 2026-05-15
작성자: johngreen
관련:
- (V023) 멀티테넌시 메뉴 격리 — 5/15 fix (commit c530a67c)
- (V024) common-code 마스터-디테일 재설계 — 5/15 refactor (commit 2348800e)
## 목적
V023 과 V024 두 건의 누락된 운영 문서를 합본 처리.
앱 부팅 시 `StartupSchemaMigrator` 가 idempotent 로 메타 DB + 활성 테넌트 DB 전부에 자동 적용한다.
### V023 — MENU_INFO.IS_SOLUTION_ONLY 컬럼 (회상)
테넌트 사이트(`*.invyone.com`)에서 솔루션 전용 관리자 메뉴(회사관리/회사 프로비저닝/감사로그)를 숨기기 위한 플래그.
- 메뉴 mapper SQL(`selectAdminMenuList`, `selectUserMenuList`)이 `is_management_host` 파라미터를 보고 `IS_SOLUTION_ONLY=TRUE` 행을 제외.
- 이미 부팅 마이그레이션으로는 적용 중이지만 RUN_*.md 운영 문서가 빠져있어 이번 089 에 합본.
### V024 — TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO (★ 신규, 본 PR 의 핵심)
5/15 의 commonCode 마스터-디테일 재설계(commit `2348800e`)가 mapper SQL 6 군데에서
`CL.CODE_CATEGORY``CL.CODE_INFO` 로 컬럼 참조명을 바꿨지만, **DB 컬럼 rename SQL 을 빠뜨린 채 머지**됨.
그 결과 모든 테넌트 DB 의 `테이블 타입관리 > 테이블 클릭 > 컬럼 목록` API
(`GET /api/table-management/tables/{name}/columns`) 가 **500** 반환:
```
ERROR: column cl.code_info does not exist
```
본 089 마이그레이션이 `CODE_CATEGORY``CODE_INFO` 로 컬럼명을 안전하게 변경한다.
## 스키마
### MENU_INFO (V023)
| 컬럼 | 타입 | 제약 | 설명 |
|---|---|---|---|
| `IS_SOLUTION_ONLY` | BOOLEAN | NOT NULL DEFAULT FALSE | TRUE 인 메뉴는 솔루션 관리 호스트에서만 노출 |
### TABLE_TYPE_COLUMNS (V024)
| 변경 | 설명 |
|---|---|
| `CODE_CATEGORY``CODE_INFO` | 컬럼 RENAME (값/타입/제약 그대로) |
## SQL
```sql
-- =================================================================
-- 089-V023: MENU_INFO.IS_SOLUTION_ONLY (idempotent)
-- =================================================================
ALTER TABLE MENU_INFO
ADD COLUMN IF NOT EXISTS IS_SOLUTION_ONLY BOOLEAN DEFAULT FALSE NOT NULL;
UPDATE MENU_INFO
SET IS_SOLUTION_ONLY = TRUE
WHERE IS_SOLUTION_ONLY = FALSE
AND MENU_URL IN (
'/admin/sysMng/subdomainList',
'/admin/userMng/companyList',
'/admin/audit-log'
);
-- =================================================================
-- 089-V024: TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO (idempotent)
-- =================================================================
-- PostgreSQL 은 RENAME COLUMN 에 IF EXISTS 가 없으므로 DO 블록으로
-- 멱등성 보장 (이미 CODE_INFO 면 no-op, CODE_CATEGORY 만 존재할 때만 rename).
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'table_type_columns'
AND column_name = 'code_category'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'table_type_columns'
AND column_name = 'code_info'
) THEN
ALTER TABLE TABLE_TYPE_COLUMNS
RENAME COLUMN CODE_CATEGORY TO CODE_INFO;
END IF;
END $$;
```
## 멱등성
- V023: `ADD COLUMN IF NOT EXISTS` + UPDATE `WHERE IS_SOLUTION_ONLY = FALSE` 로 중복 실행 안전.
- V024: DO 블록 안에서 information_schema 로 현재 상태 확인 후 분기.
- 신규 테넌트 DB (이미 CODE_INFO 면): no-op
- 기존 테넌트 DB (CODE_CATEGORY 만 있으면): rename 수행
- 둘 다 있거나 둘 다 없으면: no-op (방어적)
## 적용 방법
부팅 시 자동 적용 — 별도 작업 불필요.
`backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java` 의 MIGRATIONS 리스트에
위 SQL 이 등록되어 있어서 앱이 시작할 때 모든 활성 테넌트 DB 에 idempotent 로 실행된다.
수동 적용이 필요한 경우 (예: 새 환경 부트스트랩 전):
```bash
psql -h <host> -U <user> -d <tenant_db> -f - <<'SQL'
-- 위 SQL 본문 붙여넣기
SQL
```
## 검증
```sql
-- V023
SELECT COLUMN_NAME FROM information_schema.columns
WHERE TABLE_NAME = 'menu_info' AND COLUMN_NAME = 'is_solution_only';
-- → 1 row
SELECT MENU_URL, IS_SOLUTION_ONLY FROM MENU_INFO
WHERE MENU_URL IN ('/admin/sysMng/subdomainList', '/admin/userMng/companyList', '/admin/audit-log');
-- → 모두 IS_SOLUTION_ONLY = TRUE
-- V024
SELECT COLUMN_NAME FROM information_schema.columns
WHERE TABLE_NAME = 'table_type_columns' AND COLUMN_NAME IN ('code_category', 'code_info');
-- → 1 row: code_info (code_category 는 존재하면 안 됨)
```
## 영향 범위
- 테이블 타입관리 페이지 컬럼 조회 500 에러 해소.
- common-code 재설계 후속 (mapper/Service/Frontend 는 이미 5/15 에 머지됨).
- 부팅 시점 1회 실행 — 런타임 트래픽에는 영향 없음.
## 롤백
V024 rename 을 되돌리려면 mapper SQL 도 같이 되돌려야 하므로 일반적으로 권장하지 않음.
만약 필요하면:
```sql
ALTER TABLE TABLE_TYPE_COLUMNS RENAME COLUMN CODE_INFO TO CODE_CATEGORY;
```
+ `mapper/tableManagement.xml`, `commonCode.xml`, FE `commonCode.ts` 등 5/15 변경분 revert.
+109
View File
@@ -0,0 +1,109 @@
# 090 마이그레이션 — TABLE_TYPE_COLUMNS 중복 정리 + ON CONFLICT 용 UNIQUE INDEX
작성일: 2026-05-15
작성자: johngreen
관련 버그: 테이블 타입관리에서 모든 쓰기 API (UNIQUE 토글 / NOT NULL 토글 / 컬럼 설정 저장) 가 500 반환.
## 증상
```
PSQLException: ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specification
mapper: tableManagement.upsertColumnSettings / upsertNullable / upsertUnique / upsertColumnInputType
```
## 원인
`TABLE_TYPE_COLUMNS` 의 PK 는 `id` 단일(varchar). 운영 DB 어디에도
`(TABLE_NAME, COLUMN_NAME, COMPANY_CODE)` UNIQUE 제약/인덱스가 없음.
mapper 의 `INSERT … ON CONFLICT (TABLE_NAME, COLUMN_NAME, COMPANY_CODE) DO UPDATE …`
구문이 매칭할 unique constraint 를 찾지 못해 즉시 BadSqlGrammar 로 500.
RUN_044 가 company_code 컬럼을 추가했지만 함께 도입했어야 할 unique index 가
빠진 채로 운영에 들어간 것으로 보이며, 그 후 mapper 가 ON CONFLICT 패턴으로 작성되면서
실제로는 한 번도 정상 동작하지 못한 채로 잠복했던 정황 (운영 메타 DB 의 35,316 행 중
중복 키 그룹 2개 = 추가 4 row 가 그 흔적).
## 조치
### (1) 중복 행 정리
`(TABLE_NAME, COLUMN_NAME, COMPANY_CODE)` 그룹에서
`updated_date DESC NULLS LAST, id::bigint DESC` 로 정렬해 첫 행만 유지, 나머지 DELETE.
```sql
DELETE FROM TABLE_TYPE_COLUMNS
WHERE id IN (
SELECT id FROM (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY TABLE_NAME, COLUMN_NAME, COMPANY_CODE
ORDER BY UPDATED_DATE DESC NULLS LAST,
id::bigint DESC
) AS rn
FROM TABLE_TYPE_COLUMNS
) r
WHERE r.rn > 1
);
```
실측(2026-05-15) 중복:
| DB | 중복 그룹 | 삭제될 row |
|---|---|---|
| meta `invyone` | 2 (`sales_order_mng.incoterms@COMPANY_16`, `sales_order_mng.payment_term@COMPANY_16`) | 2 |
| `siflex_invyone` | 0 | 0 |
| `test01_invyone` | 0 | 0 |
| `test02_invyone` | 0 | 0 |
남는 행은 가장 최근에 갱신된 동일 키 row (column_label/input_type 모두 동일 — 옛 NULL updated_date row 가 제거 대상).
### (2) UNIQUE INDEX 추가
```sql
CREATE UNIQUE INDEX IF NOT EXISTS UX_TABLE_TYPE_COLUMNS_TCC
ON TABLE_TYPE_COLUMNS (TABLE_NAME, COLUMN_NAME, COMPANY_CODE);
```
PostgreSQL 은 ON CONFLICT 가 인덱스도 인식하므로 mapper 의 모든 upsert SQL 이
즉시 정상 동작. `IF NOT EXISTS` 로 멱등.
## 적용 방법
부팅 시 자동 적용 — 별도 작업 불필요. `StartupSchemaMigrator.MIGRATIONS` 리스트에
V025 / RUN_090 (1) (2) 항목으로 등록되어 있어서 앱이 시작할 때 메타 DB + 모든 활성
테넌트 DB 에 차례로 실행된다.
## 검증
```sql
-- 중복 없음
SELECT COUNT(*) FROM (
SELECT 1 FROM TABLE_TYPE_COLUMNS
GROUP BY TABLE_NAME, COLUMN_NAME, COMPANY_CODE HAVING COUNT(*) > 1
) d;
-- → 0
-- 인덱스 존재
SELECT indexname FROM pg_indexes
WHERE tablename = 'table_type_columns' AND indexname = 'ux_table_type_columns_tcc';
-- → 1 row
```
브라우저 검증:
1. 솔루션 또는 테넌트 사이트 > 시스템 관리 > 테이블 타입관리 > 거래처 클릭
2. 어느 컬럼이든 `UQ` / `NN` 토글 클릭 → 200, 토스트 "UNIQUE/NOT NULL 제약이 설정되었습니다"
3. "컬럼 설정 저장" 버튼 클릭 → 200, 토스트 "모든 컬럼 설정을 성공적으로 저장했습니다"
## 영향 범위
- 테이블 타입관리 페이지 쓰기 API 4종 (`unique`, `nullable`, `columns/settings`, `columns/{c}/input-type`) 정상화.
- 멱등 — 재실행 시 DELETE 0건, CREATE INDEX 도 IF NOT EXISTS 라 skip.
- 부팅 시점 1회 실행, 런타임 트래픽에는 영향 없음.
## 롤백
```sql
DROP INDEX IF EXISTS UX_TABLE_TYPE_COLUMNS_TCC;
```
DELETE 된 중복 row 는 정보 손실 없음 (남은 row 와 column_label/input_type 동일) 이라
복구가 의미 없음. 그래도 굳이 되돌리려면 사전 백업 필요.
+81
View File
@@ -0,0 +1,81 @@
# 091 마이그레이션 — TABLE_TYPE_COLUMNS.INPUT_TYPE legacy → 표준 8종 정리
작성일: 2026-05-16
작성자: johngreen
관련: 5/15 common-code 재설계 (commit `2348800e`) 후속 데이터 마이그레이션.
## 배경
5/15 PR 이 `InputTypeConstants.USER_SELECTABLE_INPUT_TYPES` 화이트리스트를
표준 8종(`text/number/date/code/entity/numbering/file/image`) 으로 좁혔지만,
운영 DB 에 잔존하는 옛 input_type 값들을 정리하는 데이터 마이그레이션이 빠지고
프론트엔드도 옛 값을 그대로 echo 했기 때문에 컬럼 설정 저장 batch 가 400 으로 거부됐다.
긴급 회복은 `90787d83` 에서 화이트리스트에 legacy 7종을 다시 인정하는 방식으로
끝냈고, 본 091 마이그레이션은 그 뒤로 **데이터를 표준으로 통합**하는 후속 정리.
## 매핑
| Legacy | → | Standard | 사유 |
|---|---|---|---|
| `category` | → | `code` | commonCode 통합 의도와 일치 |
| `select` | → | `code` | 미리 정의된 코드 선택 = code 와 동등 |
| `radio` | → | `code` | enum 선택 |
| `checkbox` | → | `code` | enum/boolean → code 매핑 (표준에 boolean 없음) |
| `boolean` | → | `code` | 표준에 boolean 없음 — code 가 가장 근접 |
| `textarea` | → | `text` | single/multi line 구분 UI 손실 (가벼움) |
| `datetime` | → | `date` | 표준에 datetime 분리 없음 |
## 영향 범위 (실측 2026-05-16)
| DB | 갱신 row |
|---|---|
| meta `invyone` | 1,207 (category 886 + select 149 + textarea 102 + checkbox 55 + radio 12 + datetime 2 + boolean 1) |
| `siflex_invyone` | 0 (테이블 비어있음) |
| `test01_invyone` | 0 |
| `test02_invyone` | 0 |
## SQL
```sql
UPDATE TABLE_TYPE_COLUMNS
SET INPUT_TYPE = CASE INPUT_TYPE
WHEN 'category' THEN 'code'
WHEN 'select' THEN 'code'
WHEN 'radio' THEN 'code'
WHEN 'checkbox' THEN 'code'
WHEN 'boolean' THEN 'code'
WHEN 'textarea' THEN 'text'
WHEN 'datetime' THEN 'date'
END,
UPDATED_DATE = NOW()
WHERE INPUT_TYPE IN ('category','select','radio','checkbox','boolean','textarea','datetime');
```
## 멱등성
`WHERE INPUT_TYPE IN (...)` 으로 두 번째 실행 시 매칭 row 0 → no-op.
## 적용 방법
부팅 시 자동 적용. `StartupSchemaMigrator.MIGRATIONS` 리스트에 V026 / RUN_091 항목으로
등록되어 있어서 backend 시작 시 메타 DB + 활성 테넌트 DB 전부에 idempotent 로 실행된다.
## 검증
```sql
-- 화이트리스트 밖 row 0 이어야 함
SELECT input_type, COUNT(*) FROM table_type_columns
WHERE input_type NOT IN ('text','number','date','code','entity','numbering','file','image')
GROUP BY 1;
-- → 0 rows
```
## 후속 cleanup (별도 PR 거리)
본 마이그레이션이 모든 환경에 한 번 적용된 다음에는:
1. `InputTypeConstants.USER_SELECTABLE_INPUT_TYPES` 에서 legacy 7종 다시 제거.
2. 프론트엔드 input type 선택 UI 에서 legacy 옵션 제거 (이미 있을 수도).
3. mapper/Service 에서 legacy 값 참조 흔적 grep + 정리.
이번 PR 은 데이터 정리만. 화이트리스트 축소는 운영 안정 확인 후.
+15 -1
View File
@@ -1,6 +1,7 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -64,6 +65,7 @@ import {
import { getCompanyList } from "@/lib/api/company";
import { useAuth } from "@/hooks/useAuth";
import { Company } from "@/types/company";
import { isManagementHost } from "@/lib/tenant/subdomain";
const RESOURCE_TYPE_CONFIG: Record<
string,
@@ -78,7 +80,7 @@ const RESOURCE_TYPE_CONFIG: Record<
USER: { label: "사용자", icon: User, color: "bg-amber-100 text-orange-700" },
ROLE: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" },
COMPANY: { label: "회사", icon: Building2, color: "bg-indigo-100 text-indigo-700" },
CODE_CATEGORY: { label: "코드 카테고리", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
CODE_INFO: { label: "코드 카테고리", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
CODE: { label: "코드", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
DATA: { label: "데이터", icon: Database, color: "bg-muted text-foreground" },
TABLE: { label: "테이블", icon: Database, color: "bg-muted text-foreground" },
@@ -290,6 +292,16 @@ function groupByDate(entries: AuditLogEntry[]): Map<string, AuditLogEntry[]> {
}
export default function AuditLogPage() {
const router = useRouter();
const [hostBlocked, setHostBlocked] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
if (!isManagementHost(window.location.hostname)) {
setHostBlocked(true);
router.replace("/main");
}
}, [router]);
const { user } = useAuth();
const isSuperAdmin = user?.company_code === "*";
@@ -393,6 +405,8 @@ export default function AuditLogPage() {
setDetailOpen(true);
};
if (hostBlocked) return null;
return (
<div className="flex h-full flex-col gap-4 p-4 md:p-6">
<div className="flex items-center justify-between">
@@ -1,21 +0,0 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
/**
*
*/
export default function AutoFillRedirect() {
const router = useRouter();
useEffect(() => {
router.replace("/admin/cascading-management?tab=autofill");
}, [router]);
return (
<div className="flex h-full items-center justify-center">
<div className="text-muted-foreground text-sm"> ...</div>
</div>
);
}
@@ -213,7 +213,7 @@ export default function BatchCreatePage() {
toast.success("매핑을 삭제했어요");
};
const goBack = () => openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
const goBack = () => openTab({ type: "admin", title: "배치 관리", admin_url: "/admin/automaticMng/batchmngList" });
const saveBatchConfig = async () => {
if (!batchName.trim()) { toast.error("배치 이름을 입력해주세요"); return; }
@@ -25,8 +25,14 @@ import {
ConnectionInfo,
type NodeFlowInfo,
type BatchExecutionType,
type ConditionalConfig,
} from "@/lib/api/batch";
import { BatchManagementAPI } from "@/lib/api/batchManagement";
import {
ConditionalEditor,
emptyConditionalConfig,
normalizeConditionalConfig,
} from "@/components/admin/batch/ConditionalEditor";
const SCHEDULE_PRESETS = [
{ label: "5분마다", cron: "*/5 * * * *", preview: "5분마다 실행돼요" },
@@ -165,12 +171,17 @@ export default function BatchEditPage() {
const [apiParamSource, setApiParamSource] = useState<"static" | "dynamic">("static");
// 매핑 리스트 (새로운 UI용)
// sourceType:
// - "api" : apiField 의 값을 그대로 복사 (mapping_type=direct)
// - "fixed" : fixedValue 자체가 저장값 (mapping_type=fixed)
// - "conditional" : apiField 값을 conditionalConfig 룰로 변환 (mapping_type=conditional)
interface MappingItem {
id: string;
dbColumn: string;
sourceType: "api" | "fixed";
sourceType: "api" | "fixed" | "conditional";
apiField: string;
fixedValue: string;
conditionalConfig?: ConditionalConfig;
}
const [mappingList, setMappingList] = useState<MappingItem[]>([]);
@@ -377,13 +388,27 @@ export default function BatchEditPage() {
});
// 기존 매핑을 mappingList로 변환
const convertedMappingList: MappingItem[] = config.batch_mappings.map((mapping, index) => ({
id: `mapping-${index}-${Date.now()}`,
dbColumn: mapping.to_column_name || "",
sourceType: (mapping as any).mapping_type === "fixed" ? "fixed" as const : "api" as const,
apiField: (mapping as any).mapping_type === "fixed" ? "" : mapping.from_column_name || "",
fixedValue: (mapping as any).mapping_type === "fixed" ? mapping.from_column_name || "" : "",
}));
// mapping_type 분기:
// "fixed" → from_column_name 자체가 고정값 → fixedValue
// "conditional" → from_column_name 이 평가 필드명 → apiField + conditionalConfig
// 그 외(direct) → from_column_name 이 API 필드명 → apiField
const convertedMappingList: MappingItem[] = config.batch_mappings.map((mapping, index) => {
const mt = (mapping as any).mapping_type || "direct";
const sourceType: MappingItem["sourceType"] =
mt === "fixed" ? "fixed" : mt === "conditional" ? "conditional" : "api";
const conditionalConfig =
sourceType === "conditional"
? normalizeConditionalConfig((mapping as any).mapping_config)
: undefined;
return {
id: `mapping-${index}-${Date.now()}`,
dbColumn: mapping.to_column_name || "",
sourceType,
apiField: sourceType === "fixed" ? "" : mapping.from_column_name || "",
fixedValue: sourceType === "fixed" ? mapping.from_column_name || "" : "",
conditionalConfig,
};
});
setMappingList(convertedMappingList);
console.log("🔄 변환된 mappingList:", convertedMappingList);
}
@@ -651,7 +676,7 @@ export default function BatchEditPage() {
nodeFlowContext: parsedContext,
});
toast.success("배치 설정이 저장되었습니다!");
openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
openTab({ type: "admin", title: "배치 관리", admin_url: "/admin/automaticMng/batchmngList" });
} catch (error) {
console.error("배치 저장 실패:", error);
toast.error("배치 저장에 실패했습니다.");
@@ -679,26 +704,46 @@ export default function BatchEditPage() {
const first = batchConfig.batch_mappings[0] as any;
finalMappings = mappingList
.filter((m) => m.dbColumn) // DB 컬럼이 선택된 것만
.map((m, index) => ({
// FROM: REST API (기존 설정 복사)
from_connection_type: "restapi" as any,
from_connection_id: first.from_connection_id,
from_table_name: first.from_table_name,
from_column_name: m.sourceType === "fixed" ? m.fixedValue : m.apiField,
from_column_type: m.sourceType === "fixed" ? "text" : "text",
from_api_url: mappings[0]?.from_api_url || first.from_api_url,
from_api_key: authTokenMode === "direct" ? fromApiKey : first.from_api_key,
from_api_method: mappings[0]?.from_api_method || first.from_api_method,
from_api_body: mappings[0]?.from_api_body || first.from_api_body,
// TO: DB (기존 설정 복사)
to_connection_type: first.to_connection_type as any,
to_connection_id: first.to_connection_id,
to_table_name: toTable || first.to_table_name,
to_column_name: m.dbColumn,
to_column_type: toColumns.find((c) => c.column_name === m.dbColumn)?.data_type || "text",
mapping_type: m.sourceType === "fixed" ? "fixed" : "direct",
mapping_order: index + 1,
})) as BatchMapping[];
.map((m, index) => {
// from_column_name 결정:
// fixed → fixedValue 자체가 저장됨
// conditional → apiField (평가할 API 필드)
// direct(api) → apiField
const fromColumnName =
m.sourceType === "fixed" ? m.fixedValue : m.apiField;
const mappingType: "direct" | "fixed" | "conditional" =
m.sourceType === "fixed"
? "fixed"
: m.sourceType === "conditional"
? "conditional"
: "direct";
return {
// FROM: REST API (기존 설정 복사)
from_connection_type: "restapi" as any,
from_connection_id: first.from_connection_id,
from_table_name: first.from_table_name,
from_column_name: fromColumnName,
from_column_type: "text",
from_api_url: mappings[0]?.from_api_url || first.from_api_url,
from_api_key: authTokenMode === "direct" ? fromApiKey : first.from_api_key,
from_api_method: mappings[0]?.from_api_method || first.from_api_method,
from_api_body: mappings[0]?.from_api_body || first.from_api_body,
// TO: DB (기존 설정 복사)
to_connection_type: first.to_connection_type as any,
to_connection_id: first.to_connection_id,
to_table_name: toTable || first.to_table_name,
to_column_name: m.dbColumn,
to_column_type:
toColumns.find((c) => c.column_name === m.dbColumn)?.data_type || "text",
mapping_type: mappingType,
// conditional 일 때만 룰 객체를 함께 전송. 백엔드가 JSONB 로 저장.
mapping_config:
m.sourceType === "conditional" && m.conditionalConfig
? m.conditionalConfig
: null,
mapping_order: index + 1,
};
}) as BatchMapping[];
}
await BatchAPI.updateBatchConfig(batchId, {
@@ -714,7 +759,7 @@ export default function BatchEditPage() {
});
toast.success("배치 설정이 성공적으로 수정되었습니다.");
openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
openTab({ type: "admin", title: "배치 관리", admin_url: "/admin/automaticMng/batchmngList" });
} catch (error) {
console.error("배치 설정 수정 실패:", error);
@@ -724,7 +769,7 @@ export default function BatchEditPage() {
}
};
const goBack = () => openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
const goBack = () => openTab({ type: "admin", title: "배치 관리", admin_url: "/admin/automaticMng/batchmngList" });
const selectedFlow = nodeFlows.find(f => f.flow_id === selectedFlowId);
if (loading && !batchConfig) {
@@ -739,7 +784,7 @@ export default function BatchEditPage() {
}
return (
<div className="mx-auto h-full max-w-[640px] space-y-7 overflow-y-auto p-4 sm:p-6">
<div className="h-full w-full space-y-7 overflow-y-auto p-4 sm:p-6">
{/* 헤더 */}
<div>
<button onClick={goBack} className="mb-2 flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
@@ -1617,14 +1662,22 @@ export default function BatchEditPage() {
<ArrowLeft className="text-muted-foreground h-4 w-4 shrink-0" />
{/* 소스 타입 선택 */}
<div className="w-24 shrink-0">
<div className="w-28 shrink-0">
<Select
value={mapping.sourceType}
onValueChange={(value: "api" | "fixed") =>
onValueChange={(value: "api" | "fixed" | "conditional") =>
updateMappingListItem(mapping.id, {
sourceType: value,
apiField: value === "fixed" ? "" : mapping.apiField,
fixedValue: value === "api" ? "" : mapping.fixedValue,
// 모드 전환 시 입력값 정리
apiField:
value === "api" || value === "conditional"
? mapping.apiField
: "",
fixedValue: value === "fixed" ? mapping.fixedValue : "",
conditionalConfig:
value === "conditional"
? mapping.conditionalConfig || emptyConditionalConfig()
: mapping.conditionalConfig,
})
}
>
@@ -1634,13 +1687,14 @@ export default function BatchEditPage() {
<SelectContent>
<SelectItem value="api">API </SelectItem>
<SelectItem value="fixed"></SelectItem>
<SelectItem value="conditional"> </SelectItem>
</SelectContent>
</Select>
</div>
{/* API 필드 선택 또는 고정값 입력 (우측 - FROM) */}
{/* API 필드 선택 / 고정값 입력 / 조건 변환 (우측 - FROM) */}
<div className="min-w-0 flex-1">
{mapping.sourceType === "api" ? (
{mapping.sourceType === "api" && (
<Select
value={mapping.apiField || "none"}
onValueChange={(value) =>
@@ -1667,7 +1721,8 @@ export default function BatchEditPage() {
))}
</SelectContent>
</Select>
) : (
)}
{mapping.sourceType === "fixed" && (
<Input
value={mapping.fixedValue}
onChange={(e) => updateMappingListItem(mapping.id, { fixedValue: e.target.value })}
@@ -1675,6 +1730,19 @@ export default function BatchEditPage() {
className="h-9"
/>
)}
{mapping.sourceType === "conditional" && (
<ConditionalEditor
evaluateField={mapping.apiField}
fieldOptions={fromApiFields}
config={mapping.conditionalConfig || emptyConditionalConfig()}
onEvaluateFieldChange={(v) =>
updateMappingListItem(mapping.id, { apiField: v })
}
onConfigChange={(cfg) =>
updateMappingListItem(mapping.id, { conditionalConfig: cfg })
}
/>
)}
</div>
{/* 삭제 버튼 */}
@@ -35,6 +35,7 @@ import {
} from "@/lib/api/batch";
import { ScrollToTop } from "@/components/common/ScrollToTop";
import { CrossTenantBanner } from "@/components/common/CrossTenantBanner";
import { Pagination } from "@/components/common/Pagination";
import { useTabStore } from "@/stores/tabStore";
function cronToKorean(cron: string): string {
@@ -127,9 +128,11 @@ function Sparkline({ data }: { data: SparklineData[] }) {
return (
<div className="flex h-8 items-end gap-[2px]">
{data.map((slot, i) => {
const hasFail = slot.failed > 0;
const hasSuccess = slot.success > 0;
const height = hasFail ? "40%" : hasSuccess ? `${Math.max(30, Math.min(95, 50 + slot.success * 10))}%` : "8%";
const failed = Number(slot.failed_count) || 0;
const success = Number(slot.success_count) || 0;
const hasFail = failed > 0;
const hasSuccess = success > 0;
const height = hasFail ? "40%" : hasSuccess ? `${Math.max(30, Math.min(95, 50 + success * 10))}%` : "8%";
const colorClass = hasFail
? "bg-destructive/70 hover:bg-destructive"
: hasSuccess
@@ -140,7 +143,7 @@ function Sparkline({ data }: { data: SparklineData[] }) {
key={i}
className={`min-w-[4px] flex-1 rounded-t-sm transition-colors ${colorClass}`}
style={{ height }}
title={`${slot.hour?.slice(11, 16) || i}시 | 성공: ${slot.success} 실패: ${slot.failed}`}
title={`${slot.hour_slot?.slice(11, 16) || i}시 | 성공: ${success} 실패: ${failed}`}
/>
);
})}
@@ -277,8 +280,10 @@ function BatchDetailPanel({ batch, sparkline, recentLogs }: { batch: BatchConfig
);
}
function GlobalSparkline({ stats }: { stats: BatchStats | null }) {
if (!stats) return null;
function GlobalSparkline({ data }: { data: SparklineData[] }) {
if (!data || data.length === 0) return null;
// 24개 슬롯 중 가장 큰 success_count 를 100% 로 맞춰 비율 스케일링
const maxSuccess = data.reduce((m, s) => Math.max(m, Number(s.success_count) || 0), 0);
return (
<div className="rounded-lg border bg-card p-4">
<div className="mb-3 flex items-center justify-between">
@@ -293,22 +298,31 @@ function GlobalSparkline({ stats }: { stats: BatchStats | null }) {
</div>
</div>
<div className="flex h-10 items-end gap-[3px]">
{Array.from({ length: 24 }).map((_, i) => {
const hasExec = Math.random() > 0.3;
const hasFail = hasExec && Math.random() < 0.08;
const h = hasFail ? 35 : hasExec ? 25 + Math.random() * 70 : 6;
{data.map((slot, i) => {
const success = Number(slot.success_count) || 0;
const failed = Number(slot.failed_count) || 0;
const hasFail = failed > 0;
const hasExec = success > 0 || hasFail;
// 실패가 하나라도 있으면 실패 색으로 강조, 아니면 success 비율
const h = hasFail
? Math.max(35, Math.min(95, 35 + (failed / Math.max(maxSuccess, 1)) * 60))
: hasExec
? Math.max(20, Math.min(95, (success / Math.max(maxSuccess, 1)) * 90))
: 6;
const hour = slot.hour_slot?.slice(11, 16) || "";
return (
<div
key={i}
className={`flex-1 rounded-t-sm transition-colors ${hasFail ? "bg-destructive/60 hover:bg-destructive" : hasExec ? "bg-emerald-500/40 hover:bg-emerald-500/70" : "bg-muted-foreground/8"}`}
className={`flex-1 rounded-t-sm transition-colors ${hasFail ? "bg-destructive/60 hover:bg-destructive" : hasExec ? "bg-emerald-500/40 hover:bg-emerald-500/70" : "bg-muted-foreground/10"}`}
style={{ height: `${h}%` }}
title={`${hour} | 성공 ${success} 실패 ${failed}`}
/>
);
})}
</div>
<div className="mt-1 flex justify-between text-[10px] text-muted-foreground">
<span>24 </span>
<span>12 </span>
<span>6 </span>
<span></span>
</div>
</div>
@@ -326,18 +340,25 @@ export default function BatchManagementPage() {
const [executingBatch, setExecutingBatch] = useState<number | null>(null);
const [expandedBatch, setExpandedBatch] = useState<number | null>(null);
const [stats, setStats] = useState<BatchStats | null>(null);
const [globalSparkline, setGlobalSparkline] = useState<SparklineData[]>([]);
const [sparklineCache, setSparklineCache] = useState<Record<number, SparklineData[]>>({});
const [recentLogsCache, setRecentLogsCache] = useState<Record<number, RecentLog[]>>({});
const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false);
const [togglingBatch, setTogglingBatch] = useState<number | null>(null);
// 페이지네이션 상태
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(20);
const loadBatchConfigs = useCallback(async () => {
setLoading(true);
try {
const [configsResponse, statsData] = await Promise.all([
const [configsResponse, statsData, globalSpark] = await Promise.all([
BatchAPI.getBatchConfigs({ page: 1, limit: 200 }),
BatchAPI.getBatchStats(),
BatchAPI.getGlobalSparkline(),
]);
setGlobalSparkline(globalSpark);
// cross-tenant 메타 (단일 모드면 undefined → null)
setCrossTenantMeta((configsResponse as any)?.cross_tenant_meta ?? null);
if (configsResponse.success && configsResponse.data) {
@@ -364,6 +385,9 @@ export default function BatchManagementPage() {
useEffect(() => { loadBatchConfigs(); }, [loadBatchConfigs]);
// 검색/필터 변경 시 1페이지로 리셋
useEffect(() => { setCurrentPage(1); }, [searchTerm, statusFilter]);
const handleRowClick = async (batchId: number) => {
if (expandedBatch === batchId) { setExpandedBatch(null); return; }
setExpandedBatch(batchId);
@@ -427,12 +451,12 @@ export default function BatchManagementPage() {
setIsBatchTypeModalOpen(false);
if (type === "db-to-db") {
sessionStorage.setItem("batch_create_type", "mapping");
openTab({ type: "admin", title: "배치 생성 (DB→DB)", adminUrl: "/admin/automaticMng/batchmngList/create" });
openTab({ type: "admin", title: "배치 생성 (DB→DB)", admin_url: "/admin/automaticMng/batchmngList/create" });
} else if (type === "restapi-to-db") {
openTab({ type: "admin", title: "배치 생성 (API→DB)", adminUrl: "/admin/batch-management-new" });
openTab({ type: "admin", title: "배치 생성 (API→DB)", admin_url: "/admin/batch-management-new" });
} else {
sessionStorage.setItem("batch_create_type", "node_flow");
openTab({ type: "admin", title: "배치 생성 (노드플로우)", adminUrl: "/admin/automaticMng/batchmngList/create" });
openTab({ type: "admin", title: "배치 생성 (노드플로우)", admin_url: "/admin/automaticMng/batchmngList/create" });
}
};
@@ -443,14 +467,26 @@ export default function BatchManagementPage() {
return true;
});
// 페이지네이션 계산
const totalItems = filteredBatches.length;
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage));
const safePage = Math.min(currentPage, totalPages);
const startIdx = (safePage - 1) * itemsPerPage;
const endIdx = Math.min(startIdx + itemsPerPage, totalItems);
const pagedBatches = filteredBatches.slice(startIdx, endIdx);
const activeBatches = batchConfigs.filter(b => b.is_active === "Y").length;
const inactiveBatches = batchConfigs.length - activeBatches;
const execDiff = stats ? stats.todayExecutions - stats.prevDayExecutions : 0;
const failDiff = stats ? stats.todayFailures - stats.prevDayFailures : 0;
const todayExec = Number(stats?.today_count) || 0;
const todayFail = Number(stats?.today_failed_count) || 0;
const yestExec = Number(stats?.yesterday_count) || 0;
const yestFail = Number(stats?.yesterday_failed_count) || 0;
const execDiff = todayExec - yestExec;
const failDiff = todayFail - yestFail;
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="mx-auto w-full max-w-[720px] space-y-4 px-4 py-6 sm:px-6">
<div className="bg-background flex h-full min-h-0 w-full flex-col overflow-hidden">
<div className="flex min-h-0 flex-1 flex-col gap-4 px-4 py-6 sm:px-6">
{/* 헤더 */}
<div className="flex items-center justify-between">
@@ -486,7 +522,7 @@ export default function BatchManagementPage() {
<div className="h-8 w-px bg-border" />
<div className="flex flex-1 flex-col px-4 py-3">
<span className="text-[11px] text-muted-foreground"> </span>
<span className="text-lg font-bold text-emerald-600">{stats.todayExecutions}</span>
<span className="text-lg font-bold text-emerald-600">{todayExec}</span>
{execDiff !== 0 && (
<span className={`text-[10px] ${execDiff > 0 ? "text-emerald-500" : "text-muted-foreground"}`}>
{execDiff > 0 ? "+" : ""}{execDiff}
@@ -496,8 +532,8 @@ export default function BatchManagementPage() {
<div className="h-8 w-px bg-border" />
<div className="flex flex-1 flex-col px-4 py-3">
<span className="text-[11px] text-muted-foreground"></span>
<span className={`text-lg font-bold ${stats.todayFailures > 0 ? "text-destructive" : "text-muted-foreground"}`}>
{stats.todayFailures}
<span className={`text-lg font-bold ${todayFail > 0 ? "text-destructive" : "text-muted-foreground"}`}>
{todayFail}
</span>
{failDiff !== 0 && (
<span className={`text-[10px] ${failDiff > 0 ? "text-destructive" : "text-emerald-500"}`}>
@@ -509,7 +545,7 @@ export default function BatchManagementPage() {
)}
{/* 24시간 차트 */}
<GlobalSparkline stats={stats} />
<GlobalSparkline data={globalSparkline} />
{/* 검색 + 필터 */}
<div className="flex flex-wrap items-center gap-3">
@@ -534,8 +570,8 @@ export default function BatchManagementPage() {
</div>
</div>
{/* 배치 리스트 */}
<div className="space-y-1.5">
{/* 배치 리스트 - 자체 스크롤 */}
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1">
{loading && batchConfigs.length === 0 && (
<div className="flex h-40 items-center justify-center">
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
@@ -549,7 +585,7 @@ export default function BatchManagementPage() {
</div>
)}
{filteredBatches.map((batch) => {
{pagedBatches.map((batch) => {
const batchId = batch.id!;
const isExpanded = expandedBatch === batchId;
const isExecuting = executingBatch === batchId;
@@ -564,7 +600,7 @@ export default function BatchManagementPage() {
const isSuccess = lastStatus === "SUCCESS";
return (
<div key={batchId} className={`overflow-hidden rounded-lg border transition-all ${isExpanded ? "ring-1 ring-primary/20" : "hover:border-muted-foreground/20"} ${!isActive ? "opacity-55" : ""}`}>
<div key={`${batch.company_code ?? "x"}-${batchId}`} className={`overflow-hidden rounded-lg border transition-all ${isExpanded ? "ring-1 ring-primary/20" : "hover:border-muted-foreground/20"} ${!isActive ? "opacity-55" : ""}`}>
{/* 행 */}
<div className="flex cursor-pointer items-center gap-3 px-4 py-3.5 sm:gap-4" onClick={() => handleRowClick(batchId)}>
{/* 토글 */}
@@ -638,7 +674,7 @@ export default function BatchManagementPage() {
</button>
<button
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={(e) => { e.stopPropagation(); openTab({ type: "admin", title: `배치 편집 #${batchId}`, adminUrl: `/admin/automaticMng/batchmngList/edit/${batchId}` }); }}
onClick={(e) => { e.stopPropagation(); openTab({ type: "admin", title: `배치 편집 #${batchId}`, admin_url: `/admin/automaticMng/batchmngList/edit/${batchId}` }); }}
title="수정하기"
>
<Pencil className="h-3.5 w-3.5" />
@@ -674,6 +710,29 @@ export default function BatchManagementPage() {
})}
</div>
{/* 페이지네이션 — 리스트 영역 아래 고정 */}
{!loading && (
<div className="shrink-0 rounded-lg border bg-card p-2 shadow-sm">
<Pagination
paginationInfo={{
currentPage: safePage,
totalPages,
totalItems,
itemsPerPage,
startItem: totalItems === 0 ? 0 : startIdx + 1,
endItem: endIdx,
}}
onPageChange={setCurrentPage}
onPageSizeChange={(size) => {
setItemsPerPage(size);
setCurrentPage(1);
}}
showPageSizeSelector
pageSizeOptions={[10, 20, 50, 100]}
/>
</div>
)}
{/* 배치 타입 선택 모달 */}
{isBatchTypeModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm" onClick={() => setIsBatchTypeModalOpen(false)}>
@@ -231,15 +231,15 @@ export default function ExternalConnectionsPage() {
) },
{ key: "id", label: "연결 테스트", width: "150px", hideOnMobile: true,
render: (_v, row) => (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<Button variant="outline" size="sm"
onClick={(e) => { e.stopPropagation(); handleTestConnection(row); }}
disabled={testingConnections.has(row.id!)}
className="h-9 text-sm">
className="h-7 px-2 text-xs">
{testingConnections.has(row.id!) ? "테스트 중..." : "테스트"}
</Button>
{testResults.has(row.id!) && (
<Badge variant={testResults.get(row.id!) ? "default" : "destructive"}>
<Badge variant={testResults.get(row.id!) ? "default" : "destructive"} className="text-[10px]">
{testResults.get(row.id!) ? "성공" : "실패"}
</Badge>
)}
@@ -264,68 +264,68 @@ export default function ExternalConnectionsPage() {
];
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
<div className="bg-background flex h-full min-h-0 w-full flex-col overflow-hidden">
<div className="flex min-h-0 flex-1 flex-col gap-4 px-4 py-4 sm:px-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> REST API </p>
<div className="shrink-0 space-y-0.5 border-b pb-3">
<h1 className="text-lg font-bold tracking-tight"> </h1>
<p className="text-xs text-muted-foreground"> REST API </p>
</div>
{/* 탭 */}
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as ConnectionTabType)}>
<TabsList className="grid w-full max-w-[400px] grid-cols-2">
<TabsTrigger value="database" className="flex items-center gap-2">
<Database className="h-4 w-4" />
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as ConnectionTabType)} className="flex min-h-0 flex-1 flex-col gap-3">
<TabsList className="grid h-8 w-full max-w-[320px] shrink-0 grid-cols-2">
<TabsTrigger value="database" className="flex items-center gap-1.5 text-xs">
<Database className="h-3.5 w-3.5" />
</TabsTrigger>
<TabsTrigger value="rest-api" className="flex items-center gap-2">
<Globe className="h-4 w-4" />
<TabsTrigger value="rest-api" className="flex items-center gap-1.5 text-xs">
<Globe className="h-3.5 w-3.5" />
REST API
</TabsTrigger>
</TabsList>
{/* 데이터베이스 연결 탭 */}
<TabsContent value="database" className="space-y-6">
<TabsContent value="database" className="mt-0 flex min-h-0 flex-1 flex-col gap-3">
{/* 검색 및 필터 */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-[300px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<div className="flex shrink-0 flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-[260px]">
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="연결명 또는 설명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-10 pl-10 text-sm"
className="h-8 pl-9 text-xs"
/>
</div>
<Select value={dbTypeFilter} onValueChange={setDbTypeFilter}>
<SelectTrigger className="h-10 w-full sm:w-[160px]">
<SelectTrigger className="h-8 w-full text-xs sm:w-[140px]">
<SelectValue placeholder="DB 타입" />
</SelectTrigger>
<SelectContent>
{supportedDbTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
<SelectItem key={type.value} value={type.value} className="text-xs">
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={activeStatusFilter} onValueChange={setActiveStatusFilter}>
<SelectTrigger className="h-10 w-full sm:w-[120px]">
<SelectTrigger className="h-8 w-full text-xs sm:w-[110px]">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{ACTIVE_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
<SelectItem key={option.value} value={option.value} className="text-xs">
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={handleAddConnection} className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
<Button onClick={handleAddConnection} size="sm" className="h-8 gap-1 text-xs font-medium">
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
@@ -338,10 +338,12 @@ export default function ExternalConnectionsPage() {
isLoading={loading}
emptyMessage="등록된 연결이 없습니다"
skeletonCount={5}
compact
scrollContainer
cardTitle={(c) => c.connection_name}
cardSubtitle={(c) => <span className="font-mono text-xs">{c.host}:{c.port}/{c.database_name}</span>}
cardHeaderRight={(c) => (
<Badge variant={c.is_active === "Y" ? "default" : "secondary"}>
<Badge variant={c.is_active === "Y" ? "default" : "secondary"} className="text-[10px]">
{c.is_active === "Y" ? "활성" : "비활성"}
</Badge>
)}
@@ -351,7 +353,7 @@ export default function ExternalConnectionsPage() {
<Button variant="outline" size="sm"
onClick={(e) => { e.stopPropagation(); handleTestConnection(c); }}
disabled={testingConnections.has(c.id!)}
className="h-9 flex-1 gap-2 text-sm">
className="h-7 flex-1 gap-1 text-xs">
{testingConnections.has(c.id!) ? "테스트 중..." : "테스트"}
</Button>
<Button variant="outline" size="sm"
@@ -360,20 +362,20 @@ export default function ExternalConnectionsPage() {
setSelectedConnection(c);
setSqlModalOpen(true);
}}
className="h-9 flex-1 gap-2 text-sm">
<Terminal className="h-4 w-4" />
className="h-7 flex-1 gap-1 text-xs">
<Terminal className="h-3.5 w-3.5" />
SQL
</Button>
<Button variant="outline" size="sm"
onClick={(e) => { e.stopPropagation(); handleEditConnection(c); }}
className="h-9 flex-1 gap-2 text-sm">
<Pencil className="h-4 w-4" />
className="h-7 flex-1 gap-1 text-xs">
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="outline" size="sm"
onClick={(e) => { e.stopPropagation(); handleDeleteConnection(c); }}
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-9 flex-1 gap-2 text-sm">
<Trash2 className="h-4 w-4" />
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-7 flex-1 gap-1 text-xs">
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>
@@ -436,7 +438,7 @@ export default function ExternalConnectionsPage() {
</TabsContent>
{/* REST API 연결 탭 */}
<TabsContent value="rest-api" className="space-y-6">
<TabsContent value="rest-api" className="mt-0 flex min-h-0 flex-1 flex-col gap-3">
<RestApiConnectionList />
</TabsContent>
</Tabs>
@@ -13,6 +13,15 @@ import { Trash2, Plus, ArrowLeft, Save, RefreshCw, Globe, Database, Eye } from "
import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { BatchManagementAPI } from "@/lib/api/batchManagement";
import type { ConditionalConfig } from "@/lib/api/batch";
import {
ConditionalEditor,
emptyConditionalConfig,
} from "@/components/admin/batch/ConditionalEditor";
import {
ExternalRestApiConnectionAPI,
type ExternalRestApiConnection,
} from "@/lib/api/externalRestApiConnection";
// 타입 정의
type BatchType = "db-to-restapi" | "restapi-to-db" | "restapi-to-restapi";
@@ -36,12 +45,17 @@ interface BatchColumnInfo {
}
// 통합 매핑 아이템 타입
// sourceType:
// - "api" : apiField 의 값을 그대로 복사 (mapping_type=direct)
// - "fixed" : fixedValue 자체가 저장값 (mapping_type=fixed)
// - "conditional" : apiField 값을 conditionalConfig 룰로 변환 (mapping_type=conditional)
interface MappingItem {
id: string;
dbColumn: string;
sourceType: "api" | "fixed";
sourceType: "api" | "fixed" | "conditional";
apiField: string;
fixedValue: string;
conditionalConfig?: ConditionalConfig;
}
interface RestApiToDbMappingCardProps {
@@ -117,6 +131,15 @@ export default function BatchManagementNewPage() {
const [fromApiData, setFromApiData] = useState<any[]>([]);
const [fromApiFields, setFromApiFields] = useState<string[]>([]);
// 등록된 REST API 연결 (외부 커넥션 관리에서 등록한 연결 선택)
// - 선택 시 폼(URL/엔드포인트/메서드/Body/인증) 자동 채움
// - 자동으로 API 호출하여 응답 필드 추출 → 매핑 드롭다운 즉시 활성화
const [registeredRestApis, setRegisteredRestApis] = useState<ExternalRestApiConnection[]>([]);
const [selectedRestApiId, setSelectedRestApiId] = useState<string>("manual"); // "manual" = 직접 입력
const [rawResponse, setRawResponse] = useState<unknown>(null);
const [rawResponseLoading, setRawResponseLoading] = useState(false);
const [rawResponseError, setRawResponseError] = useState<string>("");
// 통합 매핑 리스트
const [mappingList, setMappingList] = useState<MappingItem[]>([]);
@@ -145,8 +168,110 @@ export default function BatchManagementNewPage() {
useEffect(() => {
loadConnections();
loadAuthServiceNames();
loadRegisteredRestApis();
}, []);
// TO DB 자동 선택 — REST API → DB 모드에서 connections 로드 완료 후 TO 가 비어있으면 internal 자동.
// 사용자가 외부 DB 로 직접 변경하면 toConnection != null 이 되어 더 이상 동작 안 함.
// 대부분의 배치가 internal DB 적재라 디폴트로 들어가는 게 UX 상 자연스러움.
useEffect(() => {
if (batchType === "restapi-to-db" && !toConnection && connections.length > 0) {
handleToConnectionChange("internal");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [batchType, connections, toConnection]);
// 등록된 REST API 연결 목록 로드
const loadRegisteredRestApis = async () => {
try {
const list = await ExternalRestApiConnectionAPI.getConnections();
setRegisteredRestApis(Array.isArray(list) ? list : []);
} catch (e) {
console.error("등록된 REST API 연결 목록 로드 실패:", e);
}
};
// 등록된 연결 선택 시 폼 자동 채우기 + API 호출 + 응답 필드 추출 (자동 매핑 준비).
// vexplor_rps 의 applyRegisteredRestApi 에서 회사 전용 프리셋(Amaranth) 분기는 의도적으로 제외.
const applyRegisteredRestApi = async (id: string) => {
setSelectedRestApiId(id);
if (id === "manual") return;
const conn = registeredRestApis.find((c) => String(c.id) === id);
if (!conn) return;
// 폼 자동 채움
setFromApiUrl(conn.base_url || "");
setFromEndpoint(conn.endpoint_path || "");
setFromApiMethod((conn.default_method as "GET" | "POST" | "PUT" | "DELETE") || "GET");
setFromApiBody(conn.default_body || "");
// 인증 토큰 자동 채움 (직접 입력 모드)
setAuthTokenMode("direct");
setAuthServiceName("");
if (conn.auth_type === "bearer" && conn.auth_config?.token) {
setFromApiKey(`Bearer ${conn.auth_config.token}`);
} else if (conn.auth_type === "api-key" && conn.auth_config?.keyValue) {
setFromApiKey(conn.auth_config.keyValue);
} else {
// wehago 등 백엔드 자동 서명 타입은 토큰 입력 불필요 — 비워둠
setFromApiKey("");
}
// 자동으로 API 호출 → 응답 본문 + 필드 추출하여 매핑 드롭다운 즉시 활성화
setRawResponseError("");
setRawResponseLoading(true);
setRawResponse(null);
try {
const result = await ExternalRestApiConnectionAPI.testConnectionById(
Number(id),
conn.endpoint_path || undefined,
);
if (result.success) {
setRawResponse(result.response_data);
// 응답 안에서 배열을 자동 탐색 (dataArrayPath 가 아직 안 박혀도 동작)
const findArr = (o: unknown, depth = 0): unknown[] | null => {
if (Array.isArray(o)) return o;
if (depth >= 4 || typeof o !== "object" || o === null) return null;
for (const v of Object.values(o)) {
const a = findArr(v, depth + 1);
if (a) return a;
}
return null;
};
const arr = findArr(result.response_data);
if (arr && arr.length > 0 && typeof arr[0] === "object" && arr[0] !== null) {
const fields = Object.keys(arr[0] as Record<string, unknown>);
setFromApiFields(fields);
setFromApiData(arr as Record<string, unknown>[]);
toast.success(
`'${conn.connection_name}' API 호출 완료 — 배열 ${arr.length}건 / 필드 ${fields.length}개 추출`,
);
} else if (
result.response_data &&
typeof result.response_data === "object" &&
!Array.isArray(result.response_data)
) {
const fields = Object.keys(result.response_data as Record<string, unknown>);
setFromApiFields(fields);
setFromApiData([result.response_data as Record<string, unknown>]);
toast.success(`'${conn.connection_name}' API 호출 완료 — 필드 ${fields.length}개 추출`);
} else {
toast.success(`'${conn.connection_name}' API 호출 완료 — 응답을 받았어요`);
}
} else {
const msg = result.message || result.error_details || "API 호출 실패";
setRawResponseError(msg);
toast.error(`'${conn.connection_name}' API 호출 실패: ${msg}`);
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
setRawResponseError(msg);
toast.error(`API 호출 중 오류: ${msg}`);
} finally {
setRawResponseLoading(false);
}
};
// 인증 서비스명 목록 로드
const loadAuthServiceNames = async () => {
try {
@@ -206,14 +331,14 @@ export default function BatchManagementNewPage() {
// 내부 데이터베이스 선택
connection = connections.find((conn) => conn.type === "internal") || null;
} else {
// 외부 데이터베이스 선택
const connectionId = parseInt(connectionValue);
connection = connections.find((conn) => conn.id === connectionId) || null;
// 외부 데이터베이스 선택 — id 가 number/string 어느 쪽이든 안전하게 비교
connection = connections.find((conn) => conn.id?.toString() === connectionValue) || null;
}
setToConnection(connection);
setToTable("");
setToColumns([]);
setToTables([]);
if (connection) {
try {
@@ -258,12 +383,12 @@ export default function BatchManagementNewPage() {
if (connectionValue === "internal") {
connection = connections.find((conn) => conn.type === "internal") || null;
} else {
const connectionId = parseInt(connectionValue);
connection = connections.find((conn) => conn.id === connectionId) || null;
connection = connections.find((conn) => conn.id?.toString() === connectionValue) || null;
}
setFromConnection(connection);
setFromTable("");
setFromColumns([]);
setFromTables([]);
if (connection) {
try {
@@ -409,10 +534,21 @@ export default function BatchManagementNewPage() {
// 배치 타입별 검증 및 저장
if (batchType === "restapi-to-db") {
// 유효한 매핑만 필터링 (DB 컬럼이 선택되고, API 필드 또는 고정값이 있는 것)
const validMappings = mappingList.filter(
(m) => m.dbColumn && (m.sourceType === "api" ? m.apiField : m.fixedValue),
);
// 유효한 매핑만 필터링:
// api → dbColumn + apiField 둘 다 필요
// conditional → dbColumn + apiField (평가 필드) + 최소 1개 룰 또는 default 필요
// fixed → dbColumn + fixedValue 둘 다 필요
const validMappings = mappingList.filter((m) => {
if (!m.dbColumn) return false;
if (m.sourceType === "fixed") return !!m.fixedValue;
if (m.sourceType === "conditional") {
if (!m.apiField) return false;
const cfg = m.conditionalConfig;
if (!cfg) return false;
return cfg.rules.some((r) => r.when || r.then) || !!cfg.default;
}
return !!m.apiField;
});
if (validMappings.length === 0) {
toast.error("최소 하나의 매핑을 설정해주세요.");
@@ -427,26 +563,45 @@ export default function BatchManagementNewPage() {
// 통합 매핑 리스트를 배치 매핑 형태로 변환
// 고정값 매핑도 동일한 from_connection_type을 사용해야 같은 그룹으로 처리됨
const apiMappings = validMappings.map((mapping) => ({
from_connection_type: "restapi" as const, // 고정값도 동일한 소스 타입 사용
from_table_name: fromEndpoint,
from_column_name: mapping.sourceType === "api" ? mapping.apiField : mapping.fixedValue,
from_api_url: fromApiUrl,
from_api_key: authTokenMode === "direct" ? fromApiKey : "",
from_api_method: fromApiMethod,
from_api_body:
fromApiMethod === "POST" || fromApiMethod === "PUT" || fromApiMethod === "DELETE" ? fromApiBody : undefined,
from_api_param_type: apiParamType !== "none" ? apiParamType : undefined,
from_api_param_name: apiParamType !== "none" ? apiParamName : undefined,
from_api_param_value: apiParamType !== "none" ? apiParamValue : undefined,
from_api_param_source: apiParamType !== "none" ? apiParamSource : undefined,
to_connection_type: toConnection?.type === "internal" ? "internal" : "external",
to_connection_id: toConnection?.type === "internal" ? undefined : toConnection?.id,
to_table_name: toTable,
to_column_name: mapping.dbColumn,
mapping_type: mapping.sourceType === "fixed" ? ("fixed" as const) : ("direct" as const),
fixed_value: mapping.sourceType === "fixed" ? mapping.fixedValue : undefined,
}));
const apiMappings = validMappings.map((mapping) => {
// from_column_name 결정:
// fixed → fixedValue 자체가 저장됨
// conditional → apiField (평가할 API 필드)
// api(direct) → apiField
const fromColumnName =
mapping.sourceType === "fixed" ? mapping.fixedValue : mapping.apiField;
const mappingType: "direct" | "fixed" | "conditional" =
mapping.sourceType === "fixed"
? "fixed"
: mapping.sourceType === "conditional"
? "conditional"
: "direct";
return {
from_connection_type: "restapi" as const,
from_table_name: fromEndpoint,
from_column_name: fromColumnName,
from_api_url: fromApiUrl,
from_api_key: authTokenMode === "direct" ? fromApiKey : "",
from_api_method: fromApiMethod,
from_api_body:
fromApiMethod === "POST" || fromApiMethod === "PUT" || fromApiMethod === "DELETE" ? fromApiBody : undefined,
from_api_param_type: apiParamType !== "none" ? apiParamType : undefined,
from_api_param_name: apiParamType !== "none" ? apiParamName : undefined,
from_api_param_value: apiParamType !== "none" ? apiParamValue : undefined,
from_api_param_source: apiParamType !== "none" ? apiParamSource : undefined,
to_connection_type: toConnection?.type === "internal" ? "internal" : "external",
to_connection_id: toConnection?.type === "internal" ? undefined : toConnection?.id,
to_table_name: toTable,
to_column_name: mapping.dbColumn,
mapping_type: mappingType,
fixed_value: mapping.sourceType === "fixed" ? mapping.fixedValue : undefined,
// conditional 일 때만 룰 객체를 함께 전송 — 백엔드가 JSONB 직렬화 처리
mapping_config:
mapping.sourceType === "conditional" && mapping.conditionalConfig
? mapping.conditionalConfig
: null,
};
});
// 실제 API 호출
try {
@@ -465,7 +620,7 @@ export default function BatchManagementNewPage() {
if (result.success) {
toast.success(result.message || "REST API 배치 설정이 저장되었습니다.");
setTimeout(() => {
openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
openTab({ type: "admin", title: "배치 관리", admin_url: "/admin/automaticMng/batchmngList" });
}, 1000);
} else {
toast.error(result.message || "배치 저장에 실패했습니다.");
@@ -556,7 +711,7 @@ export default function BatchManagementNewPage() {
if (result.success) {
toast.success(result.message || "DB → REST API 배치 설정이 저장되었습니다.");
setTimeout(() => {
openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
openTab({ type: "admin", title: "배치 관리", admin_url: "/admin/automaticMng/batchmngList" });
}, 1000);
} else {
toast.error(result.message || "배치 저장에 실패했습니다.");
@@ -573,10 +728,10 @@ export default function BatchManagementNewPage() {
toast.error("지원하지 않는 배치 타입입니다.");
};
const goBack = () => openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
const goBack = () => openTab({ type: "admin", title: "배치 관리", admin_url: "/admin/automaticMng/batchmngList" });
return (
<div className="mx-auto max-w-5xl space-y-6 p-4 sm:p-6">
<div className="h-full w-full space-y-6 overflow-y-auto p-4 sm:p-6">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
@@ -590,49 +745,48 @@ export default function BatchManagementNewPage() {
</div>
</div>
{/* 배치 타입 선택 */}
<div className="grid grid-cols-2 gap-3">
{batchTypeOptions.map((option) => (
<button
key={option.value}
onClick={() => setBatchType(option.value)}
className={`group relative flex items-center gap-3 rounded-lg border p-4 text-left transition-all ${
batchType === option.value
? "border-primary bg-primary/5 ring-1 ring-primary/30"
: "border-border hover:border-muted-foreground/30 hover:bg-muted/50"
}`}
>
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${batchType === option.value ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"}`}>
{option.value === "restapi-to-db" ? <Globe className="h-5 w-5" /> : <Database className="h-5 w-5" />}
</div>
<div className="min-w-0">
<div className="text-sm font-medium">{option.label}</div>
<div className="text-[11px] text-muted-foreground">{option.description}</div>
</div>
{batchType === option.value && <div className="absolute right-3 top-3 h-2 w-2 rounded-full bg-primary" />}
</button>
))}
</div>
{/* 배치 타입 + 기본 정보 — 한 행으로 통합 (xl+ 한 줄, 그 미만은 stack) */}
<div className="grid grid-cols-1 gap-3 xl:grid-cols-[minmax(28rem,1.4fr)_1fr_1fr_1.5fr]">
{/* 모드 토글 2개 */}
<div className="grid grid-cols-2 gap-3">
{batchTypeOptions.map((option) => (
<button
key={option.value}
onClick={() => setBatchType(option.value)}
className={`group relative flex items-center gap-2 rounded-lg border p-3 text-left transition-all ${
batchType === option.value
? "border-primary bg-primary/5 ring-1 ring-primary/30"
: "border-border hover:border-muted-foreground/30 hover:bg-muted/50"
}`}
>
<div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${batchType === option.value ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"}`}>
{option.value === "restapi-to-db" ? <Globe className="h-4 w-4" /> : <Database className="h-4 w-4" />}
</div>
<div className="min-w-0">
<div className="text-sm font-medium leading-tight">{option.label}</div>
<div className="mt-0.5 text-[10px] leading-tight text-muted-foreground">{option.description}</div>
</div>
{batchType === option.value && <div className="absolute right-2 top-2 h-1.5 w-1.5 rounded-full bg-primary" />}
</button>
))}
</div>
{/* 기본 정보 */}
<div className="space-y-4 rounded-lg border p-4 sm:p-5">
<div className="flex items-center gap-2 text-sm font-medium">
<Eye className="h-4 w-4 text-muted-foreground" />
{/* 배치명 */}
<div className="space-y-1">
<Label htmlFor="batchName" className="text-xs"> <span className="text-destructive">*</span></Label>
<Input id="batchName" value={batchName} onChange={e => setBatchName(e.target.value)} placeholder="배치명" className="h-9 text-sm" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="batchName" className="text-xs"> <span className="text-destructive">*</span></Label>
<Input id="batchName" value={batchName} onChange={e => setBatchName(e.target.value)} placeholder="배치명을 입력하세요" className="h-9 text-sm" />
</div>
<div className="space-y-1.5">
<Label htmlFor="cronSchedule" className="text-xs"> <span className="text-destructive">*</span></Label>
<Input id="cronSchedule" value={cronSchedule} onChange={e => setCronSchedule(e.target.value)} placeholder="0 12 * * *" className="h-9 font-mono text-sm" />
</div>
{/* 실행 스케줄 */}
<div className="space-y-1">
<Label htmlFor="cronSchedule" className="text-xs"> <span className="text-destructive">*</span></Label>
<Input id="cronSchedule" value={cronSchedule} onChange={e => setCronSchedule(e.target.value)} placeholder="0 12 * * *" className="h-9 font-mono text-sm" />
</div>
<div className="space-y-1.5">
{/* 설명 (textarea 한 줄 높이 — 다른 입력과 정렬) */}
<div className="space-y-1">
<Label htmlFor="description" className="text-xs"></Label>
<Textarea id="description" value={description} onChange={e => setDescription(e.target.value)} placeholder="배치에 대한 설명을 입력하세요" rows={2} className="resize-none text-sm" />
<Input id="description" value={description} onChange={e => setDescription(e.target.value)} placeholder="설명 (선택)" className="h-9 text-sm" />
</div>
</div>
@@ -659,125 +813,130 @@ export default function BatchManagementNewPage() {
{/* REST API 설정 (REST API → DB) */}
{batchType === "restapi-to-db" && (
<div className="space-y-4">
{/* API 서버 URL */}
{/* 등록된 연결 선택 — 외부 커넥션 관리에 등록한 REST API 연결을 골라 자동 호출 */}
<div>
<Label htmlFor="fromApiUrl">API URL *</Label>
<Input
id="fromApiUrl"
value={fromApiUrl}
onChange={(e) => setFromApiUrl(e.target.value)}
placeholder="https://api.example.com"
/>
</div>
{/* 인증 토큰 설정 */}
<div>
<Label> (Authorization)</Label>
{/* 토큰 설정 방식 선택 */}
<div className="mt-2 flex gap-4">
<label className="flex cursor-pointer items-center gap-1.5">
<input
type="radio"
name="authTokenMode"
value="direct"
checked={authTokenMode === "direct"}
onChange={() => {
setAuthTokenMode("direct");
setAuthServiceName("");
}}
className="h-3.5 w-3.5"
/>
<span className="text-xs"> </span>
</label>
<label className="flex cursor-pointer items-center gap-1.5">
<input
type="radio"
name="authTokenMode"
value="db"
checked={authTokenMode === "db"}
onChange={() => setAuthTokenMode("db")}
className="h-3.5 w-3.5"
/>
<span className="text-xs">DB에서 </span>
</label>
</div>
{/* 직접 입력 모드 */}
{authTokenMode === "direct" && (
<Input
id="fromApiKey"
value={fromApiKey}
onChange={(e) => setFromApiKey(e.target.value)}
placeholder="Bearer eyJhbGciOiJIUzI1NiIs..."
className="mt-2"
/>
)}
{/* DB 선택 모드 */}
{authTokenMode === "db" && (
<Select
value={authServiceName || "none"}
onValueChange={(value) => setAuthServiceName(value === "none" ? "" : value)}
>
<SelectTrigger className="mt-2">
<SelectValue placeholder="서비스명 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{authServiceNames.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<p className="mt-1 text-xs text-muted-foreground">
{authTokenMode === "direct"
? "API 호출 시 Authorization 헤더에 사용할 토큰을 입력하세요."
: "auth_tokens 테이블에서 선택한 서비스의 최신 토큰을 사용합니다."}
</p>
</div>
{/* 엔드포인트 */}
<div>
<Label htmlFor="fromEndpoint"> *</Label>
<Input
id="fromEndpoint"
value={fromEndpoint}
onChange={(e) => setFromEndpoint(e.target.value)}
placeholder="/api/users"
/>
</div>
{/* HTTP 메서드 */}
<div>
<Label>HTTP </Label>
<Select value={fromApiMethod} onValueChange={(value: any) => setFromApiMethod(value)}>
<SelectTrigger>
<SelectValue />
<Label htmlFor="registeredRestApi" className="flex items-center gap-1.5 text-xs">
<span>🔗 </span>
{rawResponseLoading && (
<RefreshCw className="h-3 w-3 animate-spin text-muted-foreground" />
)}
</Label>
<Select
value={selectedRestApiId}
onValueChange={applyRegisteredRestApi}
>
<SelectTrigger id="registeredRestApi" className="h-9 text-sm">
<SelectValue placeholder="직접 입력 (등록된 연결 사용 안 함)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET ( )</SelectItem>
<SelectItem value="POST">POST ( /)</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
<SelectItem value="manual"> ( )</SelectItem>
{registeredRestApis.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>
<div className="flex items-center gap-2">
<span>{c.connection_name}</span>
<span className="text-[10px] text-muted-foreground">
[{c.auth_type || "none"}]
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{rawResponseError && (
<p className="mt-1 text-[11px] text-destructive">{rawResponseError}</p>
)}
</div>
{/* 데이터 배열 경로 */}
{/* API 서버 URL + HTTP 메서드 — 한 행, URL 이 길어 7:3 비율 */}
<div className="grid grid-cols-[3fr_1fr] gap-3">
<div>
<Label htmlFor="fromApiUrl" className="text-xs">API URL *</Label>
<Input
id="fromApiUrl"
value={fromApiUrl}
onChange={(e) => setFromApiUrl(e.target.value)}
placeholder="https://api.example.com"
className="h-9 text-sm"
/>
</div>
<div>
<Label className="text-xs"></Label>
<Select value={fromApiMethod} onValueChange={(value: any) => setFromApiMethod(value)}>
<SelectTrigger className="h-9 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 엔드포인트 + 데이터 배열 경로 — 한 행, 50:50 */}
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="fromEndpoint" className="text-xs"> *</Label>
<Input
id="fromEndpoint"
value={fromEndpoint}
onChange={(e) => setFromEndpoint(e.target.value)}
placeholder="/api/users"
className="h-9 text-sm"
/>
</div>
<div>
<Label htmlFor="dataArrayPath" className="text-xs"> </Label>
<Input
id="dataArrayPath"
value={dataArrayPath}
onChange={(e) => setDataArrayPath(e.target.value)}
placeholder="resultData (비우면 자동 탐색)"
className="h-9 text-sm"
/>
</div>
</div>
{/* 인증 토큰 — 라디오 + 입력을 한 행으로 압축 */}
<div>
<Label htmlFor="dataArrayPath"> </Label>
<Input
id="dataArrayPath"
value={dataArrayPath}
onChange={(e) => setDataArrayPath(e.target.value)}
placeholder="response (예: data.items, results)"
/>
<p className="mt-1 text-xs text-muted-foreground">
API . .
<br />
예시: response, data.items, result.list
</p>
<Label className="text-xs"> </Label>
<div className="mt-1.5 flex items-center gap-2">
<div className="flex shrink-0 overflow-hidden rounded-md border">
<button
type="button"
onClick={() => { setAuthTokenMode("direct"); setAuthServiceName(""); }}
className={`px-2.5 py-1.5 text-xs ${authTokenMode === "direct" ? "bg-primary text-primary-foreground" : "bg-background hover:bg-muted"}`}
> </button>
<button
type="button"
onClick={() => setAuthTokenMode("db")}
className={`px-2.5 py-1.5 text-xs ${authTokenMode === "db" ? "bg-primary text-primary-foreground" : "bg-background hover:bg-muted"}`}
>DB에서 </button>
</div>
{authTokenMode === "direct" ? (
<Input
id="fromApiKey"
value={fromApiKey}
onChange={(e) => setFromApiKey(e.target.value)}
placeholder="Bearer eyJhbGciOiJIUzI1NiIs..."
className="h-9 flex-1 text-sm"
/>
) : (
<Select value={authServiceName || "none"} onValueChange={(value) => setAuthServiceName(value === "none" ? "" : value)}>
<SelectTrigger className="h-9 flex-1 text-sm">
<SelectValue placeholder="서비스명 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{authServiceNames.map((name) => (
<SelectItem key={name} value={name}>{name}</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
{/* Request Body (POST/PUT/DELETE용) */}
@@ -796,17 +955,20 @@ export default function BatchManagementNewPage() {
</div>
)}
{/* API 파라미터 설정 */}
<div className="space-y-4">
<div className="border-t pt-4">
<Label className="text-base font-medium">API </Label>
<p className="mt-1 text-sm text-muted-foreground"> .</p>
</div>
{/* API 파라미터 설정 — 기본 접힘. 필요할 때만 펼침 */}
<details className="rounded-lg border bg-muted/20 [&[open]>summary>svg]:rotate-90">
<summary className="flex cursor-pointer list-none items-center gap-2 p-3 text-xs font-medium hover:bg-muted/30">
<svg className="h-3 w-3 transition-transform" viewBox="0 0 12 12" fill="currentColor">
<path d="M4 2l4 4-4 4z" />
</svg>
API
<span className="text-[10px] font-normal text-muted-foreground"> / </span>
</summary>
<div className="space-y-3 border-t p-3">
<div>
<Label> </Label>
<Label className="text-xs"> </Label>
<Select value={apiParamType} onValueChange={(value: any) => setApiParamType(value)}>
<SelectTrigger>
<SelectTrigger className="h-9 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -886,7 +1048,8 @@ export default function BatchManagementNewPage() {
)}
</>
)}
</div>
</div>
</details>
{/* API 호출 미리보기 정보 */}
{fromApiUrl && fromEndpoint && (
@@ -1133,7 +1296,10 @@ export default function BatchManagementNewPage() {
{/* 1. 커넥션 선택 - 항상 활성화 */}
<div>
<Label> *</Label>
<Select onValueChange={handleToConnectionChange}>
<Select
value={toConnection ? (toConnection.type === "internal" ? "internal" : String(toConnection.id)) : ""}
onValueChange={handleToConnectionChange}
>
<SelectTrigger>
<SelectValue placeholder="커넥션을 선택하세요" />
</SelectTrigger>
@@ -1153,7 +1319,7 @@ export default function BatchManagementNewPage() {
{/* 2. 테이블 선택 - 커넥션 선택 후 활성화 */}
<div className={toTables.length === 0 ? "pointer-events-none opacity-50" : ""}>
<Label> *</Label>
<Select onValueChange={handleToTableChange} disabled={toTables.length === 0}>
<Select value={toTable} onValueChange={handleToTableChange} disabled={toTables.length === 0}>
<SelectTrigger>
<SelectValue
placeholder={toTables.length === 0 ? "먼저 커넥션을 선택하세요" : "테이블을 선택하세요"}
@@ -1477,30 +1643,30 @@ const RestApiToDbMappingCard = memo(function RestApiToDbMappingCard({
return (
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription>DB API .</CardDescription>
<CardHeader className="px-4 pb-2 pt-3">
<CardTitle className="text-sm"> </CardTitle>
<CardDescription className="text-[11px]">DB API .</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<CardContent className="px-4 pb-3 pt-0">
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{/* 왼쪽: 샘플 데이터 */}
<div className="flex flex-col">
<div className="mb-3 flex h-8 items-center">
<h4 className="text-sm font-semibold"> ( 3)</h4>
<div className="mb-2 flex h-7 items-center">
<h4 className="text-xs font-semibold"> ( 3)</h4>
</div>
{sampleJsonList.length > 0 ? (
<div className="bg-muted/30 h-[360px] overflow-y-auto rounded-lg border p-3">
<div className="space-y-2">
<div className="bg-muted/30 h-[300px] overflow-y-auto rounded-lg border p-2">
<div className="space-y-1.5">
{sampleJsonList.map((json, index) => (
<div key={index} className="bg-background rounded border p-2">
<pre className="font-mono text-xs whitespace-pre-wrap">{json}</pre>
<div key={index} className="bg-background rounded border p-1.5">
<pre className="font-mono text-[11px] whitespace-pre-wrap">{json}</pre>
</div>
))}
</div>
</div>
) : (
<div className="flex h-[360px] items-center justify-center rounded-lg border border-dashed">
<p className="text-muted-foreground text-sm">
<div className="flex h-[300px] items-center justify-center rounded-lg border border-dashed">
<p className="text-muted-foreground text-xs">
API .
</p>
</div>
@@ -1509,39 +1675,39 @@ const RestApiToDbMappingCard = memo(function RestApiToDbMappingCard({
{/* 오른쪽: 매핑 영역 (스크롤) */}
<div className="flex flex-col">
<div className="mb-3 flex h-8 items-center justify-between">
<h4 className="text-sm font-semibold"> </h4>
<Button variant="outline" size="sm" onClick={addMapping} className="h-8 gap-1">
<Plus className="h-4 w-4" />
<div className="mb-2 flex h-7 items-center justify-between">
<h4 className="text-xs font-semibold"> </h4>
<Button variant="outline" size="sm" onClick={addMapping} className="h-7 gap-1 px-2 text-[11px]">
<Plus className="h-3 w-3" />
</Button>
</div>
{mappingList.length === 0 ? (
<div className="flex h-[360px] flex-col items-center justify-center rounded-lg border border-dashed text-center">
<p className="text-muted-foreground text-sm"> .</p>
<Button variant="link" onClick={addMapping} className="mt-2">
<div className="flex h-[300px] flex-col items-center justify-center rounded-lg border border-dashed text-center">
<p className="text-muted-foreground text-xs"> .</p>
<Button variant="link" size="sm" onClick={addMapping} className="mt-1 h-auto text-xs">
</Button>
</div>
) : (
<div className="bg-muted/30 h-[360px] space-y-3 overflow-y-auto rounded-lg border p-3">
<div className="bg-muted/30 h-[300px] space-y-2 overflow-y-auto rounded-lg border p-2">
{mappingList.map((mapping, index) => (
<div key={mapping.id} className="bg-background flex items-center gap-2 rounded-lg border p-3">
<div key={mapping.id} className="bg-background flex items-center gap-1.5 rounded-lg border p-2">
{/* 순서 표시 */}
<div className="bg-primary/10 text-primary flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-medium">
<div className="bg-primary/10 text-primary flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-medium">
{index + 1}
</div>
{/* DB 컬럼 선택 (좌측 - TO) */}
<div className="w-36 shrink-0">
<div className="w-32 shrink-0">
<Select
value={mapping.dbColumn || "none"}
onValueChange={(value) =>
updateMapping(mapping.id, { dbColumn: value === "none" ? "" : value })
}
>
<SelectTrigger className="h-9">
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="DB 컬럼" />
</SelectTrigger>
<SelectContent>
@@ -1563,40 +1729,49 @@ const RestApiToDbMappingCard = memo(function RestApiToDbMappingCard({
</div>
{/* 화살표 */}
<ArrowLeft className="text-muted-foreground h-4 w-4 shrink-0" />
<ArrowLeft className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
{/* 소스 타입 선택 */}
<div className="w-24 shrink-0">
<Select
value={mapping.sourceType}
onValueChange={(value: "api" | "fixed") =>
onValueChange={(value: "api" | "fixed" | "conditional") =>
updateMapping(mapping.id, {
sourceType: value,
apiField: value === "fixed" ? "" : mapping.apiField,
fixedValue: value === "api" ? "" : mapping.fixedValue,
// 모드 전환 시 입력값 정리
apiField:
value === "api" || value === "conditional"
? mapping.apiField
: "",
fixedValue: value === "fixed" ? mapping.fixedValue : "",
conditionalConfig:
value === "conditional"
? mapping.conditionalConfig || emptyConditionalConfig()
: mapping.conditionalConfig,
})
}
>
<SelectTrigger className="h-9">
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="api">API </SelectItem>
<SelectItem value="fixed"></SelectItem>
<SelectItem value="conditional"> </SelectItem>
</SelectContent>
</Select>
</div>
{/* API 필드 선택 또는 고정값 입력 (우측 - FROM) */}
{/* API 필드 선택 / 고정값 입력 / 조건 변환 (우측 - FROM) */}
<div className="min-w-0 flex-1">
{mapping.sourceType === "api" ? (
{mapping.sourceType === "api" && (
<Select
value={mapping.apiField || "none"}
onValueChange={(value) =>
updateMapping(mapping.id, { apiField: value === "none" ? "" : value })
}
>
<SelectTrigger className="h-9">
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="API 필드" />
</SelectTrigger>
<SelectContent>
@@ -1606,7 +1781,7 @@ const RestApiToDbMappingCard = memo(function RestApiToDbMappingCard({
<div className="flex items-center gap-2">
<span>{field}</span>
{firstSample && firstSample[field] !== undefined && (
<span className="text-muted-foreground text-xs">
<span className="text-muted-foreground text-[10px]">
(: {String(firstSample[field]).substring(0, 15)}
{String(firstSample[field]).length > 15 ? "..." : ""})
</span>
@@ -1616,12 +1791,26 @@ const RestApiToDbMappingCard = memo(function RestApiToDbMappingCard({
))}
</SelectContent>
</Select>
) : (
)}
{mapping.sourceType === "fixed" && (
<Input
value={mapping.fixedValue}
onChange={(e) => updateMapping(mapping.id, { fixedValue: e.target.value })}
placeholder="고정값 입력"
className="h-9"
className="h-7 text-xs"
/>
)}
{mapping.sourceType === "conditional" && (
<ConditionalEditor
evaluateField={mapping.apiField}
fieldOptions={fromApiFields}
config={mapping.conditionalConfig || emptyConditionalConfig()}
onEvaluateFieldChange={(v) =>
updateMapping(mapping.id, { apiField: v })
}
onConfigChange={(cfg) =>
updateMapping(mapping.id, { conditionalConfig: cfg })
}
/>
)}
</div>
@@ -1631,9 +1820,9 @@ const RestApiToDbMappingCard = memo(function RestApiToDbMappingCard({
variant="ghost"
size="icon"
onClick={() => removeMapping(mapping.id)}
className="text-muted-foreground hover:text-destructive h-8 w-8 shrink-0"
className="text-muted-foreground hover:text-destructive h-6 w-6 shrink-0"
>
<Trash2 className="h-4 w-4" />
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
+3 -3
View File
@@ -3,10 +3,10 @@
// INVYONE 스튜디오 진입 페이지 (templates 테이블 기반)
// - 템플릿 목록 + 새 템플릿 생성 → templates 테이블 CRUD
// - URL ?id=<template_id> 로 바로 진입
// - ScreenDesigner 는 template_id 를 통해 templates API 로 저장/로드
// - InvyoneStudio 는 template_id 를 통해 templates API 로 저장/로드
import { Suspense, useState, useEffect, useCallback } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import ScreenDesigner from "@/components/screen/ScreenDesigner";
import InvyoneStudio from "@/components/screen/InvyoneStudio";
import type { ScreenDefinition } from "@/types/screen";
import { getTemplateList, deleteTemplate } from "@/lib/api/template";
import { createTemplate } from "@/lib/utils/templateAdapter";
@@ -442,7 +442,7 @@ function BuilderInner() {
return (
<div className="ide-builder h-[calc(100vh-4rem)] w-full overflow-hidden bg-background">
<ScreenDesigner
<InvyoneStudio
selectedScreen={selectedScreen}
onBackToList={handleBackToList}
onScreenUpdate={(updatedFields) => {
@@ -1,115 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Link2, Layers, Filter, FormInput, Ban, Tags, Columns } from "lucide-react";
// 탭별 컴포넌트
import CascadingRelationsTab from "./tabs/CascadingRelationsTab";
import AutoFillTab from "./tabs/AutoFillTab";
import HierarchyTab from "./tabs/HierarchyTab";
import ConditionTab from "./tabs/ConditionTab";
import MutualExclusionTab from "./tabs/MutualExclusionTab";
import CategoryValueCascadingTab from "./tabs/CategoryValueCascadingTab";
import HierarchyColumnTab from "./tabs/HierarchyColumnTab";
export default function CascadingManagementPage() {
const searchParams = useSearchParams();
const router = useRouter();
const [activeTab, setActiveTab] = useState("relations");
// URL 쿼리 파라미터에서 탭 설정
useEffect(() => {
const tab = searchParams.get("tab");
if (tab && ["relations", "hierarchy", "condition", "autofill", "exclusion", "category-value", "hierarchy-column"].includes(tab)) {
setActiveTab(tab);
}
}, [searchParams]);
// 탭 변경 시 URL 업데이트
const handleTabChange = (value: string) => {
setActiveTab(value);
const url = new URL(window.location.href);
url.searchParams.set("tab", value);
router.replace(url.pathname + url.search);
};
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground">
, , , .
</p>
</div>
{/* 탭 네비게이션 */}
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
<TabsList className="grid w-full grid-cols-6">
<TabsTrigger value="relations" className="gap-2">
<Link2 className="h-4 w-4" />
<span className="hidden sm:inline">2 </span>
<span className="sm:hidden"></span>
</TabsTrigger>
<TabsTrigger value="hierarchy" className="gap-2">
<Layers className="h-4 w-4" />
<span className="hidden sm:inline"> </span>
<span className="sm:hidden"></span>
</TabsTrigger>
<TabsTrigger value="condition" className="gap-2">
<Filter className="h-4 w-4" />
<span className="hidden sm:inline"> </span>
<span className="sm:hidden"></span>
</TabsTrigger>
<TabsTrigger value="autofill" className="gap-2">
<FormInput className="h-4 w-4" />
<span className="hidden sm:inline"> </span>
<span className="sm:hidden"></span>
</TabsTrigger>
<TabsTrigger value="exclusion" className="gap-2">
<Ban className="h-4 w-4" />
<span className="hidden sm:inline"> </span>
<span className="sm:hidden"></span>
</TabsTrigger>
<TabsTrigger value="category-value" className="gap-2">
<Tags className="h-4 w-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
</TabsTrigger>
</TabsList>
{/* 탭 컨텐츠 */}
<div className="mt-6">
<TabsContent value="relations">
<CascadingRelationsTab />
</TabsContent>
<TabsContent value="hierarchy">
<HierarchyTab />
</TabsContent>
<TabsContent value="condition">
<ConditionTab />
</TabsContent>
<TabsContent value="autofill">
<AutoFillTab />
</TabsContent>
<TabsContent value="exclusion">
<MutualExclusionTab />
</TabsContent>
<TabsContent value="category-value">
<CategoryValueCascadingTab />
</TabsContent>
</div>
</Tabs>
</div>
</div>
);
}
@@ -1,687 +0,0 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Separator } from "@/components/ui/separator";
import {
Check,
ChevronsUpDown,
Plus,
Pencil,
Trash2,
Search,
RefreshCw,
ArrowRight,
X,
GripVertical,
} from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { cn } from "@/lib/utils";
import { cascadingAutoFillApi, AutoFillGroup, AutoFillMapping } from "@/lib/api/cascadingAutoFill";
import { tableManagementApi } from "@/lib/api/tableManagement";
interface TableColumn {
columnName: string;
columnLabel?: string;
dataType?: string;
}
export default function AutoFillTab() {
// 목록 상태
const [groups, setGroups] = useState<AutoFillGroup[]>([]);
const [loading, setLoading] = useState(true);
const [searchText, setSearchText] = useState("");
// 모달 상태
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [editingGroup, setEditingGroup] = useState<AutoFillGroup | null>(null);
const [deletingGroupCode, setDeletingGroupCode] = useState<string | null>(null);
// 테이블/컬럼 목록
const [tableList, setTableList] = useState<Array<{ tableName: string; displayName?: string }>>([]);
const [masterColumns, setMasterColumns] = useState<TableColumn[]>([]);
// 폼 데이터
const [formData, setFormData] = useState({
groupName: "",
description: "",
masterTable: "",
masterValueColumn: "",
masterLabelColumn: "",
isActive: "Y",
});
// 매핑 데이터
const [mappings, setMappings] = useState<AutoFillMapping[]>([]);
// 테이블 Combobox 상태
const [tableComboOpen, setTableComboOpen] = useState(false);
// 목록 로드
const loadGroups = useCallback(async () => {
setLoading(true);
try {
const response = await cascadingAutoFillApi.getGroups();
if (response.success && response.data) {
setGroups(response.data);
}
} catch (error) {
console.error("그룹 목록 로드 실패:", error);
showErrorToast("그룹 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." });
} finally {
setLoading(false);
}
}, []);
// 테이블 목록 로드
const loadTableList = useCallback(async () => {
try {
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
setTableList(response.data);
}
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
}
}, []);
// 테이블 컬럼 로드
const loadColumns = useCallback(async (tableName: string) => {
if (!tableName) {
setMasterColumns([]);
return;
}
try {
const response = await tableManagementApi.getColumnList(tableName);
if (response.success && response.data?.columns) {
setMasterColumns(
response.data.columns.map((col: any) => ({
columnName: col.columnName || col.column_name,
columnLabel: col.columnLabel || col.column_label || col.columnName,
dataType: col.dataType || col.data_type,
})),
);
}
} catch (error) {
console.error("컬럼 목록 로드 실패:", error);
setMasterColumns([]);
}
}, []);
useEffect(() => {
loadGroups();
loadTableList();
}, [loadGroups, loadTableList]);
// 테이블 변경 시 컬럼 로드
useEffect(() => {
if (formData.masterTable) {
loadColumns(formData.masterTable);
}
}, [formData.masterTable, loadColumns]);
// 필터된 목록
const filteredGroups = groups.filter(
(g) =>
g.groupCode.toLowerCase().includes(searchText.toLowerCase()) ||
g.groupName.toLowerCase().includes(searchText.toLowerCase()) ||
g.masterTable?.toLowerCase().includes(searchText.toLowerCase()),
);
// 모달 열기 (생성)
const handleOpenCreate = () => {
setEditingGroup(null);
setFormData({
groupName: "",
description: "",
masterTable: "",
masterValueColumn: "",
masterLabelColumn: "",
isActive: "Y",
});
setMappings([]);
setMasterColumns([]);
setIsModalOpen(true);
};
// 모달 열기 (수정)
const handleOpenEdit = async (group: AutoFillGroup) => {
setEditingGroup(group);
// 상세 정보 로드
const detailResponse = await cascadingAutoFillApi.getGroupDetail(group.groupCode);
if (detailResponse.success && detailResponse.data) {
const detail = detailResponse.data;
// 컬럼 먼저 로드
if (detail.masterTable) {
await loadColumns(detail.masterTable);
}
setFormData({
groupCode: detail.groupCode,
groupName: detail.groupName,
description: detail.description || "",
masterTable: detail.masterTable,
masterValueColumn: detail.masterValueColumn,
masterLabelColumn: detail.masterLabelColumn || "",
isActive: detail.isActive || "Y",
});
// 매핑 데이터 변환 (snake_case → camelCase)
const convertedMappings = (detail.mappings || []).map((m: any) => ({
sourceColumn: m.source_column || m.sourceColumn,
targetField: m.target_field || m.targetField,
targetLabel: m.target_label || m.targetLabel || "",
isEditable: m.is_editable || m.isEditable || "Y",
isRequired: m.is_required || m.isRequired || "N",
defaultValue: m.default_value || m.defaultValue || "",
sortOrder: m.sort_order || m.sortOrder || 0,
}));
setMappings(convertedMappings);
}
setIsModalOpen(true);
};
// 삭제 확인
const handleDeleteConfirm = (groupCode: string) => {
setDeletingGroupCode(groupCode);
setIsDeleteDialogOpen(true);
};
// 삭제 실행
const handleDelete = async () => {
if (!deletingGroupCode) return;
try {
const response = await cascadingAutoFillApi.deleteGroup(deletingGroupCode);
if (response.success) {
toast.success("자동 입력 그룹이 삭제되었습니다.");
loadGroups();
} else {
toast.error(response.error || "삭제에 실패했습니다.");
}
} catch (error) {
toast.error("삭제 중 오류가 발생했습니다.");
} finally {
setIsDeleteDialogOpen(false);
setDeletingGroupCode(null);
}
};
// 저장
const handleSave = async () => {
// 유효성 검사
if (!formData.groupName || !formData.masterTable || !formData.masterValueColumn) {
toast.error("필수 항목을 모두 입력해주세요.");
return;
}
try {
const saveData = {
...formData,
mappings,
};
let response;
if (editingGroup) {
response = await cascadingAutoFillApi.updateGroup(editingGroup.groupCode!, saveData);
} else {
response = await cascadingAutoFillApi.createGroup(saveData);
}
if (response.success) {
toast.success(editingGroup ? "수정되었습니다." : "생성되었습니다.");
setIsModalOpen(false);
loadGroups();
} else {
toast.error(response.error || "저장에 실패했습니다.");
}
} catch (error) {
showErrorToast("자동입력 설정 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
}
};
// 매핑 추가
const handleAddMapping = () => {
setMappings([
...mappings,
{
sourceColumn: "",
targetField: "",
targetLabel: "",
isEditable: "Y",
isRequired: "N",
defaultValue: "",
sortOrder: mappings.length + 1,
},
]);
};
// 매핑 삭제
const handleRemoveMapping = (index: number) => {
setMappings(mappings.filter((_, i) => i !== index));
};
// 매핑 수정
const handleMappingChange = (index: number, field: keyof AutoFillMapping, value: any) => {
const updated = [...mappings];
updated[index] = { ...updated[index], [field]: value };
setMappings(updated);
};
return (
<div className="space-y-6">
{/* 검색 및 액션 */}
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="relative flex-1">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="그룹 코드, 이름, 테이블명으로 검색..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="pl-10"
/>
</div>
<Button variant="outline" onClick={loadGroups}>
<RefreshCw className="mr-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
{/* 목록 */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle> </CardTitle>
<CardDescription>
. ( {filteredGroups.length})
</CardDescription>
</div>
<Button onClick={handleOpenCreate}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-8">
<RefreshCw className="h-6 w-6 animate-spin" />
<span className="ml-2"> ...</span>
</div>
) : filteredGroups.length === 0 ? (
<div className="text-muted-foreground py-8 text-center">
{searchText ? "검색 결과가 없습니다." : "등록된 자동 입력 그룹이 없습니다."}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead> </TableHead>
<TableHead></TableHead>
<TableHead> </TableHead>
<TableHead> </TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredGroups.map((group) => (
<TableRow key={group.groupCode}>
<TableCell className="font-mono text-sm">{group.groupCode}</TableCell>
<TableCell className="font-medium">{group.groupName}</TableCell>
<TableCell className="text-muted-foreground">{group.masterTable}</TableCell>
<TableCell>
<Badge variant="secondary">{group.mappingCount || 0}</Badge>
</TableCell>
<TableCell>
<Badge variant={group.isActive === "Y" ? "default" : "outline"}>
{group.isActive === "Y" ? "활성" : "비활성"}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(group)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => handleDeleteConfirm(group.groupCode)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* 생성/수정 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingGroup ? "자동 입력 그룹 수정" : "자동 입력 그룹 생성"}</DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* 기본 정보 */}
<div className="space-y-4">
<h3 className="text-sm font-semibold"> </h3>
<div className="space-y-2">
<Label> *</Label>
<Input
value={formData.groupName}
onChange={(e) => setFormData({ ...formData, groupName: e.target.value })}
placeholder="예: 고객사 정보 자동입력"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="이 자동 입력 그룹에 대한 설명"
rows={2}
/>
</div>
<div className="flex items-center space-x-2">
<Switch
checked={formData.isActive === "Y"}
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked ? "Y" : "N" })}
/>
<Label></Label>
</div>
</div>
<Separator />
{/* 마스터 테이블 설정 */}
<div className="space-y-4">
<h3 className="text-sm font-semibold"> </h3>
<p className="text-muted-foreground text-xs">
.
</p>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label> *</Label>
<Popover open={tableComboOpen} onOpenChange={setTableComboOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableComboOpen}
className="h-10 w-full justify-between text-sm"
>
{formData.masterTable
? tableList.find((t) => t.tableName === formData.masterTable)?.displayName ||
formData.masterTable
: "테이블 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="테이블명 또는 라벨로 검색..." className="text-sm" />
<CommandList className="max-h-[300px] overflow-y-auto overscroll-contain">
<CommandEmpty className="text-sm"> .</CommandEmpty>
<CommandGroup>
{tableList.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.tableName} ${table.displayName || ""}`}
onSelect={() => {
setFormData({
...formData,
masterTable: table.tableName,
masterValueColumn: "",
masterLabelColumn: "",
});
setTableComboOpen(false);
}}
className="text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.masterTable === table.tableName ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{table.displayName || table.tableName}</span>
{table.displayName && table.displayName !== table.tableName && (
<span className="text-muted-foreground text-xs">{table.tableName}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label> *</Label>
<Select
value={formData.masterValueColumn}
onValueChange={(value) => setFormData({ ...formData, masterValueColumn: value })}
disabled={!formData.masterTable}
>
<SelectTrigger>
<SelectValue placeholder="값 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{masterColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label> </Label>
<Select
value={formData.masterLabelColumn}
onValueChange={(value) => setFormData({ ...formData, masterLabelColumn: value })}
disabled={!formData.masterTable}
>
<SelectTrigger>
<SelectValue placeholder="라벨 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{masterColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<Separator />
{/* 필드 매핑 */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold"> </h3>
<p className="text-muted-foreground text-xs">
.
</p>
</div>
<Button variant="outline" size="sm" onClick={handleAddMapping}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
{mappings.length === 0 ? (
<div className="text-muted-foreground rounded-lg border border-dashed py-8 text-center text-sm">
. "매핑 추가" .
</div>
) : (
<div className="space-y-3">
{mappings.map((mapping, index) => (
<div key={index} className="bg-muted/30 flex items-center gap-3 rounded-lg border p-3">
<GripVertical className="text-muted-foreground h-4 w-4 cursor-move" />
{/* 소스 컬럼 */}
<div className="w-40">
<Select
value={mapping.sourceColumn}
onValueChange={(value) => handleMappingChange(index, "sourceColumn", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="소스 컬럼" />
</SelectTrigger>
<SelectContent>
{masterColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<ArrowRight className="text-muted-foreground h-4 w-4" />
{/* 타겟 필드 */}
<div className="flex-1">
<Input
value={mapping.targetField}
onChange={(e) => handleMappingChange(index, "targetField", e.target.value)}
placeholder="타겟 필드명 (예: contact_name)"
className="h-8 text-xs"
/>
</div>
{/* 타겟 라벨 */}
<div className="w-28">
<Input
value={mapping.targetLabel || ""}
onChange={(e) => handleMappingChange(index, "targetLabel", e.target.value)}
placeholder="라벨"
className="h-8 text-xs"
/>
</div>
{/* 옵션 */}
<div className="flex items-center gap-2">
<div className="flex items-center space-x-1">
<Checkbox
id={`editable-${index}`}
checked={mapping.isEditable === "Y"}
onCheckedChange={(checked) => handleMappingChange(index, "isEditable", checked ? "Y" : "N")}
/>
<Label htmlFor={`editable-${index}`} className="text-xs">
</Label>
</div>
<div className="flex items-center space-x-1">
<Checkbox
id={`required-${index}`}
checked={mapping.isRequired === "Y"}
onCheckedChange={(checked) => handleMappingChange(index, "isRequired", checked ? "Y" : "N")}
/>
<Label htmlFor={`required-${index}`} className="text-xs">
</Label>
</div>
</div>
{/* 삭제 버튼 */}
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleRemoveMapping(index)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
</Button>
<Button onClick={handleSave}>{editingGroup ? "수정" : "생성"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive hover:bg-destructive">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More