176 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
johngreen aeddd7dc2a Merge pull request #11 - perf(CI): standalone output
Build & Deploy to K8s / build-and-deploy (push) Successful in 6m41s
johngreen -> main: frontend Dockerfile standalone 활용. 빌드 21m -> ~9m
2026-05-13 07:45:22 +00:00
johngreen 5fdd1c67b1 perf(CI): frontend build runner stage 를 Next.js standalone output 활용으로 전환
배경:
- frontend build 의 가장 큰 시간 소비 = runner stage 의 COPY node_modules (12분 16초)
- 전체 21분 34초 중 57%
- next.config.mjs 의 output: "standalone" 가 prod 빌드에서 이미 활성 상태였으나, Dockerfile 의 runner stage 가 .next 통째 + node_modules 통째를 COPY 하느라 standalone 결과물 미활용

조치:
- runner stage 재작성:
  - .next 전체 → .next/standalone (server.js + 실제 사용 node_modules)
  - .next/static 별도 COPY (standalone 가 자동 포함 안 함)
  - public 별도 COPY (standalone 가 자동 포함 안 함)
  - node_modules 통째 COPY 제거 (standalone 가 알아서 포함)
  - package.json COPY 제거 (server.js 직접 실행)
  - CMD: npm start → node server.js

검증:
- frontend 에 dynamic require/import 0건 (정적 import 만) → standalone 의존성 추적 정확
- prisma 가 package.json 에 있으나 코드 import 0건 → 자연 제외, 추가 설정 불필요

예상 효과:
- 빌드 시간 21m 34s → 약 9분 (12분 단축, 57% 감소)
- 이미지 크기 약 1GB → 약 300MB (70% 감소)
- pull 시간 단축
- runtime memory footprint 감소

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 16:44:36 +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
johngreen 0199d1624b Merge pull request #10 - fix(k8s): ConfigMap sslmode=disable
Build & Deploy to K8s / build-and-deploy (push) Successful in 26m24s
johngreen → main: ConfigMap SPRING_DATASOURCE_URL 에 sslmode=disable 영구 추가
2026-05-13 07:14:55 +00:00
johngreen b3f955d97d fix(k8s): ConfigMap SPRING_DATASOURCE_URL 에 sslmode=disable 추가
PR #9 deploy 단계 실패의 후속 처리.

배경:
- 운영 cluster 의 backend-spring deployment 에 누군가 직접 sslmode=disable 박은 value override 가 있었음 (env[2] SPRING_DATASOURCE_URL value + valueFrom 동시 존재 → k8s reject)
- PR #9 머지 후 수동으로 cluster 의 ConfigMap 에 sslmode=disable patch + deployment 재생성 + 새 image rollout 완료
- 다만 git 의 k8s/configmap.yaml 은 sslmode 없는 상태 → 다음 머지 시 workflow 가 ConfigMap 을 git 값으로 덮어쓰면 sslmode 다시 빠짐 → backend pod 가 SSL 시도 후 DB 연결 실패 가능

조치:
- k8s/configmap.yaml line 9 의 SPRING_DATASOURCE_URL 에 ?sslmode=disable 영구 추가
- 운영 postgres 가 SSL 강제 안 하는 환경이라 disable 가 안전
- cluster 와 git 의 state 일치

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 16:14:28 +09:00
johngreen ae899a3589 Merge pull request #9 — refactor(테이블타입): 3-layer 분리 + CI OOM fix
Build & Deploy to K8s / build-and-deploy (push) Failing after 24m30s
johngreen → main: 테이블 타입 관리 3-layer 분리 (DB 12 유지 / UI 8 한정 / widget variant) + backend 백스톱 + CI OOM fix
2026-05-13 06:32:16 +00:00
johngreen 43b0455364 fix(CI): frontend build OOM 방지 — NODE_OPTIONS=--max-old-space-size=4096
main 의 최근 8연속 build 실패 (run 113~120) 원인이 docker build 단계의 OOM Killed.

진단:
- wace 호스트 32GB / available 24GB 충분, 시스템 OOM 기록 없음
- act_runner systemd MemoryMax=infinity, config container.options=null (제한 없음)
- 그러나 Next.js build V8 heap spike (5-8GB+) 가 다른 30개 동시 가동 서비스 (k3s/mailu/nextcloud/mattermost 등) 와 충돌
- Committed_AS 21.6 GB / Limit 24.8 GB 한계 — page touch 시 oom-killer 가 build process kill
- 결과: 28분 빌드 후 Killed → 8연속 main 배포 실패

조치:
- builder stage 에 NODE_OPTIONS=--max-old-space-size=4096 명시 → V8 heap 4GB 로 cap
- build process 가 알아서 절제 → OOM killer 트리거 안 됨

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:24:25 +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
johngreen 574319811c refactor(테이블타입): backend INSERT/UPDATE 8개 validate 백스톱
- TableManagementService.normalizeInputType(value, context) 오버로드 — user-insert/user-update-type 만 8개 검증, user-update-other/system-normalize 는 skip
- TableManagementService.updateColumnSettings: payload 의 input_type 키 존재 여부로 context 분기 (input_type 자체 변경 vs 다른 속성 변경)
- DdlService.addColumn / saveColumnMetadata: convertToInputType 결과를 USER_SELECTABLE_INPUT_TYPES (8개) 와 대조, 외이면 IllegalArgumentException

mapper XML 5곳 (categoryTree / entityJoin / tableCategoryValue / screenManagement / tableManagement / entityReference) 무변경 — READ 경로 12개 그대로.

spec: .omc/specs/deep-dive-table-type-storage-ui-separation.md (v3.2 §6.3)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:43:41 +09:00
johngreen 8f92fb2368 refactor(테이블타입): 3-layer 분리 — DB 12개 유지, UI 8개 한정, widget variant
- input-type-mapping.ts: BaseInputType 10개 → UserSelectableInputType 8개 (박창현 image 2). vexplor_rps INPUT_TYPE_DETAIL_TYPES 포팅, select/checkbox/radio variant 를 code base 로 흡수
- input-types.ts: USER_SELECTABLE_INPUT_TYPE_ORDER/LABELS re-export (InputType 12개는 그대로)
- getDetailType.ts (신규): getWidgetVariants / getDefaultWidgetVariant helper
- 드롭다운 호출처 7개 8개 제한: ColumnDetailPanel, AddColumnModal, ColumnDefinitionTable, tableMngList/page.tsx, TableSettingModal, TypeOverviewStrip, types.ts
- ColumnDetailPanel: Legacy row 드롭다운 disabled + v5-glow-sm Alert 배너
- backward shim: BaseInputType / BASE_INPUT_TYPE_OPTIONS / getBaseInputType 등 V2/Properties/DetailSettingsPanel 호환

운영 DB 96.6% 가 이미 8개 안 (V0, 35,316 row). DB zero touch, mapper 5곳 보호.

spec: .omc/specs/deep-dive-table-type-storage-ui-separation.md (v3.2)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:43:26 +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
johngreen 5c0dca004b Merge fix(반응형): 페이지네이션 잘림 픽스
Build & Deploy to K8s / build-and-deploy (push) Failing after 31m59s
2026-05-13 09:41:44 +09:00
johngreen acbd61e25a fix(반응형): 페이지네이션 잘림 — viewport 하드코딩 → flex-1 min-h-0
기존: scrollContainer 모드의 max-h-[calc(100vh-280px)] 가 viewport 기준 하드코딩이라 페이지 헤더+툴바 합산이 280px 보다 클 때 카드 영역이 viewport 끝까지 차지 → 페이지네이션이 부모 overflow-hidden 에 잘림.

신: outer wrapper 에 flex flex-col min-h-0 flex-1, 내부 테이블/카드에 min-h-0 flex-1 overflow-y-auto. 부모 flex-col 의 남는 공간만 차지 → 페이지네이션 등 형제는 자기 자연 height 유지.

로딩 스켈레톤 동일 패턴 적용.
2026-05-13 09:41:42 +09:00
johngreen dc77c07cc4 Merge refactor(반응형): container query 전환
Build & Deploy to K8s / build-and-deploy (push) Failing after 28m28s
2026-05-13 08:43:16 +09:00
johngreen 6d5ca2f23a refactor(반응형): ResponsiveDataView 를 container query 기반으로 전환
기존: viewport 기준 (lg:hidden, sm:grid-cols-2) — 사이드바 펼친 상태에서 콘텐츠 영역의 실제 width 와 무관하게 동작 → 좁은 영역에 2열 카드가 들어가 카드가 잘려보이는 문제

신: @container 기반 — 컴포넌트가 자기 부모 컨테이너 width 에 반응
- 컨테이너 < 32rem (512px): 카드 1열
- 32~48rem (512~768px): 카드 2열
- ≥ 48rem (768px): 데스크톱 테이블

Tailwind v4 의 first-class container query 활용 (별도 플러그인 불필요). 데스크톱 테이블의 viewport 기준 max-height 스크롤은 유지.

근거: 2026 베스트프랙티스 — page layout=media query, 컴포넌트=container query (LogRocket / NN-Group / Tailwind v4 가이드).
2026-05-13 08:43:13 +09:00
johngreen 1ba310236c Merge fix(반응형): 모바일 width 카드/이력 모달 스크롤
Build & Deploy to K8s / build-and-deploy (push) Failing after 29m24s
2026-05-13 08:09:01 +09:00
johngreen 0d5d1fe10d fix(반응형): 모바일 width 에서 카드 뷰 + 이력 모달 스크롤 누락
- ResponsiveDataView: 모바일 카드 뷰 (lg 미만) + 카드 스켈레톤에 scrollContainer 모드의 max-h + overflow-y-auto 적용. 데스크톱 테이블만 적용돼 있던 누락 픽스
- TableHistoryModal: timeline/detail 탭의 ScrollArea 고정 h-[500px] → 모바일 h-[300px] sm:h-[500px] 적응형. DialogContent max-h-[90vh] 와 충돌 방지

증상: 브라우저 width 좁아질 때 카드 그리드/이력 타임라인이 viewport 너머로 잘리고 스크롤 안 됨.
2026-05-13 08:08:58 +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
johngreen 3dbc2107d8 Merge fix(비번초기화): 키 불일치 + 입력값 무시 픽스
Build & Deploy to K8s / build-and-deploy (push) Failing after 11m15s
2026-05-12 19:28:02 +09:00
johngreen 3eeb0764bf fix(비번초기화): 키 불일치 + 입력값 무시 픽스
- Frontend: body 키를 snake_case (user_id/new_password) 로 변환
- Controller: new_password 도 추출해서 service 에 전달
- Service: 2-arg 오버로드 추가, newPassword 입력값 사용 (blank 일 때만 Welcome1! fallback), userId null/blank 시 IllegalArgumentException

증상: 사용자관리에서 비밀번호 초기화 modal 입력 → backend 가 user_id=null 로 SQL 실행 (0행) + newPassword 무시 후 항상 Welcome1! 로 덮어쓰기.
2026-05-12 19:27:44 +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
johngreen 7706403caa Merge fix(대무자): insert date CAST
Build & Deploy to K8s / build-and-deploy (push) Failing after 11m19s
2026-05-12 18:23:51 +09:00
johngreen 3c24956efd fix(대무자): insert SQL 의 start_date/end_date 도 CAST AS DATE 추가
insertSubstitute 공백 이 char varying 으로 전달되어 DATE 컴럼 에 ERROR. checkOverlap, updateSubstitute 처럼 CAST(#{...} AS DATE) 로 일관되게 수정.
2026-05-12 18:23:49 +09:00
johngreen a7683d4d0e Merge fix(대무자): Select candidates data shape
Build & Deploy to K8s / build-and-deploy (push) Failing after 18m41s
2026-05-12 18:10:09 +09:00
johngreen c3e5d7fc1b fix(대무자): Select 후보 목록 - getUserList 응답 shape 호환
SubstituteSection 의 loadCandidates 가 res.data.list 를 가정했지만
getUserList(/api/admin/users) 응답은 { data: [...] } 형태 (data 가 list 자체).
결과로 모든 select 가 '지정 가능한 사용자가 없습니다' 로 표시됐음.

Array.isArray(res.data) 와 res.data.list 둘 다 fallback 으로 처리.
2026-05-12 18:10:07 +09:00
johngreen 33a245e4e8 Merge pull request 'fix(대무자): COMPANY_ADMIN + SQL + UI select' from johngreen into main
Build & Deploy to K8s / build-and-deploy (push) Failing after 3m24s
2026-05-12 17:02:44 +09:00
johngreen c4a62b7e35 fix(대무자): COMPANY_ADMIN 권한 허용 + 결재함 SQL 컬럼 오타 fix + UI 셀렉트 개선
운영 QA 에서 발견된 3가지 결함을 한 번에 수정.

1. SubstituteController.java:56 / SubstituteService.java:242 (requireAdmin)
   - role 비교에서 "COMPANY_ADMIN" 누락 → 운영 admin 이 대무자 지정 시 항상 403.
   - 운영 회사 admin 의 user_type 은 COMPANY_ADMIN 이 표준 (AdminAccountCreator 가 그렇게 생성).
   - "ADMIN" / "SUPER_ADMIN" 외 "COMPANY_ADMIN" 도 허용.

2. mapper/approval.xml (selectMyRequests, selectMyPendingLines)
   - ORDER BY / SELECT 의 R.CREATED_DATE 가 잘못된 컬럼명 (APPROVAL_REQUESTS 실제: created_at).
   - 결재함 /api/approval/my-pending, /api/approval/requests 가 항상 500.
   - 3군데 R.CREATED_DATE → R.CREATED_AT.

3. SubstituteSection.tsx
   - 대무자 ID 를 직접 타이핑하던 input 을 Select 로 교체.
   - getUserList 로 같은 회사 활성 사용자 목록 로드, 본인 + SUPER_ADMIN + 비활성 자동 제외.
   - 다이얼로그 열 때 한 번만 load (openDialog 시 loadCandidates).
   - 빈 결과/로딩 placeholder 처리.
2026-05-12 17:02:15 +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
hjjeong ffa6799053 Merge pull request 'Merge branch 'main' into hjjeong' (#8) from hjjeong into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m35s
Reviewed-on: #8
2026-05-12 02:48:19 +00:00
hjjeong 7315603f0f Merge branch 'main' into hjjeong
# Conflicts:
#	backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java
2026-05-12 11:42:17 +09:00
hjjeong 8bdc9a958f feat(favorites): 사용자별 메뉴 즐겨찾기
사이드바 leaf 메뉴 옆 별표로 토글하고, 사이드바 최상단 '즐겨찾기'
섹션에 등록된 메뉴를 모아 보여준다. 테넌트 DB 별로 격리.

- V020 + StartupSchemaMigrator: USER_MENU_FAVORITES (USER_ID,
  MENU_OBJID UNIQUE) 메타·테넌트 DB 동기 멱등 생성
- 백엔드: GET/POST /api/favorites/menus, DELETE
  /api/favorites/menus/{menuObjid} — FavoritesController/Service
  + mapper/favorites.xml (MENU_INFO JOIN)
- 프론트: favoritesAPI 클라이언트 + FavoritesContext (낙관적
  toggle/롤백) + (main)/layout 에 Provider 마운트
- AppLayout: leaf/sub-item 옆 Star 토글 (등록 시 primary 컬러,
  미등록 시 0.35 dimmed) + 사이드바 최상단 favorites 섹션. uiMenus
  의 leaf 평탄화 결과를 isFavorite 으로 필터링해 권한 필터/메뉴
  클릭 핸들러를 그대로 재사용

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:29:18 +09:00
johngreen 6561aad7ef Merge pull request 'feat(대무자): 대무자 관리 기능 — 결재 위임 + 컨텍스트 주입 + UI' (#7) from johngreen into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m38s
2026-05-11 23:29:33 +00:00
johngreen af23fd0316 feat(대무자): StartupSchemaMigrator 에 RUN_086 자동 적용 등록
invyone 의 정상 마이그레이션 패턴(StartupSchemaMigrator)에 RUN_086 의 7개 idempotent
DDL/DML 등록. 다음 backend 부팅 시 메타 DB + 모든 활성 테넌트 DB 에 자동 적용됨.

- btree_gist 확장 (CREATE EXTENSION IF NOT EXISTS)
- USER_SUBSTITUTES 테이블 (PK, CHECK 2, EXCLUDE 제약, 인덱스 2)
- SYSTEM_AUDIT_LOG ALTER (PROCESSOR_ID/PROCESSOR_NAME ADD COLUMN IF NOT EXISTS)
- APPROVAL_PROXY_SETTINGS → USER_SUBSTITUTES idempotent 데이터 복사

호환성 보정:
- EXCLUDE 의 daterange lower 에 COALESCE(.., CURRENT_DATE) 사용 불가 (IMMUTABLE 만 허용)
  → daterange(START_DATE, END_DATE, '[]') 단순 형식. NULL lower = -infinity 자연 처리.
- APPROVAL_PROXY_SETTINGS 의 START_DATE/END_DATE 가 varchar → DATE cast 추가
- 원본 메타데이터(created/updated) 컬럼명 환경별 차이 회피 → 'migration_086' + NOW() 고정

실측: meta+3 tenants 모두 OK (test01/test02/siflex)
2026-05-12 08:20:59 +09:00
johngreen 6a9fc06f0e feat(대무자): 프론트엔드 UI — UserFormModal 대무자 섹션 + ProfileModal 조회 + 결재 뱃지
- frontend/lib/api/substitute.ts: 7개 API 함수 (Record<string, any> 컨벤션)
- components/admin/SubstituteSection.tsx (신규): 관리자용 대무자 지정 섹션
  · 활성/예정 대무 관계 테이블, 사전 겹침 검증
  · v5 토큰 (--v5-surface-solid, --v5-glow-sm) 사용, blur 금지
- components/admin/UserFormModal.tsx: 수정 모드일 때 SubstituteSection 노출
- components/layout/MySubstituteView.tsx (신규): ProfileModal 용 read-only 조회
  · 내 대무자 + 내가 대무 중인 사람 양방향, D-day 카운트다운
- components/layout/ProfileModal.tsx: MySubstituteView 삽입
- app/(main)/approval/page.tsx: 대기함 행에 "대무 ← {원본 결재자}" 뱃지
  · currentUser.user_id !== line.approver_id 비교 (별도 타입 필드 X)
2026-05-12 08:07:15 +09:00
johngreen c0bd420c66 feat(대무자): SYSTEM_AUDIT_LOG processor 분리 기록 + USER_INFO lookup
- auditLog.xml insertAuditLog INSERT 절에 PROCESSOR_ID/PROCESSOR_NAME 컬럼 추가
- auditLog.xml selectUserNameById — 처리자 이름 lookup
- AuditLogService.insertAuditLog:
  · processor_id null → user_id 로 자동 채움 (평시 = 동일)
  · processor_id != user_id 이고 processor_name null → USER_INFO 단건 조회 (대무 이벤트만)
- notes: 도메인 테이블 CREATED_BY/UPDATED_BY broad scan — actual processor(B) 통일 자동 만족
2026-05-12 08:06:55 +09:00
johngreen 6ab7c3e780 feat(대무자): 결재 시스템 어댑터 통합 — USER_SUBSTITUTES read + IN(effective_user_ids)
- approval.xml selectActiveProxyForLine: APPROVAL_PROXY_SETTINGS → USER_SUBSTITUTES 참조
  (BOOLEAN IS_ACTIVE, START_DATE NULL 허용)
- approval.xml 3곳 (selectRequests/countRequests/selectMyPendingLines):
  APPROVER_ID = #{user_id} → APPROVER_ID IN (effective_user_ids) foreach
- ApprovalService.ensureEffectiveUserIds helper — mybatis 빈 IN() 방지
- ApprovalController: getRequests/getMyPendingLines 에 @RequestAttribute(effective_user_ids) 추가
- ApprovalService.processApproval 마지막에 AuditLogService.insertAuditLog 호출 추가
  (user_id=A, processor_id=B 분리 기록)
2026-05-12 08:06:39 +09:00
johngreen 4a83bfc8e8 feat(대무자): SubstituteContextFilter — JWT 후단계 effective_user_ids 주입
- TenantConsistencyGuard 뒤, ForcePasswordChangeGuard 앞에 등록
- /api/** 요청에 effective_user_ids = [user_id, ...active_original_ids] attribute 세팅
- SUPER_ADMIN (company_code='*') 은 short-circuit
- DB 조회 실패 시 본 요청 차단 안 함 (가용성 우선, warn 로그)
2026-05-12 08:06:21 +09:00
johngreen 6a7d261d23 feat(대무자): SubstituteService/Controller/mapper — CRUD + 검증 + Filter 핫패스
- substitute.xml (namespace=substitute): selectSubstituteList/Cnt, selectMySubstitutes,
  selectActiveOriginalUserIds (Filter 핫패스), selectActiveProxyForLine (결재 어댑터),
  countOverlap, countUserInCompany, countSuperAdmin, insert/update/delete
- SubstituteService extends BaseService: 관리자 권한 검증, self-위임 차단,
  cross-company 검증, SUPER_ADMIN 을 proxy 로 지정 거부, 사전 겹침 검증
- SubstituteController: /api/substitutes CRUD + /mine (read-only) + /check-overlap
2026-05-12 08:06:03 +09:00
johngreen e4856dcae5 feat(대무자): RUN_086 DB 마이그레이션 — USER_SUBSTITUTES + SYSTEM_AUDIT_LOG processor 추적
- USER_SUBSTITUTES 테이블 (PK, CHECK 2개, EXCLUDE 제약, 인덱스 2개)
- btree_gist 확장 hard prerequisite
- SYSTEM_AUDIT_LOG.PROCESSOR_ID/PROCESSOR_NAME 컬럼 추가
- APPROVAL_PROXY_SETTINGS → USER_SUBSTITUTES idempotent 데이터 복사
- 사전/사후 검증 SQL + 롤백 섹션 포함
2026-05-12 08:05:47 +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
hjjeong 1bd0fd8b80 fix(rolesList): 권한관리 viewport 안에 맞도록 높이 조정
페이지 외부 컨테이너를 h-screen 으로 가두고, 상단 4분할 카드는
viewport 비례(clamp 220~320px), 하단 메뉴 트리는 남은 공간을
flex-1 + min-h-0 으로 채우되 max-h(clamp 280~430px) 로 상한 제한.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 18:13:22 +09:00
hjjeong 4b97448467 Merge remote-tracking branch 'origin/main' into hjjeong 2026-05-11 14:06:21 +09:00
johngreen 63279296f8 Merge pull request 'fix(layout): 부서관리 탭 라벨 hardcoded 한글 fallback 추가' (#6) from johngreen into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m28s
2026-05-11 04:03:39 +00:00
johngreen 1b9604f66e fix(layout): 부서관리 탭 라벨 hardcoded 한글 fallback 추가
SUPER_ADMIN cross-tenant 모드에서 menu API (/api/admin/menus) 가 500 응답을
내어 uiMenus 가 비어있고, 그 결과 우리 effect 가 매칭할 데이터가 없어
sessionStorage 의 영어 fallback title (deptMngList) 이 갱신되지 않던 문제.

AppLayout 의 fallback 두 곳에 ADMIN_PATH_LABELS 맵 추가:
1. URL 직접 진입 시 첫 openTab 의 fallback title
2. uiMenus 매칭 실패 시 한글 라벨 보강

근본 원인 (menu API 500) 은 별도 backend 이슈 — 본 fix 는 우회.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:03:05 +09:00
johngreen b16439098a Merge pull request 'fix(layout): admin 탭 영어 fallback title 을 메뉴 한글명으로 갱신' (#5) from johngreen into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 10m59s
2026-05-11 02:11:23 +00:00
johngreen 36d93d91cf fix(layout): admin 탭 영어 fallback title 을 메뉴 한글명으로 갱신
URL 직접 진입 / sessionStorage 복원 시 AppLayout 의 fallback
(pathname.split('/').pop()) 이 path segment 를 그대로 탭 title 로
사용해서 '부서관리' 대신 'deptMngList' 같은 영어가 표시되던 문제.

- tabStore: updateTabTitle(tabId, title) 추가
- AppLayout: uiMenus 로드 후 admin 탭들의 admin_url 매칭하여
  menu_name_kor (tabTitle/label/name) 로 갱신

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:10:01 +09:00
hjjeong 77bd8cab75 Merge branch 'main' into hjjeong 2026-05-09 16:53:14 +09:00
johngreen f0781022de fix(�μ�����): 25�� ���� �ϰ� ���� + ������ ���Ἲ ��ȭ (#4)
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m31s
johngreen �귣ġ�� �μ����� ���� + �м� ��Ʈ�� main ���� ����. �ڵ� ���� Ʈ����.
2026-05-08 09:48:19 +00:00
hjjeong 0a8be2df1e Merge pull request 'hjjeong' (#3) from hjjeong into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 5m38s
Reviewed-on: #3
2026-05-08 08:29:46 +00:00
johngreen 9c658ffd36 docs(notes): 부서관리 버그 헌팅 분석 리포트 (frontend/backend/sql/ux)
4개 도메인 병렬 분석 결과 + 통합 요약 + 시나리오 중심 정리.
총 25개 버그 (CRITICAL 3 / HIGH 11 / MEDIUM 7 / LOW 4) 식별.
실제 수정은 별도 커밋 (commit 68c1cb5b).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 17:08:23 +09:00
johngreen 68c1cb5b14 fix(부서관리): 25개 버그 일괄 수정 + 데이터 무결성 강화
CRITICAL:
- searchUsers 회사/role 격리 가드 추가 (멀티테넌시 침해 차단)
- setPrimaryDept 멤버십 검증 추가 (주부서 데이터 손상 방지)
- parent_dept_code cross-tenant 검증 (validateParent 헬퍼)

HIGH:
- updateDepartment SQL WHERE 에 DELETED_AT IS NULL 추가 (silent corruption 방지)
- update/restore 부서명 중복 검증 추가
- 글로벌 부서 (*) write 작업 SUPER_ADMIN 전용 가드
- 부서코드 자동 생성으로 강제 (사용자 입력 받지 않음)
- 회사 변경 시 상세 패널 초기화
- handleMove 부분 실패 시 화면 동기화
- 검색 시 부모 체인 자동 포함 (broken tree 수정)
- start_date 기본값 today 강제 제거

MEDIUM:
- 멤버 fetch cancellation flag
- 삭제 다이얼로그 dept_code 클로저 캡처
- isDirty 시 X 버튼 폼 초기화 경고
- 변경이력 버튼 disabled (백엔드 API 미구현)
- 일괄등록 실패 상세 모달 (라인 + 사유)
- LIKE 와일드카드 ESCAPE 적용
- nullIfBlank 에 trim 통합

LOW + 새 기능:
- 부서원 추가/제거 UI 신규 구현 (UserSearchModal)
- selectDeptMembers LEFT JOIN 으로 변경
- DepartmentPicker allowRoot 옵션 (최상위로 이동)
- expandAll 전체 departments 사용
- dead code 정리

DB:
- RUN_085 마이그레이션: DEPT_INFO partial UNIQUE + USER_DEPT UNIQUE
- 모든 active 테넌트 DB (siflex/test01/test02_invyone) 적용 완료

Breaking changes:
- 일괄등록 CSV 4컬럼 → 3컬럼 (부서명,상위부서,유형)
- 부서코드 입력란 제거 (자동 부여 DEPT_n)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 17:08:03 +09:00
hjjeong 74ddc4936f Merge branch 'main' into hjjeong 2026-05-08 14:59:12 +09:00
johngreen 3d220373d8 Merge pull request 'feat(부서관리): V1 슬림 스코프 + UX 리디자인 + Windows dev HMR' (#2) from johngreen into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 5m30s
2026-05-08 02:12:04 +00:00
johngreen 3d5b2a4911 docs(notes): 부서 vs SRM 갭 분석 (johngreen)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 08:35:02 +09:00
johngreen d2b77d348b chore(dev): Windows Docker Desktop 자동 HMR 복원
Docker Desktop on Windows 의 bind mount 가 host inotify 이벤트를
컨테이너로 전파하지 못해 Turbopack file watcher 가 host 편집을 감지 못 함.
webpack 은 WATCHPACK_POLLING=true 폴백을 지원하므로 Windows 에서만
Turbopack 을 끄고 webpack 으로 폴백 → 자동 HMR 복원.

- frontend/package.json: dev:docker:nopack 스크립트 추가 (next dev, no turbopack)
- docker/dev/docker-compose.windows.yml: Windows 전용 frontend command override
- scripts/start/invyone-start-docker-all.bat: windows.yml 자동 merge

Mac/Linux 진입점은 영향 없음 (start.bat 만 windows override 활성).
첫 컴파일은 약간 느려지지만 (~10-30%) 수정→반영 시간이 80s → 1~3s 로 단축.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 08:34:46 +09:00
johngreen 0e895a90fa feat(부서관리): V1 슬림 스코프 + 트리 컨텍스트 메뉴 UX 리디자인
백엔드:
- V018 soft-delete (deleted_at 컬럼) + 휴지통/복구 흐름
- V019 미사용 컬럼 cleanup (V1 슬림 스코프)
- DepartmentService.updateDepartment 에 parent_dept_code 사이클 가드
  (자기 자신/자손을 부모로 지정 시도 차단)
- DepartmentController, mapper 갱신

프론트:
- 부서관리 페이지(deptMngList) UX 리디자인
  - 트리 노드 ⋮ 컨텍스트 메뉴 (하위 추가, 다른 부서 아래로 이동, 정렬 4단계, 삭제)
  - 헤더 breadcrumb 으로 부서 위치 상시 표시
  - 폼의 상위부서 row 제거 (트리 ⋮ 로 진입점 일원화)
  - 빈 상태 placeholder + X 닫기 동작
  - 토글 버튼 토스 스타일 (아이콘 + 툴팁, 일정한 위치)
  - 부서유형 row 좁은 화면 가로 오버플로 fix
- DepartmentPicker 신규 재사용 컴포넌트 (자손 자동 exclude, 사이클 차단)
- 회사관리/프로비저닝 폼 개선 (Step1Basic, fields, CompanyTable, AdminPageRenderer)
- companyList/[companyCode]/departments 구버전 페이지 삭제

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 08:34:23 +09:00
DDD1542 59f5cf22f0 Merge remote-tracking branch 'origin/gbpark-node' into gbpark-node
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m4s
2026-05-07 17:09:57 +09:00
DDD1542 c4631efbd2 중간저장 2026-05-07 17:06:26 +09:00
hjjeong 84b9060e4e Merge branch 'hjjeong' into gbpark-node
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m36s
2026-05-07 17:04:57 +09:00
hjjeong 3280be8bd4 fix(rolesList): cross-tenant row 식별 + 메뉴 트리 스크롤 보강
cross-tenant fan-out 결과에서 회사 A·B 의 동일 objid 가 합본에 들어와
React key 중복 경고 발생 + isSelected 가 회사 구분 못 하던 문제.

- li key: role.objid → \`\${company_code}-\${objid}\` 조합으로 unique
- isSelected 비교: objid + company_code 둘 다 매칭
- selectedRole 유효성 체크(useEffect)에도 company_code 매칭 추가

추가:
- 메뉴 전체 트리구조에 자체 스크롤 (maxHeight: calc(100vh - 32rem))
- thead sticky top-0 + bg-muted (투명도 제거) → 스크롤 시 헤더 가려짐 해소

SUPER_ADMIN cross-tenant 정책 변경 없음 (모든 회사 합본 표시 유지),
React 식별만 정확해지는 fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:51:16 +09:00
hjjeong b782bb298f merge: origin/gbpark-node → hjjeong (60 commits, 5 conflicts resolved)
충돌 해결 5개 파일:
- .gitignore: .envrc/.direnv (hjjeong direnv 셋업) + .omc/ (gbpark) 양쪽 보존
- docs/MULTI_TENANCY_ARCHITECTURE.md: *.localhost dev 분기 + *.invyone.com/solution.invyone.com 통합
- frontend/lib/api/client.ts: 1-b *.localhost:8081 dev + 1-c DEV_TENANT_HOST(nip.io):8083 + invyone.com 신 도메인
- frontend/lib/tenant/subdomain.ts: IPv4 차단 + *.invyone.com + DEV_TENANT_HOST + *.localhost 모두 처리
- frontend/app/(auth)/login/page.tsx: B안 채택 — buttons 항상 렌더, className 만 mounted 가드 (next-themes 표준 패턴)

검증:
- backend: ./gradlew compileJava 성공 (Java 21)
- frontend: 머지된 4개 파일 관련 타입 에러 0개

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:51:06 +09:00
DDD1542 798fdf18b3 merge: gbpark-node → main (화재 알람 SCADA 경고 인디케이터)
Build & Deploy to K8s / build-and-deploy (push) Failing after 6m51s
- zone 중앙에 SCADA 스타일 경고 비콘 (펄스 링 + 빨간 배지 + 경고 삼각형)
- WARN(노란/정적) / ALARM(빨간/깜빡임) 색상·효과 분리
- ZONE 17 라벨/manual-call 겹침 해소

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:36:14 +09:00
DDD1542 8eb4e8c9a2 feat: 화재 알람 데모 SCADA 스타일 경고 인디케이터 + ZONE 17 콘텐츠 정렬
Build & Deploy to K8s / build-and-deploy (push) Successful in 5m29s
- 각 zone 중앙에 SCADA 스타일 경고 비콘 자동 삽입 (펄스 링 + 빨간 배지 + 노란 경고 삼각형)
- WARN/ALARM 별 색상 분리 (CSS 변수 --b-* 로 SVG <use> shadow DOM 통과)
  - WARN: 노란 톤 + 정적 표시
  - ALARM: 빨간 톤 + drop-shadow + brightness 깜빡임
- zone-area 점선 테두리: warn(노란/얇음), alarm(빨간/굵음+pulse)
- ZONE 17 콘텐츠를 ZONE 18 비례에 맞춰 재배치 (label y 453→396) — manual-call 과 라벨 겹침 해소

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:34:20 +09:00
DDD1542 48d74170fc merge: gbpark-node → main (SCADA/화재알람 데모 가독성 개선)
Build & Deploy to K8s / build-and-deploy (push) Failing after 6m31s
- SCADA 데모: 게이지 1.5x 확대, 설비 라벨 폰트 일괄 키움, 컴포넌트 위치 미세 조정
- SCADA 데모: dev-drag.js 추가 (Shift+D 임시 드래그 모드 + via/끝점 핸들)
- 화재 알람 데모: 건물 4종 색상 분류, 라벨/아이콘 폰트 확대
- chore: @anthropic-ai/claude-code dependency 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 11:07:54 +09:00
DDD1542 e8ba13f52b chore: @anthropic-ai/claude-code 패키지 dependencies 추가
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m3s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 11:05:46 +09:00
DDD1542 5d2283cb47 feat: 화재 알람 데모 건물별 색상 분류 + 라벨/아이콘 가독성 개선
- 건물 4종(utility/service/factory1/office)에 배경 그라디언트 분기
- zone 영역도 같은 톤 색상 클래스 (za-utility/service/factory1/office)
- room-label 폰트 5.7 → 7.5, zone-label 10 → 13.5 키움
- 센서/manual-call 아이콘 26 → 34, 18 → 24 로 확대 + 위치 보정

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 11:05:42 +09:00
DDD1542 de0bfc1af4 feat: SCADA 데모 가독성 개선 + 컴포넌트 좌표 조정 도구 추가
- 압력 게이지 1.5x 확대, PRESS/판독값/라벨 폰트 강조
- 모든 설비(탱크/펌프/시스템/MBR/필터/카본) 라벨 폰트 +2~4 단계 키움
- 센서/타일 박스 및 폰트 확대, 위치 간격 보정
- 위쪽 처리 라인 30px 위로 + TSS-RAW 자기 자리 유지하도록 dy 보정
- AIR BLOWER 를 CIP 패널 내려간 만큼 같이 내림
- 게이지/밸브/모듈/탱크 및 파이프 라우팅 미세 조정 (드래그로 잡은 좌표 일괄 반영)
- dev-drag.js: Shift+D 임시 드래그 모드. 컴포넌트/파이프 via/절대 끝점 핸들로 좌표 인스펙션 후 토폴로지 재빌드, 변경 이력 누적 패널 + 전체 복사
2026-05-07 10:42:33 +09:00
hjjeong 205428533d fix(rolesList): 직원 체크박스 클릭 시 이중 토글로 해제 안 되던 버그
증상: 권한있는/없는 직원 영역의 체크박스를 클릭하면 즉시 다시 해제됨
처럼 보이고 (사실은 두 번 토글됨), 체크 상태 자체가 유지 안 됨.
전체선택 버튼 (별개 핸들러) 만 정상 동작.

원인: <li onClick={토글}><Checkbox onCheckedChange={토글} /></li> 구조에서
체크박스 클릭 → onCheckedChange 트리거 (토글 1회) → 이벤트가 li 로 버블 →
li onClick 트리거 (토글 1회 더) → 합쳐서 2회 토글 = 원상복귀.

수정: Checkbox 에 onClick={(e) => e.stopPropagation()} 추가해서 li 로
버블 차단. li 빈 영역 클릭 시는 li onClick 만 동작, 체크박스 직접 클릭
시는 onCheckedChange 만 동작 — 둘 다 정확히 1회 토글.

권한있는 직원 + 권한없는 직원 두 곳 모두 수정.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 07:11:39 +09:00
hjjeong b5a60d1c8b fix(role): getRoleNonMemberList OGNL test 의 inner-quote 컨벤션 위반 수정
증상: SUPER_ADMIN cross-tenant 모드에서 권한 그룹 클릭 시 workspace
500 — NumberFormatException: For input string: "TEST02"

원인: role.xml getRoleNonMemberList 의 if 가
    test="company_code != null and company_code != '' and company_code != '*'"
형태로 single-quote 안에 single-quote 가 박혀, OGNL 이 '*' 를 number 로
변환 시도하다 NumberFormatException. 단일 모드 (test01.localhost) 에서는
group.company_code 가 시드 잔여 '*' 라 if 가 false 로 fall-through 해
안 터졌고, cross-tenant 에서 명시적 "TEST02" 가 들어가니 평가 시도
→ 깨짐.

CLAUDE.md "OGNL test: 바깥 작은따옴표" 컨벤션대로 수정:
    test='company_code != null and company_code != "" and company_code != "*"'

검증: TEST02 의 '관리자' 그룹 workspace 호출 → HTTP 200, members: 0,
nonMembers: 2 (test02_admin, test02), menus: 0 정상 반환.

22, 334번 라인의 다른 if 문은 != '*' 가 없어 OGNL 평가가 numeric
coercion 까지 안 가므로 무수정 (회귀 위험 0).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 07:06:19 +09:00
hjjeong 42f7ae35db fix(cross-tenant): rolesList 회사 dropdown 이 cross-tenant 모드에서 무시되던 문제
Phase 2 검증 중 발견. SUPER_ADMIN 이 admin 도메인에서 회사 필터를 "시연용회사2"
선택해도 TEST01 의 권한 그룹이 그대로 보이던 UI 버그.

원인: roleAPI.getList 의 cross-tenant 분기는 fan-out 으로 모든 회사 그룹을
모아 응답에 company_code 박아 돌려줌. 화면이 그 결과를 그대로 렌더하면서
selectedCompany dropdown 으로 client-side 필터링을 안 했음.

수정: rolesList 의 filteredRoleGroups 가 isSuperAdmin && selectedCompany!="all"
조건일 때 company_code 일치 행만 통과시키도록 한 줄 추가.

알려진 이슈 (별개, Phase 2 후속):
- GET /api/admin/cross-tenant/roles/{id}/workspace 가 500 떨어짐.
  단일 모드 GET /api/roles/{id}/workspace 도 group=null 빈 결과 — 사전
  존재 시드/스키마 mismatch 추정 (RoleService.getRoleWorkspace 의 5개 mapper
  중 하나가 깨짐). cross-tenant 분기 자체 (runInCompany) 는 정상.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 21:48:48 +09:00
hjjeong 7d9ec39b5d feat(cross-tenant): SUPER_ADMIN 의 회사별 권한관리 WRITE (Phase 2)
Phase 1(사용자관리) 패턴을 권한관리에 동일 적용. 권한 그룹 CRUD,
멤버 토글, 메뉴 권한 토글 모두 회사 컨텍스트 임시 전환 후 처리.

신규 백엔드
- crosstenant/CrossTenantRoleController.java
  /api/admin/cross-tenant/roles/** — 8개 endpoint
  · POST       — 권한 그룹 생성 (body.company_code 필수)
  · PUT  /{id} — 권한 그룹 수정 (body.company_code 필수)
  · DELETE /{id}?company_code= — 삭제
  · GET  /{id}/workspace?company_code= — 그룹 + 멤버 + 메뉴 통합 로드
  · GET  /menus/all?company_code= — 회사 메뉴 트리 (권한 설정용)
  · POST   /{id}/members/{userId}?company_code= — 멤버 1명 추가
  · DELETE /{id}/members/{userId}?company_code= — 멤버 1명 제거
  · PATCH  /{id}/menu-permissions/{menuObjid} — 토글
  CrossTenantExecutor 재사용. 기존 RoleController 무수정 (회귀 0).

  중요: @RequestAttribute("user_id") 가 토큰 없을 때 missing 에러로 500
  떨어지는 문제 — required=false 로 가드까지 안전하게 도달하도록.

프론트
- lib/api/role.ts — 7개 메서드(create/update/delete/getWorkspace/
  getAllMenus/addSingleMember/removeSingleMember/toggleMenuPermission)에
  isCrossTenantMode() 분기 + companyCode 인자 추가
- RoleFormModal — update 시 editingRole.company_code 같이 전달
- RoleDeleteModal — delete 시 role.company_code 같이 전달
- rolesList/page.tsx — loadWorkspace / addSingleMember / removeSingleMember /
  toggleMenuPermission 호출 시 selectedRole.company_code 전달

검증 (curl, SUPER_ADMIN 토큰):
- 토큰 없음 → 403 super_admin_required
- POST 권한 그룹 (TEST02) → 201, /roles fan-out 에 by={TEST01:1, TEST02:1}
- DELETE → 200, fan-out by={TEST01:1} 로 복귀

미구현 (Phase 2 후속, 별도 작업):
- 일괄 멤버 추가/제거/diff (PUT/POST /members)
- 메뉴 권한 일괄 설정 (PUT /menu-permissions)
- 사용자별 권한 그룹 조회

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:45:55 +09:00
hjjeong 4d19c31440 feat(cross-tenant): 부서 endpoint + UserFormModal 회사-우선 reorder
직전 Phase 1 의 후속 폴리시.

신규 백엔드
- crosstenant/CrossTenantDeptController.java
  GET /api/admin/cross-tenant/departments?company_code=TEST02
  단일 모드 GET /admin/departments 와 응답 형태 동일. company_code query param
  으로 명시된 회사 DB 컨텍스트로 임시 전환해서 부서 트리 반환.
  버그 수정: 메타 DB DEPT_INFO 시드 (qnc/COMPANY_7 등 다른 회사 부서) 가
  TEST02 선택 시에도 dropdown 에 섞여 보이던 문제 해결.

프론트
- lib/api/user.ts — getDepartmentList(companyCode) 가 isCrossTenantMode() 면
  /admin/cross-tenant/departments?company_code= 호출.
  cross-tenant 모드 + companyCode 미지정 → 빈 배열 반환 (회사 안 골랐는데
  메타 부서 보여주는 것 방지).

UserFormModal
- 회사 dropdown 을 폼 가장 위로 이동 — 사용자 ID 중복확인·부서 선택이
  모두 회사에 의존하므로 자연스러운 입력 순서
- SUPER_ADMIN 인데 회사 미선택 상태에선 사용자 ID input + 중복확인 버튼
  disable + placeholder "회사 먼저 선택"
- checkUserIdDuplicate 가드: 회사 미선택이면 "회사를 먼저 선택해주세요"
  (백엔드의 400 "company_code 가 비어있음" 보다 친절)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:38:30 +09:00
hjjeong a41f99c579 feat(cross-tenant): SUPER_ADMIN 의 회사별 사용자 WRITE (Phase 1)
지금까지 cross-tenant 는 READ 전용. admin 도메인에서 사용자 등록하면
JWT.company_code='*' 가 그대로 박혀 메타 DB 에 INSERT 되던 버그 해결.
이제 SUPER_ADMIN 이 폼의 "회사" 드롭다운에서 TEST01/TEST02 등 선택하면
그 회사 DB 에 정확히 INSERT.

신규 백엔드
- crosstenant/CrossTenantExecutor.java  — 회사 컨텍스트 임시 전환 헬퍼
  (company_code → db_name → ensureTenantPool → set → run → restore)
- crosstenant/CrossTenantUserController.java  — /api/admin/cross-tenant/users
  9개 endpoint (POST/PUT/DELETE/PATCH/with-dept/check-duplicate/단건/이력)
- mapper/provisioning.xml — resolveDbNameByCompanyCode (active 회사만)

기존 단일 회사 모드 (POST /admin/users 등) 무수정 — 회사 도메인
컨텍스트에서 회귀 0.

프론트
- lib/api/user.ts — createUser/updateUser/updateUserStatus/checkDuplicateUserId/
  saveUserWithDept 가 isCrossTenantMode() 면 새 endpoint + body.company_code 로 분기
- UserFormModal — checkDuplicateId 호출 시 formData.company_code 같이 전달
- useUserManagement — status toggle 시 row 의 company_code 같이 전달

검증 (curl, SUPER_ADMIN 토큰):
- 토큰 없음 → 403 super_admin_required
- company_code 없음 → 400 "company_code 가 비어있음"
- 잘못된 company_code → 400 "등록되지 않았거나 비활성 회사"
- check-duplicate: TEST01.test02_admin → not_dup, TEST02.test02_admin → dup ✓
- POST 사용자 → TEST02 USER_INFO +1, TEST01·메타 격리 ✓
- /users fan-out: by={'*':8, 'TEST01':1, 'TEST02':2}, hjtest_ct_001 in TEST02만 ✓
- DELETE → status=inactive (soft) ✓

미구현 (Phase 1 후속):
- 부서 dropdown (cross-tenant department endpoint 별도 필요)
- 비밀번호 초기화 모달의 cross-tenant 분기 (UserPasswordResetModal)
- Phase 2 권한관리 (별도 커밋)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:22:52 +09:00
hjjeong cdc55dfd48 feat(cross-tenant): truncated/failed 안내 배너 (READ 트랙 마무리)
SUPER_ADMIN cross-tenant 모드에서 회사당 cap 200 에 걸리거나 한 회사
조회 실패 시 화면 상단에 안내 배너 노출. 아무 메타 없으면 자리 안 잡음.

신규
- components/common/CrossTenantBanner.tsx — amber(truncated) + red(failed)
  v5 토큰 (surface-solid + glow-sm) 기반 솔리드 배너. blur 안 씀

API 클라이언트 4개에 cross_tenant_meta 노출
- lib/api/user.ts        — userAPI.getList 응답에 cross_tenant_meta 추가
- lib/api/role.ts        — roleAPI.getList 동일
- lib/api/batch.ts       — BatchAPI.getBatchConfigs 동일
- lib/api/multilang.ts   — getLangKeys 동일 (i18nList 페이지는 아직 직접
  호출 패턴이라 자동 적용 X — 후속에서 페이지를 getLangKeys 로 통일하면 동작)

페이지 마운트 (3개)
- userMng/userMngList — useUserManagement hook 에 crossTenantMeta state 추가
- userMng/rolesList   — loadRoleGroups 에서 메타 set
- automaticMng/batchmngList — loadBatchConfigs 에서 메타 set
- systemMng/i18nList — 스킵 (cross-tenant aggregation 미적용 상태, 별도 작업)

설계서 §11 검증 (직전 §11.2 부분 실패 시뮬) 결과: failed 배너가
header X-CrossTenant-Failed 와 동일 정보로 화면에 노출됨.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:14:48 +09:00
hjjeong 280e25a4df notes(hjjeong): cross-tenant §11.2 부분 실패 시뮬레이션 검증 (2026-04-29)
테넌트 DB 만 만져도 됨 — 메타 DB 무수정.
TEST02 의 USER_INFO 를 임시 RENAME 해서 SELECT 실패 유도 → fan-out
호출 → 즉시 롤백. 메타 DB·다른 테이블 일체 영향 없음.

결과:
- RENAME 후 /users → HTTP 200, header X-CrossTenant-Failed: TEST02
  total=9 q=2 failed=1 by={'*':8, 'TEST01':1}  (TEST02 0)
- 롤백 후 /users → total=10 failed=0 by 원복

검증된 항목:
- fail-open: 한 회사 실패해도 전체 응답 200
- 회사 격리: TEST01·메타 행 영향 없음
- companies_failed: 1 + failed_company_codes: ["TEST02"]
- 응답 헤더 X-CrossTenant-Failed: TEST02

문서 갱신:
- 설계 27.md §11.2:  + 실행로그 §9.4 링크
- 실행 28.md §9.4 신설 — 시뮬레이션 SQL + 결과 + 검증 항목 5개
- 실행 28.md §9.5 — 남은 시나리오 (§11.4 락 비획득 / §11.5 캐시 N/A)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 11:42:12 +09:00
hjjeong 568eb14503 notes(hjjeong): cross-tenant fan-out 2회사 머지 검증 결과 (2026-04-29)
설계서 §11.1 (행복 경로) 의 첫 다회사 실증.
SUPER_ADMIN 토큰으로 /_active-companies + 4개 fan-out 엔드포인트 직접 curl.

검증 결과 (TEST01 + TEST02 둘 다 active):
- /users      total=10  q=2  failed=0  by={'*':8, 'TEST01':1, 'TEST02':1}
- /roles      total=1   q=2  failed=0  by={'TEST01':1}
- /batches    total=14  q=2  failed=0  by={'TEST01':7, 'TEST02':7}  ← 균등 머지
- /lang-keys  total=0   q=2  failed=0  by={}                          ← 회사 DB 시드 없음

발견:
- /users 의 '*' 8행은 버그 아님 — listUsers 만 includeMeta=true 호출,
  메타 DB SUPER_ADMIN 들을 company_code='*' 로 prepend (의도된 설계).
- runFanOut 의 마지막 boolean 은 wrapSearchWithPercent 일 뿐 includeMeta 아님
  (시그니처 분리). roles/batches/lang-keys 는 모두 includeMeta=false.
- /lang-keys 0건은 회사 DB MULTI_LANG_KEY_MASTER 가 비어있는 것 (failed=0).
  이전 §3 의 "TEST01: 646건" 은 시점/컨텍스트 다른 측정으로 추정.

문서 갱신:
- 설계 27.md §11: 11.1 , 11.3 fan-out 검증 추가 인용
- 실행 28.md §6 #3, §8 활성 회사 TEST02 추가
- 실행 28.md §9 신설 — 결과 표 + 해석 + 검증된 항목 + 미검증 + 재현용 명령

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 11:27:53 +09:00
hjjeong a3c74f926c notes(hjjeong): cross-tenant 27/28.md 현재 상태 반영 (2026-04-29)
설계 27.md
- 상단 최종 갱신 + 실행 로그 링크 + Phase A/B/C 완료 배너
- §10 단계별 체크리스트: A 4/5, B 3/4, C 3/14, 페이지네이션 cap ✓, D 미진행, E 일부
- §11 검증 시나리오: 5개 시나리오 상태 표 + TEST01 실 검증 결과 인용
- §14 다음 세션 진입 시: 즉시 가능한 4개 작업 우선순위 (TEST02 fan-out 검증 1순위)
- gbpark/ 의 2026-04-24 두 문서 참조를 ../gbpark/ 로 보정

실행 로그 28.md
- 상단 최종 갱신 + 핸드오프 문서 참조
- TL;DR: 푸시·커밋 안 함 → 3 커밋 푸시 완료, TEST02 활성 명기
- §4.4 신규 — *.localhost 라우팅이 같은 커밋 배치에 묶인 사실 + 2026-04-29 검증 결과
- §5: 워킹트리 → 3개 커밋 분배 의도 표
- §6: 권장 단계 8개 → 9개 (Gitea PR 추가), 각 항목 /🟡// 표기

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:50:41 +09:00
hjjeong 36d6ad508d notes: hjjeong 작성 MD 3개를 notes/hjjeong/ 으로 이동 + 현재 상태 반영
CLAUDE.md "notes/{git-user}/{date}-{slug}.md" 규약대로 정리.
git config user.name=hjjeong 인데 gbpark/ 에 쌓이고 있던 3개 분리.

- 2026-04-27-cross-tenant-admin-aggregation.md (설계, 내용 갱신: Phase A/B/C 체크리스트, §11 검증 시나리오 실 결과 병기, §14 다음 단계)
- 2026-04-28-cross-tenant-execution-log.md (실행 로그, 내용 갱신: 커밋·푸시 상태, §4.4 *.localhost 같은 배치 묶음 기재, §5 커밋 분배 의도, §6 권장 단계 /🟡// 표시)
- 2026-04-28-localhost-tenant-routing-handoff.md (순수 이동)

cross-tenant 27.md 의 같은 폴더 참조였던 2026-04-24 두 문서는
gbpark/ 에 그대로 남아 ../gbpark/ 로 경로 보정. 외부 참조(../../docs,
../../backend-spring 등) 는 깊이 동일해 무수정.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:50:08 +09:00
hjjeong a6be4f2efe 사용자관리 테이블 자체 스크롤 — viewport 기반 max-height 로 강제
scrollContainer 모드의 flex 기반 height 계산이 shadcn Table 의 내부 wrapper
(data-slot="table-container", overflow-x-auto) 와 충돌해 잘림 발생.
flex-1/min-h-0 대신 max-h-[calc(100vh-280px)] + overflow-y-auto 로 강제.
hack 성이지만 안정적으로 동작.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:52:30 +09:00
hjjeong 383b837a60 dev *.localhost 테넌트 라우팅 + direnv/Java 21 dev 환경 정비
SubdomainResolverFilter.extractSubdomain() 가 2파트 {sub}.localhost 호스트도
첫 파트로 파싱 (RFC 6761). 운영 3파트 경로 무변경. 단위 테스트 8건 추가.

frontend/lib/api/client.ts 에 *.localhost (bare 제외) 직접 호출 분기 1-b 추가.
8081 로 직결해 Host 헤더 보존. subdomain.ts 도 동일 규칙 적용.

application.yml CORS 디폴트에 http://*.localhost:[*] 패턴 추가.
docs/MULTI_TENANCY_ARCHITECTURE.md §4.2 (실행 모드 A/B) + §6 (1-b 분기) 갱신.
.gitignore 에 .envrc/.direnv 추가, .java-version=21 명시 (jenv 호환).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:52:30 +09:00
hjjeong e16fb16987 어드민 cross-tenant 집계 (SUPER_ADMIN) + 사용자관리 자체 스크롤
SUPER_ADMIN 토큰(company_code=*)이면 등록 회사들 DB 를 순회해 결과를
집계해 돌려주는 CrossTenantAggregator/Controller 추가. 사용자/권한그룹/
배치/다국어 키 4개 도메인의 list API 가 cross-tenant 모드 지원.

UserTable + ResponsiveDataView 에 compact/scrollContainer prop 추가.
페이지 헤더/툴바/페이지네이션은 고정, 테이블만 자체 스크롤.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:52:30 +09:00
665 changed files with 65852 additions and 66563 deletions
+4
View File
@@ -2,6 +2,10 @@
.claude/
CLAUDE.local.md
# direnv (per-developer JAVA_HOME / shell env)
.envrc
.direnv/
# OMC (oh-my-claudecode) 작업용 임시 상태 — 절대 추적 금지
# planning, autopilot state, agent transcript, project memory 등 포함
.omc/
+1
View File
@@ -0,0 +1 @@
21
+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), "사용자 메뉴 목록 조회 성공"));
}
@@ -295,7 +305,8 @@ public class AdminController {
@PostMapping("/users/reset-password")
public ResponseEntity<ApiResponse<Void>> resetUserPassword(@RequestBody Map<String, Object> body) {
String userId = (String) body.get("user_id");
adminService.resetUserPassword(userId);
String newPassword = (String) body.get("new_password");
adminService.resetUserPassword(userId, newPassword);
return ResponseEntity.ok(ApiResponse.success(null, "비밀번호 초기화 성공"));
}
@@ -190,9 +190,11 @@ public class ApprovalController {
public ResponseEntity<ApiResponse<Map<String, Object>>> getRequests(
@RequestParam Map<String, Object> params,
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("user_id") String userId) {
@RequestAttribute("user_id") String userId,
@RequestAttribute(name = "effective_user_ids", required = false) List<String> effectiveUserIds) {
params.put("company_code", companyCode);
params.put("user_id", userId);
params.put("effective_user_ids", effectiveUserIds);
return ResponseEntity.ok(ApiResponse.success(approvalService.getRequests(params)));
}
@@ -277,10 +279,12 @@ public class ApprovalController {
@GetMapping("/my-pending")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getMyPendingLines(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("user_id") String userId) {
@RequestAttribute("user_id") String userId,
@RequestAttribute(name = "effective_user_ids", required = false) List<String> effectiveUserIds) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("user_id", userId);
params.put("effective_user_ids", effectiveUserIds);
return ResponseEntity.ok(ApiResponse.success(approvalService.getMyPendingLines(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,42 +18,64 @@ 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}");
/**
* 부서 목록 조회 (회사별)
* GET /api/departments/companies/{companyCode}/departments
* 부서 목록 조회 (회사별).
* 기본은 active 부서만. ?include_deleted=true 시 soft-delete 된 부서도 포함.
* ?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) {
@RequestAttribute("company_code") String userCompanyCode,
@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);
List<Map<String, Object>> departments = departmentService.getDepartments(companyCode, includeDeleted, baseDate);
return ResponseEntity.ok(ApiResponse.success(departments, "부서 목록 조회 성공"));
}
/**
* 부서 상세 조회
* GET /api/departments/{deptCode}
* 부서 상세 조회.
* - 기본: active 부서만 (DELETED_AT IS NULL)
* - ?include_deleted=true: soft-delete 된 부서도 조회 가능 (복구·이력 화면용)
* - 회사 격리: 본인 회사 부서만, SUPER_ADMIN 은 전체
* GET /api/departments/{deptCode}[?include_deleted=true]
*/
@GetMapping("/{deptCode}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getDepartment(
@PathVariable String deptCode) {
@PathVariable String deptCode,
@RequestAttribute("company_code") String userCompanyCode,
@RequestParam(value = "include_deleted", required = false, defaultValue = "false") boolean includeDeleted) {
Map<String, Object> department = departmentService.getDepartment(deptCode);
Map<String, Object> department = includeDeleted
? departmentService.getDepartmentIncludingDeleted(deptCode)
: departmentService.getDepartment(deptCode);
if (department == null) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
if (!canAccessDept(department, userCompanyCode)) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(department, "부서 조회 성공"));
}
/**
* 부서 생성
* 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(
@@ -82,62 +104,283 @@ 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(
@PathVariable String deptCode,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String userCompanyCode,
@RequestBody Map<String, Object> body) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
Map<String, Object> existing = departmentService.getDepartmentIncludingDeleted(deptCode);
if (existing == null) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
if (!canAccessDept(existing, userCompanyCode)) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
}
try {
Map<String, Object> updated = departmentService.updateDepartment(deptCode, body);
if (updated == null) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(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()));
}
}
/**
* 부서 삭제
* 일괄 미리보기 (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() 마킹으로 변경
* - 응답 호환: 기존 { success, message } 에 data.soft_deleted=true 필드 추가
* - USER_DEPT 행은 보존되어 복구 시 멤버 그대로 살아남
* DELETE /api/departments/{deptCode}
*/
@DeleteMapping("/{deptCode}")
public ResponseEntity<ApiResponse<Void>> deleteDepartment(
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteDepartment(
@PathVariable String deptCode,
@RequestAttribute("role") String role) {
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String userCompanyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
Map<String, Object> existing = departmentService.getDepartmentIncludingDeleted(deptCode);
if (existing == null) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없거나 이미 삭제된 부서입니다."));
}
if (!canAccessDept(existing, userCompanyCode)) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없거나 이미 삭제된 부서입니다."));
}
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
}
try {
int memberCount = departmentService.deleteDepartment(deptCode);
if (memberCount == -1) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없니다."));
int result = departmentService.deleteDepartment(deptCode);
if (result == -1) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없거나 이미 삭제된 부서입니다."));
}
String message = memberCount > 0
? "부서가 삭제되었습니다. (부서원 " + memberCount + "명 제외됨)"
: "부서가 삭제되었습니다.";
return ResponseEntity.ok(ApiResponse.success(null, message));
Map<String, Object> data = new java.util.HashMap<>();
data.put("soft_deleted", true);
data.put("dept_code", deptCode);
return ResponseEntity.ok(ApiResponse.success(data, "부서가 삭제되었습니다. (복구 가능)"));
} catch (IllegalStateException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
}
/**
* 부서 복구 (V1 slim scope).
* - DELETED_AT = NULL 로 되돌림
* - 부모도 deleted 상태면 차단
* POST /api/departments/{deptCode}/restore
*/
@PostMapping("/{deptCode}/restore")
public ResponseEntity<ApiResponse<Map<String, Object>>> restoreDepartment(
@PathVariable String deptCode,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String userCompanyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
Map<String, Object> existing = departmentService.getDepartmentIncludingDeleted(deptCode);
if (existing == null) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
if (!canAccessDept(existing, userCompanyCode)) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
}
DepartmentService.RestoreResult result;
try {
result = departmentService.restoreDepartment(deptCode);
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
switch (result) {
case OK:
Map<String, Object> data = new java.util.HashMap<>();
data.put("dept_code", deptCode);
data.put("restored", true);
return ResponseEntity.ok(ApiResponse.success(data, "부서가 복구되었습니다."));
case NOT_FOUND:
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
case NOT_DELETED:
return ResponseEntity.status(400).body(ApiResponse.error("이미 활성 상태인 부서입니다."));
case PARENT_DELETED:
return ResponseEntity.status(400).body(ApiResponse.error("상위 부서가 삭제 상태입니다. 상위 부서를 먼저 복구해주세요."));
default:
return ResponseEntity.status(500).body(ApiResponse.error("복구 처리 중 오류"));
}
}
/**
* 부서원 목록 조회
* GET /api/departments/{deptCode}/members
*/
@GetMapping("/{deptCode}/members")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getDeptMembers(
@PathVariable String deptCode) {
@PathVariable String deptCode,
@RequestAttribute("company_code") String userCompanyCode) {
Map<String, Object> existing = departmentService.getDepartmentIncludingDeleted(deptCode);
if (existing == null) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
if (!canAccessDept(existing, userCompanyCode)) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
List<Map<String, Object>> members = departmentService.getDeptMembers(deptCode);
return ResponseEntity.ok(ApiResponse.success(members, "부서원 목록 조회 성공"));
@@ -150,8 +393,17 @@ public class DepartmentController {
@GetMapping("/companies/{companyCode}/users/search")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> searchUsers(
@PathVariable String companyCode,
@RequestAttribute("company_code") String userCompanyCode,
@RequestAttribute("role") String role,
@RequestParam(required = false) String search) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 사용자를 검색할 권한이 없습니다."));
}
if (search == null || search.isBlank()) {
return ResponseEntity.status(400).body(ApiResponse.error("검색어를 입력해주세요."));
}
@@ -168,15 +420,27 @@ public class DepartmentController {
public ResponseEntity<ApiResponse<Void>> addDeptMember(
@PathVariable String deptCode,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String userCompanyCode,
@RequestBody Map<String, Object> body) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
Map<String, Object> existing = departmentService.getDepartmentIncludingDeleted(deptCode);
if (existing == null) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
if (!canAccessDept(existing, userCompanyCode)) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
}
// 프론트엔드는 snake_case(user_id)로 전송 (Node.js 호환)
Object userIdObj = body.get("user_id");
if (userIdObj == null) userIdObj = body.get("user_id");
Object userIdObj = body.get("user_id") != null ? body.get("user_id") : body.get("userId");
if (userIdObj == null || userIdObj.toString().isBlank()) {
return ResponseEntity.status(400).body(ApiResponse.error("사용자 ID를 입력해주세요."));
}
@@ -200,12 +464,25 @@ public class DepartmentController {
public ResponseEntity<ApiResponse<Void>> removeDeptMember(
@PathVariable String deptCode,
@PathVariable String userId,
@RequestAttribute("role") String role) {
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String userCompanyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
Map<String, Object> existing = departmentService.getDepartmentIncludingDeleted(deptCode);
if (existing == null) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
if (!canAccessDept(existing, userCompanyCode)) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
}
boolean removed = departmentService.removeDeptMember(deptCode, userId);
if (!removed) {
return ResponseEntity.status(404).body(ApiResponse.error("해당 부서원을 찾을 수 없습니다."));
@@ -221,14 +498,31 @@ public class DepartmentController {
public ResponseEntity<ApiResponse<Void>> setPrimaryDept(
@PathVariable String deptCode,
@PathVariable String userId,
@RequestAttribute("role") String role) {
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String userCompanyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
departmentService.setPrimaryDept(deptCode, userId);
return ResponseEntity.ok(ApiResponse.success(null, "주 부서가 설정되었습니다."));
Map<String, Object> existing = departmentService.getDepartmentIncludingDeleted(deptCode);
if (existing == null) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
if (!canAccessDept(existing, userCompanyCode)) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
}
try {
departmentService.setPrimaryDept(deptCode, userId);
return ResponseEntity.ok(ApiResponse.success(null, "주 부서가 설정되었습니다."));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
}
// ──────────────────────────────────────────────────
@@ -242,4 +536,16 @@ public class DepartmentController {
private boolean isSuperAdmin(String companyCodeOrRole) {
return "*".equals(companyCodeOrRole) || "SUPER_ADMIN".equals(companyCodeOrRole);
}
/**
* 회사 격리 검증. SUPER_ADMIN ('*') 은 모든 회사 접근 가능.
* 일반 ADMIN/USER 는 자기 회사 + 글로벌 ('*') 부서만.
*/
private boolean canAccessDept(Map<String, Object> dept, String userCompanyCode) {
if (dept == null) return false;
if (isSuperAdmin(userCompanyCode)) return true;
String deptCompanyCode = dept.get("company_code") != null ? dept.get("company_code").toString() : null;
if (deptCompanyCode == null) return false;
return userCompanyCode.equals(deptCompanyCode) || "*".equals(deptCompanyCode);
}
}
@@ -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("공통 코드 데이터 조회 중 오류가 발생했습니다."));
}
}
@@ -0,0 +1,73 @@
package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.service.FavoritesService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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/favorites")
@RequiredArgsConstructor
@Slf4j
public class FavoritesController {
private final FavoritesService favoritesService;
/**
* GET /api/favorites/menus
* 로그인 사용자의 즐겨찾기 메뉴 목록.
*/
@GetMapping("/menus")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getMyFavorites(
@RequestAttribute("user_id") String userId) {
Map<String, Object> params = new HashMap<>();
params.put("user_id", userId);
return ResponseEntity.ok(ApiResponse.success(
favoritesService.getFavoriteMenuList(params),
"즐겨찾기 메뉴 조회 성공"));
}
/**
* POST /api/favorites/menus
* 즐겨찾기 추가. body: { menu_objid, sort_order? }
*/
@PostMapping("/menus")
public ResponseEntity<ApiResponse<Map<String, Object>>> addFavorite(
@RequestAttribute("user_id") String userId,
@RequestBody Map<String, Object> body) {
Object menuObjid = body.get("menu_objid");
if (menuObjid == null || String.valueOf(menuObjid).isBlank()) {
return ResponseEntity.badRequest().body(ApiResponse.error("menu_objid 필수입니다."));
}
Map<String, Object> params = new HashMap<>();
params.put("user_id", userId);
params.put("menu_objid", String.valueOf(menuObjid));
params.put("sort_order", body.get("sort_order"));
return ResponseEntity.ok(ApiResponse.success(
favoritesService.insertFavorite(params),
"즐겨찾기 추가 성공"));
}
/**
* DELETE /api/favorites/menus/{menuObjid}
* 즐겨찾기 제거.
*/
@DeleteMapping("/menus/{menuObjid}")
public ResponseEntity<ApiResponse<Map<String, Object>>> removeFavorite(
@RequestAttribute("user_id") String userId,
@PathVariable String menuObjid) {
Map<String, Object> params = new HashMap<>();
params.put("user_id", userId);
params.put("menu_objid", menuObjid);
int affected = favoritesService.deleteFavorite(params);
Map<String, Object> result = new HashMap<>();
result.put("deleted", affected);
return ResponseEntity.ok(ApiResponse.success(result, "즐겨찾기 제거 성공"));
}
}
@@ -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);
@@ -0,0 +1,175 @@
package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.service.SubstituteService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
/**
* 대무자(代務者) 관리 API.
*
* Spec: .omc/specs/deep-dive-user-substitute-management.md
* Plan: .omc/plans/autopilot-impl.md (T4)
*
* 정책:
* - GET /mine 은 본인 read-only (누구나 가능)
* - 나머지는 관리자(ADMIN/SUPER_ADMIN) 만 — Service 의 requireAdmin 이 2차 방어
*/
@RestController
@RequestMapping("/api/substitutes")
@RequiredArgsConstructor
@Slf4j
public class SubstituteController {
private final SubstituteService substituteService;
// ─────────────────────────────────────────────────────────────
// 조회 — 관리자
// ─────────────────────────────────────────────────────────────
@GetMapping
public ResponseEntity<ApiResponse<Map<String, Object>>> getList(
@RequestParam Map<String, Object> params,
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("role") String role) {
params.put("company_code", companyCode);
params.put("role", role);
try {
return ResponseEntity.ok(ApiResponse.success(substituteService.getSubstituteList(params)));
} catch (AccessDeniedException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.error(e.getMessage()));
}
}
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getOne(
@PathVariable("id") Long substituteId,
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("role") String role) {
if (!"ADMIN".equals(role) && !"COMPANY_ADMIN".equals(role) && !"SUPER_ADMIN".equals(role)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("관리자만 조회할 수 있습니다."));
}
Map<String, Object> params = new HashMap<>();
params.put("substitute_id", substituteId);
params.put("company_code", companyCode);
try {
return ResponseEntity.ok(ApiResponse.success(substituteService.getSubstituteInfo(params)));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponse.error(e.getMessage()));
}
}
// ─────────────────────────────────────────────────────────────
// 본인 조회 — ProfileModal read-only
// ─────────────────────────────────────────────────────────────
@GetMapping("/mine")
public ResponseEntity<ApiResponse<Map<String, Object>>> getMine(
@RequestAttribute("user_id") String userId,
@RequestAttribute("company_code") String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("user_id", userId);
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(substituteService.getMySubstitutes(params)));
}
// ─────────────────────────────────────────────────────────────
// 변경 — 관리자
// ─────────────────────────────────────────────────────────────
@PostMapping
public ResponseEntity<ApiResponse<Map<String, Object>>> create(
@RequestBody Map<String, Object> body,
@RequestAttribute("user_id") String userId,
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("role") String role) {
body.put("company_code", companyCode);
body.put("role", role);
body.put("created_by", userId);
try {
Map<String, Object> created = substituteService.insertSubstitute(body);
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success(created, "대무자가 지정되었습니다."));
} catch (AccessDeniedException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.error(e.getMessage()));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage()));
} catch (Exception e) {
log.error("대무자 등록 오류", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("대무자 등록 중 오류가 발생했습니다."));
}
}
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<Map<String, Object>>> update(
@PathVariable("id") Long substituteId,
@RequestBody Map<String, Object> body,
@RequestAttribute("user_id") String userId,
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("role") String role) {
body.put("substitute_id", substituteId);
body.put("company_code", companyCode);
body.put("role", role);
body.put("updated_by", userId);
try {
return ResponseEntity.ok(
ApiResponse.success(substituteService.updateSubstitute(body), "대무 설정이 수정되었습니다."));
} catch (AccessDeniedException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.error(e.getMessage()));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage()));
} catch (Exception e) {
log.error("대무자 수정 오류", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("대무자 수정 중 오류가 발생했습니다."));
}
}
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> delete(
@PathVariable("id") Long substituteId,
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("role") String role) {
Map<String, Object> params = new HashMap<>();
params.put("substitute_id", substituteId);
params.put("company_code", companyCode);
params.put("role", role);
try {
substituteService.deleteSubstitute(params);
return ResponseEntity.ok(ApiResponse.success(null, "대무 설정이 해지되었습니다."));
} catch (AccessDeniedException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.error(e.getMessage()));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponse.error(e.getMessage()));
} catch (Exception e) {
log.error("대무자 해지 오류", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("대무자 해지 중 오류가 발생했습니다."));
}
}
// ─────────────────────────────────────────────────────────────
// 사전 검증 — UI 가 등록 직전 호출
// ─────────────────────────────────────────────────────────────
@PostMapping("/check-overlap")
public ResponseEntity<ApiResponse<Map<String, Object>>> checkOverlap(
@RequestBody Map<String, Object> body,
@RequestAttribute("company_code") String companyCode) {
body.put("company_code", companyCode);
int cnt = substituteService.checkOverlap(body);
Map<String, Object> result = new HashMap<>();
result.put("overlap", cnt > 0);
result.put("count", cnt);
return ResponseEntity.ok(ApiResponse.success(result));
}
}
@@ -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);
}
}
@@ -0,0 +1,228 @@
package com.erp.crosstenant;
import com.erp.tenant.DbContextHolder;
import com.erp.tenant.TenantDataSourceFactory;
import com.erp.tenant.TenantDbSettings;
import com.erp.tenant.TenantRoutingDataSource;
import com.zaxxer.hikari.HikariDataSource;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* SUPER_ADMIN 의 어드민 14개 메뉴 전사 합산용 fan-out 집계기.
*
* 호출자(컨트롤러)는 {@link #fanOut(String, Map)} 에 cross-tenant 전용 mapper id 와
* 파라미터를 넘긴다. Aggregator 가 메타 DB 에서 활성 회사 목록을 가져온 뒤,
* 회사마다 {@link DbContextHolder} 를 잠깐 그 회사 DB 로 바꿔 같은 SELECT 를 돌리고,
* 모든 응답 행에 {@code company_code} 를 박아 머지한다.
*
* 핵심 원칙 (설계서 §3, §5.3):
* - 한 회사 실패해도 전체는 진행 (fail-open). 실패 카운트만 누적.
* - 모든 행에 {@code company_code} 추가 (응답 측에서 회사 필터/그룹 가능하도록).
* - SELECT 만. UPDATE/DELETE 는 회사 도메인 컨텍스트로 위임.
* - 풀 정책 무변경 — 회사 풀 {@code minIdle=0} 그대로 유지.
*
* 1차 구현은 직렬·캐시 OFF (설계서 §7.2 — N≤20 까진 충분).
*
* @see com.erp.crosstenant.CrossTenantContext
* @see com.erp.tenant.TenantRoutingDataSource
* @see com.erp.provisioning.CompanyStatsService // raw JDBC 1세대 패턴 (참고용)
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class CrossTenantAggregator {
private final SqlSession sqlSession;
private final TenantRoutingDataSource routingDataSource;
private final TenantDbSettings tenantDbSettings;
/** Aggregator 결과 봉투. 컨트롤러는 이걸 그대로 ApiResponse.data 로 감싸 반환. */
public static class Result {
public final List<Map<String, Object>> rows;
public final int companies_queried;
public final int companies_failed;
public final List<String> failed_company_codes;
public final List<String> truncated_company_codes;
public Result(List<Map<String, Object>> rows,
int companies_queried,
int companies_failed,
List<String> failed_company_codes,
List<String> truncated_company_codes) {
this.rows = rows;
this.companies_queried = companies_queried;
this.companies_failed = companies_failed;
this.failed_company_codes = failed_company_codes;
this.truncated_company_codes = truncated_company_codes;
}
public List<Map<String, Object>> getRows() { return rows; }
public int getCompanies_queried() { return companies_queried; }
public int getCompanies_failed() { return companies_failed; }
public List<String> getFailed_company_codes() { return failed_company_codes; }
public List<String> getTruncated_company_codes() { return truncated_company_codes; }
}
/**
* 활성 회사 N 개에 fan-out (cap 없음, 메타 포함 X).
*/
public Result fanOut(String mapperId, Map<String, Object> params) {
return fanOut(mapperId, params, null, false);
}
/**
* 활성 회사 N 개에 fan-out + 회사당 cap (메타 포함 X).
*/
public Result fanOut(String mapperId, Map<String, Object> params, Integer perCompanyLimit) {
return fanOut(mapperId, params, perCompanyLimit, false);
}
/**
* 활성 회사 N 개에 fan-out + 회사당 cap + 옵션으로 메타 DB 도 같이 조회.
* 호출자는 미리 {@link CrossTenantContext#requireSuperAdmin}, {@link CrossTenantContext#requireMetaContext}
* 통과를 확인해야 한다 (Aggregator 자체는 권한 가드 안 함 — 단일 책임).
*
* cap 동작:
* - mapper 가 {@code LIMIT #{per_company_limit_plus_one}} 으로 cap+1 만큼 가져옴.
* - Aggregator 가 회사별로 rows.size() > cap 인지 검사 → 초과면 잘라 cap 까지만 반환 + truncated 마킹.
*
* 메타 포함 (includeMeta=true):
* - 회사 fan-out 전에 메타 DB 에서 같은 mapper 한 번 더 실행.
* - 메타 행에는 {@code company_code='*'} 박힘 (시스템/공통 사용자 표시 — 기존 회사관리 화면 컨벤션과 동일).
* - 사용자관리처럼 "SUPER_ADMIN 도 관리 대상"인 도메인에서만 사용. roles/batches 등은 메타에 의미있는 데이터 없으니 false.
*
* @param mapperId cross-tenant 전용 mapper id
* @param params SQL 바인딩 파라미터
* @param perCompanyLimit 회사당 cap. null 이면 cap 없음
* @param includeMeta true 면 메타 DB 도 한 번 조회해서 결과 앞에 prepend (company_code='*')
* @return fan-out 결과
*/
public Result fanOut(String mapperId, Map<String, Object> params, Integer perCompanyLimit, boolean includeMeta) {
// 호출 직전 컨텍스트는 META 여야 한다 (CrossTenantContext.requireMetaContext 통과 후라고 가정).
List<Map<String, Object>> companies = sqlSession.selectList("provisioning.listActiveCompanies");
List<Map<String, Object>> mergedRows = new ArrayList<>();
List<String> failed = new ArrayList<>();
List<String> truncated = new ArrayList<>();
int queried = 0;
// includeMeta=true 면 메타 DB 결과를 먼저 추가 (company_code='*')
// — 회사 fan-out 들어가기 전에 META 컨텍스트 그대로에서 실행.
if (includeMeta) {
try {
List<Map<String, Object>> metaRows = params == null
? sqlSession.selectList(mapperId)
: sqlSession.selectList(mapperId, params);
if (perCompanyLimit != null && metaRows.size() > perCompanyLimit) {
metaRows = new ArrayList<>(metaRows.subList(0, perCompanyLimit));
truncated.add("*");
}
for (Map<String, Object> r : metaRows) {
r.put("company_code", "*");
mergedRows.add(r);
}
} catch (Exception e) {
log.warn("[CrossTenant] meta query mapper={} failed: {}", mapperId, e.getMessage());
failed.add("*");
}
}
for (Map<String, Object> c : companies) {
String companyCode = (String) c.get("company_code");
String dbName = (String) c.get("db_name");
queried++;
try {
List<Map<String, Object>> rows = queryOne(dbName, mapperId, params);
// cap 적용 — mapper 가 cap+1 만큼 가져왔으면 cap 으로 잘라 truncated 마킹
if (perCompanyLimit != null && rows.size() > perCompanyLimit) {
rows = new ArrayList<>(rows.subList(0, perCompanyLimit));
truncated.add(companyCode);
}
for (Map<String, Object> r : rows) {
// company_code 강제 주입 — 응답 행이 어느 회사 것인지 명시.
// mapper 내부에서 박은 값이 있더라도 메타 DB 라우팅 정보로 덮어씀.
r.put("company_code", companyCode);
mergedRows.add(r);
}
} catch (Exception e) {
log.warn("[CrossTenant] mapper={} failed for company={} db={} : {}",
mapperId, companyCode, dbName, e.getMessage());
failed.add(companyCode);
}
}
return new Result(mergedRows, queried, failed.size(), failed, truncated);
}
/**
* 한 회사 DB 로 컨텍스트 잠깐 바꿔 SELECT 1번.
* finally 에서 반드시 prev 복원 — 누수되면 후속 요청이 엉뚱한 회사 DB 로 라우팅됨.
*
* ★ 핵심: TenantRoutingDataSource 의 routing map 에 회사 DB 풀이 등록돼있지 않으면
* META 로 fallback 됨. SubdomainResolverFilter 는 회사 도메인 진입 시에만 풀을 등록하므로,
* SUPER_ADMIN 이 admin 도메인에서 호출하는 cross-tenant 경로에선 풀이 안 깔려 있다.
* 따라서 매 호출마다 ensureTenantPool 로 lazy 생성 (이미 있으면 no-op).
*/
private List<Map<String, Object>> queryOne(String dbName, String mapperId, Map<String, Object> params) {
ensureTenantPool(dbName);
String prev = DbContextHolder.get();
try {
DbContextHolder.set(dbName);
return params == null
? sqlSession.selectList(mapperId)
: sqlSession.selectList(mapperId, params);
} finally {
if (prev == null) {
DbContextHolder.clear();
} else {
DbContextHolder.set(prev);
}
}
}
/**
* 회사 DB 풀이 없으면 최초 1회 생성. minIdle=0 정책은 Factory 가 책임.
* SubdomainResolverFilter.ensureTenantPool 과 동일한 패턴.
*/
private void ensureTenantPool(String dbName) {
if (routingDataSource.hasTenant(dbName)) return;
synchronized (routingDataSource) {
if (routingDataSource.hasTenant(dbName)) return;
HikariDataSource ds = TenantDataSourceFactory.createTenant(
tenantDbSettings.buildJdbcUrl(dbName),
tenantDbSettings.username(),
tenantDbSettings.password(),
dbName);
routingDataSource.addTenant(dbName, ds);
}
}
/**
* Phase A 스모크 테스트 전용 — Aggregator 우회하고 메타 DB 활성 회사 목록만 반환.
* 컨트롤러가 가드 통과 검증 + mapper 등록 확인용으로 호출.
*/
public List<Map<String, Object>> listActiveCompaniesForSmokeTest() {
List<Map<String, Object>> rows = sqlSession.selectList("provisioning.listActiveCompanies");
// 응답 키 순서 안정화 (Map 직렬화 시 순서 보장 위해 LinkedHashMap 으로 복사)
List<Map<String, Object>> out = new ArrayList<>(rows.size());
for (Map<String, Object> r : rows) {
Map<String, Object> ordered = new LinkedHashMap<>();
ordered.put("company_code", r.get("company_code"));
ordered.put("company_name", r.get("company_name"));
ordered.put("db_name", r.get("db_name"));
out.add(ordered);
}
return out;
}
}
@@ -0,0 +1,59 @@
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 어드민 엔드포인트 진입 가드.
*
* 정적 헬퍼 두 개. 컨트롤러는 이 둘을 호출해 boolean 으로 검사 후
* 명시적으로 {@link org.springframework.http.ResponseEntity} 반환한다.
* 예외 throw 방식을 안 쓰는 이유 — {@link com.erp.config.GlobalExceptionHandler} 의
* catch-all 핸들러가 모든 예외를 500 으로 변환하므로, 가드 결과를 정확한 status code 로
* 내려주려면 컨트롤러가 직접 결정해야 함.
*
* SecurityConfig 단계에서 매처로 가두지 않는 이유는 기존 95개 컨트롤러가
* permitAll 로 동작 중이기 때문 ({@code SecurityConfig} L52~56 코멘트 참조).
*
* @see com.erp.crosstenant.CrossTenantAggregator
* @see com.erp.tenant.DbContextHolder
*/
public final class CrossTenantContext {
public static final String ROLE_SUPER_ADMIN = "SUPER_ADMIN";
private CrossTenantContext() {}
/**
* JWT 가 세팅한 role attribute 가 SUPER_ADMIN 인지.
* JwtAuthenticationFilter 가 토큰 검증 후 {@code request.setAttribute("role", userType)} 박음.
* 토큰 없거나 role 미스매치면 false.
*/
public static boolean isSuperAdmin(HttpServletRequest request) {
Object role = request.getAttribute("role");
return ROLE_SUPER_ADMIN.equals(role);
}
/**
* 현재 요청이 META DB 컨텍스트인지.
* SubdomainResolverFilter 가 admin.invyone.com / 메인 도메인일 때 setMeta() 박음.
* 회사 도메인 (qnc.invyone.com 등) 에서 호출되면 false.
*/
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");
}
}
}
@@ -0,0 +1,260 @@
package com.erp.crosstenant;
import com.erp.dto.ApiResponse;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* SUPER_ADMIN 의 cross-tenant 어드민 합산 엔드포인트.
*
* 모든 엔드포인트는 진입 시 두 가드를 통과해야 한다:
* 1. JWT role == "SUPER_ADMIN" → 미통과 시 403
* 2. 현재 컨텍스트 == META DB → 미통과 시 400
* 통과 후 {@link CrossTenantAggregator#fanOut} 으로 회사 N 개에 fan-out.
*
* Phase A (2026-04-27): 인프라만. 스모크 테스트용 {@code /_active-companies} 엔드포인트 1개.
* Phase B 부터 {@code /users}, {@code /menus} ... 14개 메뉴 fan-out 엔드포인트 추가 예정.
*
* 라우팅 규약: admin.invyone.com 또는 메인 도메인(메타 컨텍스트) 에서만 호출 가능.
*/
@RestController
@RequestMapping("/api/admin/cross-tenant")
@RequiredArgsConstructor
@Slf4j
public class CrossTenantController {
/**
* 회사당 cap 디폴트. cross-tenant 는 "전사 둘러보기" 용이라 정확한 페이지네이션 불필요 —
* 200건 넘으면 검색으로 좁히거나 회사 도메인 단일 모드로 전환하는 게 본 설계의 의도.
* 호출자가 ?per_company_limit= 으로 override 가능.
*/
private static final int DEFAULT_PER_COMPANY_LIMIT = 200;
private static final int MAX_PER_COMPANY_LIMIT = 2000; // 안전 가드
private final CrossTenantAggregator aggregator;
/**
* Phase A 스모크 테스트 엔드포인트.
*
* 가드 두 개 통과 후 메타 DB 의 {@code COMPANY_MNG} 에서 활성 회사 목록 반환.
* Aggregator 의 fan-out 자체는 호출하지 않음 — Phase B 에서 첫 mapper (listUsers) 추가 시 활성화.
*
* 검증 항목:
* - 컨트롤러 라우팅 정상
* - SUPER_ADMIN 가드 (다른 role 이면 403)
* - META 컨텍스트 가드 (회사 도메인이면 400)
* - {@code provisioning.listActiveCompanies} mapper 등록 확인
*/
@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()));
}
if (!CrossTenantContext.isMetaContext()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error("cross_tenant_requires_meta_context", request.getRequestURI()));
}
List<Map<String, Object>> rows = aggregator.listActiveCompaniesForSmokeTest();
Map<String, Object> data = new LinkedHashMap<>();
data.put("rows", rows);
data.put("total", rows.size());
return ResponseEntity.ok(ApiResponse.success(data, "Phase A smoke test ok"));
}
/**
* GET /api/admin/cross-tenant/users
*
* 활성 회사 N 개에 fan-out 으로 사용자 목록 합산.
* 응답 행마다 {@code company_code} 박혀있어 화면 측에서 회사 컬럼/필터 가능.
*
* 지원 파라미터: search, status, dept_code (단일 회사 화면과 동일).
* 페이지네이션은 1차 구현에선 비지원 — 회사당 전체 반환 후 클라이언트에서 페이지네이션 (설계서 §9.3).
*
* 회사 한 곳이 실패해도 나머지는 반환 (실패 격리). 응답에 {@code companies_failed} +
* 헤더 {@code X-CrossTenant-Failed} 로 어떤 회사가 빠졌는지 명시.
*/
@GetMapping("/users")
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()));
}
if (!CrossTenantContext.isMetaContext()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error("cross_tenant_requires_meta_context", request.getRequestURI()));
}
// search 파라미터 ILIKE 패턴화 — admin.selectUserList 와 동일한 변환 규칙 (양쪽 % 감쌈)
Map<String, Object> params = new HashMap<>(queryParams);
Object rawSearch = params.get("search");
if (rawSearch != null && !String.valueOf(rawSearch).isBlank()) {
params.put("search", "%" + rawSearch + "%");
}
int perCompanyLimit = resolvePerCompanyLimit(params);
params.put("per_company_limit_plus_one", perCompanyLimit + 1);
// ★ /users 는 includeMeta=true — 메타 DB 의 SUPER_ADMIN 들도 함께 반환 (company_code='*').
// SUPER_ADMIN 자기 자신들도 어디선가 관리되어야 한다는 요구. roles/batches/lang-keys 는 메타에 의미있는 데이터 없으니 false.
CrossTenantAggregator.Result result = aggregator.fanOut(
"admin-cross-tenant.listUsers", params, perCompanyLimit, true);
return buildResponse(result, perCompanyLimit);
}
/**
* GET /api/admin/cross-tenant/roles
*
* 활성 회사 N 개에 fan-out 으로 권한 그룹 목록 합산.
* 단일 회사 GET /api/roles 와 동일한 컬럼 + 행마다 company_code 추가.
*
* 지원 파라미터: search (AUTH_NAME ILIKE 검색).
*/
@GetMapping("/roles")
public ResponseEntity<ApiResponse<Map<String, Object>>> listRoles(
HttpServletRequest request,
@RequestParam Map<String, Object> queryParams) {
return runFanOut(request, queryParams, "admin-cross-tenant.listRoleGroups", true);
}
/**
* GET /api/admin/cross-tenant/batches
*
* 활성 회사 N 개에 fan-out 으로 배치 목록 합산. 행마다 company_code 박힘.
* 지원 파라미터: search, is_active.
*/
@GetMapping("/batches")
public ResponseEntity<ApiResponse<Map<String, Object>>> listBatches(
HttpServletRequest request,
@RequestParam Map<String, Object> queryParams) {
// 배치는 search 를 SQL 안에서 '%' || #{search} || '%' 로 감싸므로 컨트롤러 변환 불필요
return runFanOut(request, queryParams, "admin-cross-tenant.listBatches", false);
}
/**
* GET /api/admin/cross-tenant/lang-keys
*
* 활성 회사 N 개에 fan-out 으로 다국어 키 목록 합산. 행마다 company_code 박힘.
* 지원 파라미터: search, menu_code.
*/
@GetMapping("/lang-keys")
public ResponseEntity<ApiResponse<Map<String, Object>>> listLangKeys(
HttpServletRequest request,
@RequestParam Map<String, Object> queryParams) {
return runFanOut(request, queryParams, "admin-cross-tenant.listLangKeys", true);
}
/**
* 공통 fan-out 실행 헬퍼.
*
* @param wrapSearchWithPercent true 면 search 파라미터를 컨트롤러에서 % 로 감쌈
* (mapper 가 그대로 ILIKE 에 넣는 방식 — listUsers, listRoleGroups, listLangKeys).
* false 면 mapper 안에서 '%' || ? || '%' 로 직접 감싸는 케이스 (listBatches).
*/
private ResponseEntity<ApiResponse<Map<String, Object>>> runFanOut(
HttpServletRequest request,
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()));
}
if (!CrossTenantContext.isMetaContext()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error("cross_tenant_requires_meta_context", request.getRequestURI()));
}
Map<String, Object> params = new HashMap<>(queryParams);
if (wrapSearchWithPercent) {
Object rawSearch = params.get("search");
if (rawSearch != null && !String.valueOf(rawSearch).isBlank()) {
params.put("search", "%" + rawSearch + "%");
}
}
int perCompanyLimit = resolvePerCompanyLimit(params);
params.put("per_company_limit_plus_one", perCompanyLimit + 1);
CrossTenantAggregator.Result result = aggregator.fanOut(mapperId, params, perCompanyLimit);
return buildResponse(result, perCompanyLimit);
}
/**
* 회사당 cap 결정 — 쿼리 파라미터 {@code per_company_limit} 가 있으면 사용 (1~MAX 사이 클램프),
* 없으면 디폴트 200.
*/
private int resolvePerCompanyLimit(Map<String, Object> params) {
Object raw = params.get("per_company_limit");
if (raw == null) return DEFAULT_PER_COMPANY_LIMIT;
try {
int v = Integer.parseInt(String.valueOf(raw));
if (v < 1) return DEFAULT_PER_COMPANY_LIMIT;
if (v > MAX_PER_COMPANY_LIMIT) return MAX_PER_COMPANY_LIMIT;
return v;
} catch (NumberFormatException e) {
return DEFAULT_PER_COMPANY_LIMIT;
}
}
/**
* Aggregator 결과 → 응답 봉투. truncated 정보 포함.
* 응답 헤더에도 X-CrossTenant-Failed / X-CrossTenant-Truncated 박아 디버깅 편의.
*/
private ResponseEntity<ApiResponse<Map<String, Object>>> buildResponse(
CrossTenantAggregator.Result result, int perCompanyLimit) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("rows", result.rows);
data.put("total", result.rows.size());
data.put("companies_queried", result.companies_queried);
data.put("companies_failed", result.companies_failed);
data.put("truncated", !result.truncated_company_codes.isEmpty());
data.put("truncated_company_codes", result.truncated_company_codes);
data.put("per_company_limit", perCompanyLimit);
ResponseEntity.BodyBuilder builder = ResponseEntity.ok();
if (!result.failed_company_codes.isEmpty()) {
builder.header("X-CrossTenant-Failed", String.join(",", result.failed_company_codes));
}
if (!result.truncated_company_codes.isEmpty()) {
builder.header("X-CrossTenant-Truncated", String.join(",", result.truncated_company_codes));
}
return builder.body(ApiResponse.success(data));
}
}
@@ -0,0 +1,89 @@
package com.erp.crosstenant;
import com.erp.service.AdminService;
import jakarta.servlet.http.HttpServletRequest;
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.LinkedHashMap;
import java.util.Map;
/**
* SUPER_ADMIN 의 cross-tenant 부서 조회 — 사용자 등록/수정 폼의 "부서" dropdown 을
* 선택된 회사 DB 기준으로 채우기 위한 보조 endpoint.
*
* 단일 회사 모드의 {@code GET /api/admin/departments} 와 응답 형태 동일.
* 차이점: company_code 가 query param 으로 명시되고, 그 회사 DB 컨텍스트로 임시 전환.
*
* @see CrossTenantUserController
* @see com.erp.controller.AdminController#getDepartmentList // 단일 모드 원본
*/
@RestController
@RequestMapping("/api/admin/cross-tenant/departments")
@RequiredArgsConstructor
@Slf4j
public class CrossTenantDeptController {
private final CrossTenantExecutor executor;
private final AdminService adminService;
/**
* GET /api/admin/cross-tenant/departments?company_code=TEST02
* 응답 구조는 단일 모드와 동일: { success, data: { departments, flat_list }, total, total_count }
*/
@GetMapping
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()));
}
if (!CrossTenantContext.isMetaContext()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(errorBody("cross_tenant_requires_meta_context", request.getRequestURI()));
}
try {
Map<String, Object> serviceResult = executor.runInCompany(companyCode, () -> {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
return adminService.getDepartmentList(params);
});
int total = ((Number) serviceResult.get("total")).intValue();
Map<String, Object> data = new LinkedHashMap<>();
data.put("departments", serviceResult.get("departments"));
data.put("flat_list", serviceResult.get("flat_list"));
Map<String, Object> response = new LinkedHashMap<>();
response.put("success", true);
response.put("data", data);
response.put("message", "부서 목록 조회 성공");
response.put("total", total);
response.put("total_count", total);
return ResponseEntity.ok(response);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(errorBody(e.getMessage(), request.getRequestURI()));
}
}
private Map<String, Object> errorBody(String message, String path) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("success", false);
body.put("message", message);
body.put("path", path);
return body;
}
}
@@ -0,0 +1,116 @@
package com.erp.crosstenant;
import com.erp.tenant.DbContextHolder;
import com.erp.tenant.TenantDataSourceFactory;
import com.erp.tenant.TenantDbSettings;
import com.erp.tenant.TenantRoutingDataSource;
import com.zaxxer.hikari.HikariDataSource;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Supplier;
/**
* SUPER_ADMIN 의 cross-tenant WRITE 트랙 — 회사 컨텍스트 임시 전환 + 작업 실행 + 복원.
*
* READ 트랙({@link CrossTenantAggregator}) 와 달리 fan-out 안 함. 호출자가 명시한
* 단일 회사(company_code) DB 컨텍스트로 잠깐 전환해서 INSERT/UPDATE/DELETE 실행.
*
* 사용 패턴 (컨트롤러):
* <pre>
* if (!CrossTenantContext.isSuperAdmin(request)) return forbidden();
* if (!CrossTenantContext.isMetaContext()) return badRequest();
*
* String targetCompany = (String) body.get("company_code");
* Map&lt;String,Object&gt; result = executor.runInCompany(targetCompany, () -&gt;
* adminService.saveUser(body)
* );
* </pre>
*
* 핵심 보장:
* - 회사 풀 lazy 생성 (없으면 만들고, 이미 있으면 no-op). minIdle=0 정책 그대로.
* - finally 에서 prev 컨텍스트 복원 — 누수되면 후속 요청이 엉뚱한 회사 DB 로 라우팅.
* - 알 수 없는 company_code 면 IllegalArgumentException — 컨트롤러가 400 으로 변환.
*
* @see CrossTenantAggregator // READ 트랙 (fan-out)
* @see CrossTenantContext // 가드
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class CrossTenantExecutor {
private final SqlSession sqlSession;
private final TenantRoutingDataSource routingDataSource;
private final TenantDbSettings tenantDbSettings;
/**
* 지정 회사 DB 컨텍스트로 작업 실행. 결과 반환.
*
* @throws IllegalArgumentException company_code 가 비어있거나 active 회사가 아닐 때
*/
public <T> T runInCompany(String companyCode, Supplier<T> work) {
String dbName = resolveDbName(companyCode);
ensureTenantPool(dbName);
String prev = DbContextHolder.get();
try {
DbContextHolder.set(dbName);
log.info("[CrossTenant/Write] enter company={} db={}", companyCode, dbName);
return work.get();
} finally {
if (prev == null) {
DbContextHolder.clear();
} else {
DbContextHolder.set(prev);
}
}
}
/** 결과 없는 작업용 (Runnable 형태). */
public void runInCompany(String companyCode, Runnable work) {
runInCompany(companyCode, () -> {
work.run();
return null;
});
}
/**
* company_code → db_name 매핑. META DB 의 COMPANY_MNG 에서 active 행만 인정.
* 컨텍스트 전환 전에 호출돼야 하므로 META 컨텍스트(현재 컨텍스트)에서 실행.
*/
private String resolveDbName(String companyCode) {
if (companyCode == null || companyCode.isBlank()) {
throw new IllegalArgumentException("company_code 가 비어있음");
}
if ("*".equals(companyCode)) {
throw new IllegalArgumentException("'*' 는 cross-tenant write 대상이 아님 (메타 = SUPER_ADMIN 자신)");
}
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
String dbName = sqlSession.selectOne("provisioning.resolveDbNameByCompanyCode", params);
if (dbName == null) {
throw new IllegalArgumentException("등록되지 않았거나 비활성 회사: company_code=" + companyCode);
}
return dbName;
}
/** 회사 풀이 없으면 최초 1회 생성. SubdomainResolverFilter / Aggregator 와 동일 패턴. */
private void ensureTenantPool(String dbName) {
if (routingDataSource.hasTenant(dbName)) return;
synchronized (routingDataSource) {
if (routingDataSource.hasTenant(dbName)) return;
HikariDataSource ds = TenantDataSourceFactory.createTenant(
tenantDbSettings.buildJdbcUrl(dbName),
tenantDbSettings.username(),
tenantDbSettings.password(),
dbName);
routingDataSource.addTenant(dbName, ds);
}
}
}
@@ -0,0 +1,357 @@
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;
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;
/**
* SUPER_ADMIN 의 cross-tenant ROLE WRITE/READ 엔드포인트 — Phase 2.
*
* 권한 그룹은 회사 DB 의 AUTHORITY_MASTER, 멤버/메뉴 권한도 회사 DB 내부 테이블.
* 어느 회사의 권한 그룹인지 알아야 라우팅 가능 → 모든 endpoint 가 company_code 필수
* (body 또는 query param).
*
* 단일 회사 모드 endpoint ({@link com.erp.controller.RoleController}) 는 무수정.
*
* @see CrossTenantExecutor
* @see CrossTenantUserController // 같은 패턴, Phase 1
*/
@RestController
@RequestMapping("/api/admin/cross-tenant/roles")
@RequiredArgsConstructor
@Slf4j
public class CrossTenantRoleController {
private final CrossTenantExecutor executor;
private final RoleService roleService;
private final CompanyAuditLogService auditLogService;
// ── 권한 그룹 CRUD ──────────────────────────────────────────────
/**
* POST /api/admin/cross-tenant/roles
* body: { company_code, auth_name, auth_code, ... }
*/
@PostMapping
public ResponseEntity<ApiResponse<Map<String, Object>>> createRole(
HttpServletRequest request,
@RequestAttribute(value = "user_id", required = false) String writer,
@RequestBody Map<String, Object> body) {
ResponseEntity<ApiResponse<Map<String, Object>>> g = guardMap(request);
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);
params.put("writer", writer);
params.put("objid", "AM" + System.currentTimeMillis());
if (params.containsKey("role_name") && !params.containsKey("auth_name")) {
params.put("auth_name", params.get("role_name"));
}
if (params.containsKey("role_code") && !params.containsKey("auth_code")) {
params.put("auth_code", params.get("role_code"));
}
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()));
}
}
/**
* PUT /api/admin/cross-tenant/roles/{id} body: { company_code, ... }
*/
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateRole(
HttpServletRequest request,
@PathVariable String id,
@RequestAttribute(value = "user_id", required = false) String writer,
@RequestBody Map<String, Object> body) {
ResponseEntity<ApiResponse<Map<String, Object>>> g = guardMap(request);
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);
params.put("objid", id);
params.put("writer", writer);
if (params.containsKey("role_name") && !params.containsKey("auth_name")) {
params.put("auth_name", params.get("role_name"));
}
if (params.containsKey("role_code") && !params.containsKey("auth_code")) {
params.put("auth_code", params.get("role_code"));
}
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()));
}
}
/**
* DELETE /api/admin/cross-tenant/roles/{id}?company_code=TEST02
*/
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> deleteRole(
HttpServletRequest request,
@PathVariable String id,
@RequestParam("company_code") String companyCode) {
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()));
}
}
// ── 워크스페이스 / 메뉴 트리 ─────────────────────────────────
/**
* GET /api/admin/cross-tenant/roles/{id}/workspace?company_code=TEST02
* 그룹 + 멤버 + non-members + 메뉴 + 메뉴 권한 한 번에.
*/
@GetMapping("/{id}/workspace")
public ResponseEntity<ApiResponse<Map<String, Object>>> getWorkspace(
HttpServletRequest request,
@PathVariable String id,
@RequestParam("company_code") String companyCode) {
ResponseEntity<ApiResponse<Map<String, Object>>> g = guardMap(request);
if (g != null) return g;
try {
Map<String, Object> ws = executor.runInCompany(companyCode,
() -> roleService.getRoleWorkspace(id));
if (ws == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error("권한 그룹을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(ws, "워크스페이스 조회 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
}
}
/**
* GET /api/admin/cross-tenant/roles/menus/all?company_code=TEST02
* 회사 메뉴 트리 (권한 설정용 원천).
*/
@GetMapping("/menus/all")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getAllMenus(
HttpServletRequest request,
@RequestParam("company_code") String companyCode) {
ResponseEntity<ApiResponse<List<Map<String, Object>>>> g = guardList(request);
if (g != null) return g;
try {
List<Map<String, Object>> menus = executor.runInCompany(companyCode, () -> {
Map<String, Object> p = new HashMap<>();
p.put("company_code", companyCode);
return roleService.getAllMenus(p);
});
return ResponseEntity.ok(ApiResponse.success(menus, "메뉴 목록 조회 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
}
}
// ── 멤버 토글 ───────────────────────────────────────────────
/**
* POST /api/admin/cross-tenant/roles/{id}/members/{userId}?company_code=TEST02
*/
@PostMapping("/{id}/members/{userId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> addSingleMember(
HttpServletRequest request,
@PathVariable String id,
@PathVariable String userId,
@RequestAttribute(value = "user_id", required = false) String writer,
@RequestParam("company_code") String companyCode) {
ResponseEntity<ApiResponse<Map<String, Object>>> g = guardMap(request);
if (g != null) return g;
try {
Map<String, Object> result = executor.runInCompany(companyCode, () -> {
boolean inserted = roleService.addSingleRoleMember(id, userId, writer);
Map<String, Object> r = new HashMap<>();
r.put("inserted", inserted);
r.put("master_objid", id);
r.put("user_id", userId);
return r;
});
String msg = Boolean.TRUE.equals(result.get("inserted")) ? "멤버 추가 성공" : "이미 멤버입니다.";
return ResponseEntity.ok(ApiResponse.success(result, msg));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
}
}
/**
* DELETE /api/admin/cross-tenant/roles/{id}/members/{userId}?company_code=TEST02
*/
@DeleteMapping("/{id}/members/{userId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> removeSingleMember(
HttpServletRequest request,
@PathVariable String id,
@PathVariable String userId,
@RequestParam("company_code") String companyCode) {
ResponseEntity<ApiResponse<Map<String, Object>>> g = guardMap(request);
if (g != null) return g;
try {
Map<String, Object> result = executor.runInCompany(companyCode, () -> {
boolean deleted = roleService.removeSingleRoleMember(id, userId);
Map<String, Object> r = new HashMap<>();
r.put("deleted", deleted);
r.put("master_objid", id);
r.put("user_id", userId);
return r;
});
String msg = Boolean.TRUE.equals(result.get("deleted")) ? "멤버 제거 성공" : "멤버가 존재하지 않습니다.";
return ResponseEntity.ok(ApiResponse.success(result, msg));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
}
}
// ── 메뉴 권한 토글 ──────────────────────────────────────────
/**
* PATCH /api/admin/cross-tenant/roles/{id}/menu-permissions/{menuObjid}
* body: { company_code, create_yn?, read_yn?, update_yn?, delete_yn? }
*/
@PatchMapping("/{id}/menu-permissions/{menuObjid}")
public ResponseEntity<ApiResponse<Map<String, Object>>> toggleMenuPermission(
HttpServletRequest request,
@PathVariable String id,
@PathVariable String menuObjid,
@RequestAttribute(value = "user_id", required = false) String writer,
@RequestBody Map<String, Object> body) {
ResponseEntity<ApiResponse<Map<String, Object>>> g = guardMap(request);
if (g != null) return g;
String targetCompany = (String) body.get("company_code");
try {
Map<String, Object> result = executor.runInCompany(targetCompany, () -> roleService.toggleMenuPermission(
id, menuObjid,
asYn(body.get("create_yn")),
asYn(body.get("read_yn")),
asYn(body.get("update_yn")),
asYn(body.get("delete_yn")),
writer));
return ResponseEntity.ok(ApiResponse.success(result, "메뉴 권한 토글 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
}
}
// ── 가드 헬퍼 (응답 타입별로 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()));
}
if (!CrossTenantContext.isMetaContext()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error("cross_tenant_requires_meta_context", request.getRequestURI()));
}
return null;
}
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()));
}
if (!CrossTenantContext.isMetaContext()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error("cross_tenant_requires_meta_context", request.getRequestURI()));
}
return null;
}
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()));
}
if (!CrossTenantContext.isMetaContext()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error("cross_tenant_requires_meta_context", request.getRequestURI()));
}
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;
if (raw instanceof Boolean b) return b ? "Y" : "N";
String s = String.valueOf(raw).trim();
if (s.isEmpty()) return null;
if ("Y".equalsIgnoreCase(s) || "true".equalsIgnoreCase(s) || "1".equals(s)) return "Y";
if ("N".equalsIgnoreCase(s) || "false".equalsIgnoreCase(s) || "0".equals(s)) return "N";
return null;
}
}
@@ -0,0 +1,323 @@
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;
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.Map;
/**
* SUPER_ADMIN 의 cross-tenant USER WRITE 엔드포인트.
*
* 기본 패턴:
* 1. {@link CrossTenantContext#isSuperAdmin} + {@link CrossTenantContext#isMetaContext} 가드
* 2. 요청 body/path/query 에서 target {@code company_code} 추출 (필수)
* 3. {@link CrossTenantExecutor#runInCompany} 로 그 회사 DB 컨텍스트 임시 전환
* 4. 기존 {@link AdminService} 의 user write 메서드 호출 (재사용)
* 5. finally 에서 컨텍스트 복원
*
* 기존 {@code POST /api/admin/users} 등 단일 회사 모드 엔드포인트는 무수정 — 회사 도메인
* 컨텍스트에서 그대로 동작.
*
* @see CrossTenantExecutor
* @see com.erp.controller.AdminController // 단일 회사 모드 원본
*/
@RestController
@RequestMapping("/api/admin/cross-tenant/users")
@RequiredArgsConstructor
@Slf4j
public class CrossTenantUserController {
private final CrossTenantExecutor executor;
private final AdminService adminService;
private final CompanyAuditLogService auditLogService;
// ── 등록 / 수정 ─────────────────────────────────────────────────────
/**
* POST /api/admin/cross-tenant/users
* SUPER_ADMIN 이 특정 회사 사용자 등록/수정 (회사는 body.company_code 로 명시).
*/
@PostMapping
public ResponseEntity<ApiResponse<Map<String, Object>>> saveUser(
HttpServletRequest request,
@RequestBody Map<String, Object> body) {
ResponseEntity<ApiResponse<Map<String, Object>>> guard = guard(request);
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()));
}
}
/**
* POST /api/admin/cross-tenant/users/with-dept
* 사원+부서 통합 저장 (cross-tenant).
*/
@PostMapping("/with-dept")
public ResponseEntity<ApiResponse<Map<String, Object>>> saveUserWithDept(
HttpServletRequest request,
@RequestBody Map<String, Object> body) {
ResponseEntity<ApiResponse<Map<String, Object>>> guard = guard(request);
if (guard != null) return guard;
String targetCompanyCode = (String) body.get("company_code");
try {
Map<String, Object> result = executor.runInCompany(targetCompanyCode,
() -> adminService.saveUserWithDept(body));
return ResponseEntity.ok(ApiResponse.success(result, "사원+부서 저장 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
}
}
/**
* PUT /api/admin/cross-tenant/users/{userId}
* 사용자 수정 (REST). target company_code 는 body 에 명시.
*/
@PutMapping("/{userId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateUser(
HttpServletRequest request,
@PathVariable String userId,
@RequestBody Map<String, Object> body) {
ResponseEntity<ApiResponse<Map<String, Object>>> guard = guard(request);
if (guard != null) return guard;
String targetCompanyCode = (String) body.get("company_code");
body.put("user_id", userId);
try {
Map<String, Object> result = executor.runInCompany(targetCompanyCode,
() -> adminService.saveUser(body));
return ResponseEntity.ok(ApiResponse.success(result, "사용자 수정 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
}
}
/**
* DELETE /api/admin/cross-tenant/users/{userId}?company_code=TEST01
* 사용자 삭제 (비활성화). target company_code 는 query param.
*/
@DeleteMapping("/{userId}")
public ResponseEntity<ApiResponse<Void>> deleteUser(
HttpServletRequest request,
@PathVariable String userId,
@RequestParam("company_code") String companyCode) {
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);
if (existing == null) {
throw new IllegalArgumentException("사용자를 찾을 수 없습니다: " + userId);
}
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()));
}
}
/**
* PATCH /api/admin/cross-tenant/users/{userId}/status
* 사용자 상태 변경. body: { "status": "active|inactive", "company_code": "TEST01" }
*/
@PatchMapping("/{userId}/status")
public ResponseEntity<ApiResponse<Void>> changeUserStatus(
HttpServletRequest request,
@PathVariable String userId,
@RequestBody Map<String, Object> body) {
ResponseEntity<ApiResponse<Void>> guard = guardVoid(request);
if (guard != null) return guard;
String targetCompanyCode = (String) body.get("company_code");
String status = (String) body.get("status");
try {
executor.runInCompany(targetCompanyCode, () ->
adminService.changeUserStatus(userId, status));
return ResponseEntity.ok(ApiResponse.success(null, "사용자 상태 변경 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage()));
}
}
/**
* POST /api/admin/cross-tenant/users/reset-password
* body: { "user_id": "...", "company_code": "TEST01" }
*/
@PostMapping("/reset-password")
public ResponseEntity<ApiResponse<Void>> resetUserPassword(
HttpServletRequest request,
@RequestBody Map<String, Object> body) {
ResponseEntity<ApiResponse<Void>> guard = guardVoid(request);
if (guard != null) return guard;
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()));
}
}
// ── READ 보강 (단건 조회 / 중복확인 / 이력) ───────────────────────
/**
* GET /api/admin/cross-tenant/users/{userId}?company_code=TEST01
* 단건 조회 — 회사 컨텍스트로 가서 USER_INFO 단건.
*/
@GetMapping("/{userId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getUserInfo(
HttpServletRequest request,
@PathVariable String userId,
@RequestParam("company_code") String companyCode) {
ResponseEntity<ApiResponse<Map<String, Object>>> guard = guard(request);
if (guard != null) return guard;
try {
Map<String, Object> info = executor.runInCompany(companyCode,
() -> adminService.getUserInfo(userId));
if (info == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error("사용자를 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(info));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
}
}
/**
* GET /api/admin/cross-tenant/users/{userId}/with-dept?company_code=TEST01
*/
@GetMapping("/{userId}/with-dept")
public ResponseEntity<ApiResponse<Map<String, Object>>> getUserWithDept(
HttpServletRequest request,
@PathVariable String userId,
@RequestParam("company_code") String companyCode) {
ResponseEntity<ApiResponse<Map<String, Object>>> guard = guard(request);
if (guard != null) return guard;
try {
Map<String, Object> result = executor.runInCompany(companyCode, () -> {
Map<String, Object> p = new HashMap<>();
p.put("company_code", companyCode);
p.put("user_id", userId);
return adminService.getUserWithDept(p);
});
if (result == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error("사용자를 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(result));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
}
}
/**
* POST /api/admin/cross-tenant/users/check-duplicate
* body: { "user_id": "...", "company_code": "TEST01" }
*/
@PostMapping("/check-duplicate")
public ResponseEntity<ApiResponse<Map<String, Object>>> checkDuplicateUserId(
HttpServletRequest request,
@RequestBody Map<String, Object> body) {
ResponseEntity<ApiResponse<Map<String, Object>>> guard = guard(request);
if (guard != null) return guard;
String targetCompanyCode = (String) body.get("company_code");
String userId = (String) body.get("user_id");
try {
Map<String, Object> result = executor.runInCompany(targetCompanyCode, () -> {
Map<String, Object> existing = adminService.getUserInfo(userId);
Map<String, Object> out = new HashMap<>();
out.put("is_duplicate", existing != null);
return out;
});
return ResponseEntity.ok(ApiResponse.success(result, "아이디 중복 확인 완료"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
}
}
// ── 가드 헬퍼 ───────────────────────────────────────────────────
/** 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()));
}
if (!CrossTenantContext.isMetaContext()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error("cross_tenant_requires_meta_context", request.getRequestURI()));
}
return null;
}
/** 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()));
}
if (!CrossTenantContext.isMetaContext()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error("cross_tenant_requires_meta_context", request.getRequestURI()));
}
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;
}
}
@@ -48,15 +48,17 @@ public class StartupSchemaMigrator {
// 메타 DB 는 Flyway V017 로도 적용되지만 프로비저닝된 테넌트 DB 는
// 회사 생성 시점 스냅샷이 박혀있으므로 부팅 때 모든 활성 DB 에 동기화.
// SEQ 만 갱신 → 멱등.
// 타입 주의: SEQ 가 varchar 이므로 THEN 값도 문자열 리터럴로 줄 것
// (정수 리터럴이면 ELSE SEQ 와 CASE 타입 불일치 42804 발생).
"""
UPDATE MENU_INFO
SET SEQ = CASE MENU_NAME_KOR
WHEN '회사관리' THEN 100
WHEN '부서관리' THEN 200
WHEN '사용자관리' THEN 300
WHEN '메뉴관리' THEN 400
WHEN '권한관리' THEN 500
WHEN '권한 그룹관리' THEN 600
WHEN '회사관리' THEN '100'
WHEN '부서관리' THEN '200'
WHEN '사용자관리' THEN '300'
WHEN '메뉴관리' THEN '400'
WHEN '권한관리' THEN '500'
WHEN '권한 그룹관리' THEN '600'
ELSE SEQ
END
WHERE MENU_TYPE = '0'
@@ -67,6 +69,243 @@ public class StartupSchemaMigrator {
'회사관리', '부서관리', '사용자관리',
'메뉴관리', '권한관리', '권한 그룹관리'
)
""",
// V018 (1) 부서관리 V1 - DEPT_INFO 소프트삭제 컬럼.
// DELETE 동작이 hard 가 아니라 DELETED_AT = NOW() 로 전환됨.
// 메타 DB 는 Flyway V018 로도 적용되지만 프로비저닝된 테넌트 DB 는 부팅 때 동기화.
"ALTER TABLE DEPT_INFO ADD COLUMN IF NOT EXISTS DELETED_AT TIMESTAMP NULL",
// V018 (2) DEPT_INFO 활성 부서 부분 인덱스 (DELETED_AT IS NULL 쿼리 가속)
"CREATE INDEX IF NOT EXISTS IDX_DEPT_INFO_ACTIVE ON DEPT_INFO (COMPANY_CODE, PARENT_DEPT_CODE) WHERE DELETED_AT IS NULL",
// V019: 부서관리 V1 - DEPT_INFO 미사용/중복 컬럼 정리.
// 메타 DB 는 Flyway V019 로도 적용되지만 프로비저닝된 테넌트 DB 는 부팅 때 동기화.
// DROP IF EXISTS 로 멱등성 보장.
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS MASTER_SABUN",
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS MASTER_USER_ID",
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS ORG_HEAD",
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS LOCATION_NAME",
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS SALES_YN",
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS SHOW_IN_CHART",
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS ERP_MANAGED",
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS DATA_TYPE",
// V020: 사용자별 메뉴 즐겨찾기 테이블.
// 메타 DB 는 Flyway V020 으로도 적용되지만 프로비저닝된 테넌트 DB 는 부팅 때 동기화.
// CREATE IF NOT EXISTS 로 멱등성 보장.
"""
CREATE TABLE IF NOT EXISTS USER_MENU_FAVORITES (
OBJID BIGSERIAL PRIMARY KEY,
USER_ID VARCHAR(100) NOT NULL,
MENU_OBJID VARCHAR(50) NOT NULL,
SORT_ORDER INTEGER NOT NULL DEFAULT 0,
CREATED_AT TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT UQ_USER_MENU_FAVORITES UNIQUE (USER_ID, MENU_OBJID)
)
""",
"CREATE INDEX IF NOT EXISTS IDX_USER_MENU_FAVORITES_USER ON USER_MENU_FAVORITES (USER_ID)",
// RUN_086 (1) btree_gist 확장 — USER_SUBSTITUTES 의 EXCLUDE 제약 의존성
"CREATE EXTENSION IF NOT EXISTS btree_gist",
// RUN_086 (2) 대무자(代務者) 관리 테이블
// self-위임 차단 (CHECK), 같은 쌍 활성 기간 겹침 차단 (EXCLUDE).
// 재실행 시 IF NOT EXISTS 로 안전. EXCLUDE/CHECK 제약은 첫 생성 때만 적용.
"""
CREATE TABLE IF NOT EXISTS USER_SUBSTITUTES (
SUBSTITUTE_ID BIGSERIAL PRIMARY KEY,
COMPANY_CODE VARCHAR(50) NOT NULL,
ORIGINAL_USER_ID VARCHAR(50) NOT NULL,
PROXY_USER_ID VARCHAR(50) NOT NULL,
START_DATE DATE NULL,
END_DATE DATE NOT NULL,
REASON VARCHAR(500),
IS_ACTIVE BOOLEAN NOT NULL DEFAULT TRUE,
CREATED_BY VARCHAR(50),
CREATED_DATE TIMESTAMP NOT NULL DEFAULT NOW(),
UPDATED_BY VARCHAR(50),
UPDATED_DATE TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT chk_user_substitutes_self
CHECK (ORIGINAL_USER_ID <> PROXY_USER_ID),
CONSTRAINT chk_user_substitutes_date
CHECK (START_DATE IS NULL OR START_DATE <= END_DATE),
CONSTRAINT excl_user_substitutes_overlap
EXCLUDE USING gist (
COMPANY_CODE WITH =,
ORIGINAL_USER_ID WITH =,
PROXY_USER_ID WITH =,
daterange(START_DATE, END_DATE, '[]') WITH &&
) WHERE (IS_ACTIVE = TRUE)
)
""",
// RUN_086 (3) USER_SUBSTITUTES 인덱스 — Filter 핫패스 + 조회 가속
"CREATE INDEX IF NOT EXISTS idx_user_substitutes_original ON USER_SUBSTITUTES (COMPANY_CODE, ORIGINAL_USER_ID, IS_ACTIVE)",
"CREATE INDEX IF NOT EXISTS idx_user_substitutes_proxy ON USER_SUBSTITUTES (COMPANY_CODE, PROXY_USER_ID, IS_ACTIVE)",
// RUN_086 (4) SYSTEM_AUDIT_LOG — 처리자(actual processor) 분리 기록 컬럼
"ALTER TABLE SYSTEM_AUDIT_LOG ADD COLUMN IF NOT EXISTS PROCESSOR_ID VARCHAR(50)",
"ALTER TABLE SYSTEM_AUDIT_LOG ADD COLUMN IF NOT EXISTS PROCESSOR_NAME VARCHAR(100)",
// RUN_086 (5) APPROVAL_PROXY_SETTINGS → USER_SUBSTITUTES 1회 데이터 복사 (idempotent)
// 기존 운영 데이터 보존 + 어댑터 read 경로가 즉시 동작하도록.
// IS_ACTIVE 매핑: APPROVAL_PROXY_SETTINGS 의 CHAR('Y'/'N') → BOOLEAN.
// 메타데이터(created/updated) 는 원본 컬럼 의존 없이 'migration_086' + NOW() 로 고정
// (APPROVAL_PROXY_SETTINGS 의 timestamp 컬럼명이 환경별로 다를 수 있어 안전한 default 채택).
"""
INSERT INTO USER_SUBSTITUTES (
COMPANY_CODE, ORIGINAL_USER_ID, PROXY_USER_ID,
START_DATE, END_DATE, REASON, IS_ACTIVE,
CREATED_BY, CREATED_DATE, UPDATED_BY, UPDATED_DATE
)
SELECT
p.COMPANY_CODE, p.ORIGINAL_USER_ID, p.PROXY_USER_ID,
CAST(NULLIF(p.START_DATE, '') AS DATE),
CAST(NULLIF(p.END_DATE, '') AS DATE),
p.REASON,
CASE WHEN p.IS_ACTIVE = 'Y' THEN TRUE ELSE FALSE END,
'migration_086', NOW(),
'migration_086', NOW()
FROM APPROVAL_PROXY_SETTINGS p
WHERE NULLIF(p.END_DATE, '') IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM USER_SUBSTITUTES s
WHERE s.COMPANY_CODE = p.COMPANY_CODE
AND s.ORIGINAL_USER_ID = p.ORIGINAL_USER_ID
AND s.PROXY_USER_ID = p.PROXY_USER_ID
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')
"""
);
@@ -90,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);
}
}
@@ -2,6 +2,7 @@ package com.erp.security;
import com.erp.ai.security.AiApiKeyAuthFilter;
import com.erp.ai.service.AiAgentApiKeyService;
import com.erp.service.SubstituteService;
import com.erp.tenant.CompanyResolver;
import com.erp.tenant.SubdomainResolverFilter;
import com.erp.tenant.TenantDbSettings;
@@ -37,6 +38,7 @@ public class SecurityConfig {
private final TenantRoutingDataSource tenantRoutingDataSource;
private final TenantDbSettings tenantDbSettings;
private final AiAgentApiKeyService aiAgentApiKeyService;
private final SubstituteService substituteService;
/**
* CORS 화이트리스트. 콤마 구분 문자열로 주입 (예: "http://localhost:3000,https://v1.invyone.com").
@@ -76,9 +78,12 @@ public class SecurityConfig {
// JwtAuthenticationFilter 뒤 — JWT.company_code 와 서브도메인의 company_code 대조.
.addFilterAfter(new TenantConsistencyGuardFilter(jwtTokenProvider),
JwtAuthenticationFilter.class)
// TenantConsistencyGuardFilter 뒤 — 비번 강제 변경 대기자는 허용 경로만 통과.
// TenantConsistencyGuardFilter 뒤 — 대무자(代務者) 컨텍스트 effective_user_ids 주입.
.addFilterAfter(new SubstituteContextFilter(substituteService),
TenantConsistencyGuardFilter.class)
// SubstituteContextFilter 뒤 — 비번 강제 변경 대기자는 허용 경로만 통과.
.addFilterAfter(new ForcePasswordChangeGuardFilter(jwtTokenProvider),
TenantConsistencyGuardFilter.class);
SubstituteContextFilter.class);
return http.build();
}
@@ -0,0 +1,102 @@
package com.erp.security;
import com.erp.service.SubstituteService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
/**
* 대무자(代務者) 컨텍스트 주입 필터.
*
* Spec: .omc/specs/deep-dive-user-substitute-management.md
* Plan: .omc/plans/autopilot-impl.md (T5)
*
* 동작:
* 1. /api/** 가 아니면 통과
* 2. JWT user_id / company_code attribute 없으면 통과 (비로그인)
* 3. company_code == "*" (SUPER_ADMIN pre-switch) 이면 통과 — 대무 의미 없음
* 4. substituteService.getActiveOriginalUserIds(userId, companyCode) 조회
* 5. effective_user_ids = [userId, ...originalIds] → request attribute
* 6. actual_processor_id = userId → request attribute (의미 명시 alias)
*
* 예외 처리:
* DB 조회 실패 시 effective_user_ids 를 [userId] 만 담아 통과시킨다 — 대무 컨텍스트
* 실패가 본 요청을 깨면 안 되기 때문 (가용성 우선). warn 로그 남김.
*
* 성능:
* - request 당 SELECT 1회 (인덱스 (COMPANY_CODE, PROXY_USER_ID, IS_ACTIVE) 매치, 보통 <1ms)
* - request-scope 자연 캐시 — 한 요청 내에서 attribute 만 참조하면 추가 조회 없음
*
* 필터 순서:
* SubdomainResolver → AiApiKey → Jwt → TenantConsistencyGuard → **여기** → ForcePasswordChangeGuard
*/
@Slf4j
@RequiredArgsConstructor
public class SubstituteContextFilter extends OncePerRequestFilter {
public static final String ATTR_EFFECTIVE_USER_IDS = "effective_user_ids";
public static final String ATTR_ACTUAL_PROCESSOR_ID = "actual_processor_id";
private final SubstituteService substituteService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String path = request.getRequestURI();
if (path == null || !path.startsWith("/api/")) {
chain.doFilter(request, response);
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");
// 비로그인 또는 SUPER_ADMIN pre-switch → 대무 컨텍스트 의미 없음, 통과
if (userId == null || companyCode == null || "*".equals(companyCode)) {
chain.doFilter(request, response);
return;
}
List<String> effectiveIds = new ArrayList<>();
effectiveIds.add(userId);
try {
List<String> originalIds = substituteService.getActiveOriginalUserIds(userId, companyCode);
if (originalIds != null && !originalIds.isEmpty()) {
effectiveIds.addAll(originalIds);
}
} catch (Exception e) {
// 대무 컨텍스트 조회 실패는 본 요청을 막지 않음 — 본인 권한만으로 동작
log.warn("[SubstituteContext] failed to resolve proxy context for user={}: {}",
userId, e.getMessage());
}
request.setAttribute(ATTR_EFFECTIVE_USER_IDS, effectiveIds);
request.setAttribute(ATTR_ACTUAL_PROCESSOR_ID, userId);
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");
}
}
@@ -208,10 +208,17 @@ public class AdminService extends BaseService {
}
public void resetUserPassword(String userId) {
String defaultPw = passwordEncoder.encode("Welcome1!");
resetUserPassword(userId, null);
}
public void resetUserPassword(String userId, String newPassword) {
if (userId == null || userId.isBlank()) {
throw new IllegalArgumentException("user_id 는 필수입니다");
}
String rawPw = (newPassword != null && !newPassword.isBlank()) ? newPassword : "Welcome1!";
Map<String, Object> params = new HashMap<>();
params.put("user_id", userId);
params.put("user_password", defaultPw);
params.put("user_password", passwordEncoder.encode(rawPw));
sqlSession.update("admin.updateUserPassword", params);
}
@@ -17,6 +17,26 @@ public class ApprovalService extends BaseService {
@Autowired
private ObjectMapper objectMapper;
@Autowired
private AuditLogService auditLogService;
/**
* IN (:effective_user_ids) 쿼리용 fallback.
* SubstituteContextFilter attribute 채운 경로(통합 테스트/배치 ) 에서도
* IN () SQL 에러를 막기 위해 항상 최소 [user_id] 들어가도록 한다.
*/
@SuppressWarnings("unchecked")
private void ensureEffectiveUserIds(Map<String, Object> params) {
Object v = params.get("effective_user_ids");
boolean empty = v == null || (v instanceof Collection<?> && ((Collection<?>) v).isEmpty());
if (empty) {
Object userId = params.get("user_id");
if (userId != null) {
params.put("effective_user_ids", List.of(userId));
}
}
}
//
// approval_definitions
//
@@ -149,6 +169,7 @@ public class ApprovalService extends BaseService {
//
public Map<String, Object> getRequests(Map<String, Object> params) {
ensureEffectiveUserIds(params);
int page = toInt(params.getOrDefault("page", "1"));
int limit = toInt(params.getOrDefault("limit", "20"));
params.put("page_limit", limit);
@@ -359,6 +380,7 @@ public class ApprovalService extends BaseService {
//
public List<Map<String, Object>> getMyPendingLines(Map<String, Object> params) {
ensureEffectiveUserIds(params);
return sqlSession.selectList("approval.selectMyPendingLines", params);
}
@@ -456,6 +478,24 @@ public class ApprovalService extends BaseService {
activateNextStep(requestId, stepOrder, totalSteps, lineCC, userId, comment);
}
}
// 결재 처리 audit log 대무 user_id(A) processor_id(B) 분리 기록.
// 실패는 처리를 막지 않음 (가용성 우선).
try {
Map<String, Object> auditP = new HashMap<>();
auditP.put("company_code", lineCC);
auditP.put("user_id", approverId); // 위임자 A
auditP.put("user_name", line.get("approver_name"));
auditP.put("processor_id", userId); // 실제 처리자 B
// processor_name AuditLogService USER_INFO 에서 lookup (T14)
auditP.put("action", "approval." + action);
auditP.put("resource_type", "approval_line");
auditP.put("resource_id", String.valueOf(lineId));
auditP.put("summary", "결재 " + action + (proxyFor != null ? " (대무)" : ""));
auditLogService.insertAuditLog(auditP);
} catch (Exception e) {
log.warn("결재 audit log 기록 실패 (line={}): {}", lineId, e.getMessage());
}
}
//
@@ -73,7 +73,12 @@ public class AuditLogService extends BaseService {
}
/**
* 감사 로그 1건 기록
* 감사 로그 1건 기록.
*
* PROCESSOR 처리 (대무 추적):
* - processor_id 미지정 user_id 채움 (평시 = 동일 = 본인 처리)
* - processor_id user_id 다르고 processor_name 미지정 USER_INFO 에서 lookup
* (대무 이벤트만 USER_INFO 단건 조회 1회 평시는 추가 DB 호출 없음)
*/
public void insertAuditLog(Map<String, Object> params) {
// changes가 Map이면 JSON 문자열로 직렬화
@@ -86,6 +91,26 @@ public class AuditLogService extends BaseService {
params.put("changes", null);
}
}
Object processorId = params.get("processor_id");
Object userId = params.get("user_id");
if (processorId == null) {
params.put("processor_id", userId);
if (params.get("processor_name") == null) {
params.put("processor_name", params.get("user_name"));
}
} else if (!processorId.equals(userId) && params.get("processor_name") == null) {
try {
Map<String, Object> p = new HashMap<>();
p.put("user_id", processorId);
p.put("company_code", params.get("company_code"));
String name = sqlSession.selectOne("auditLog.selectUserNameById", p);
params.put("processor_name", name);
} catch (Exception e) {
log.warn("processor_name lookup 실패 (processor_id={}): {}", processorId, e.getMessage());
}
}
sqlSession.insert("auditLog.insertAuditLog", params);
}
@@ -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;
@@ -140,6 +141,12 @@ public class DdlService extends BaseService {
transactionTemplate.execute(status -> {
jdbcTemplate.execute(ddlQuery);
String inputType = convertToInputType(column);
if (!InputTypeConstants.USER_SELECTABLE_INPUT_TYPES.contains(inputType)) {
throw new IllegalArgumentException(
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + InputTypeConstants.USER_SELECTABLE_INPUT_TYPES
+ " (받은 값: " + inputType + ")"
);
}
String detailSettings = column.containsKey("detail_settings")
? column.get("detail_settings").toString() : "{}";
Integer maxOrder = jdbcTemplate.queryForObject(
@@ -219,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
//
@@ -408,10 +488,17 @@ 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 (!InputTypeConstants.USER_SELECTABLE_INPUT_TYPES.contains(inputType)) {
throw new IllegalArgumentException(
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + InputTypeConstants.USER_SELECTABLE_INPUT_TYPES
+ " (받은 값: " + inputType + ")"
);
}
String detailSettings = col.containsKey("detail_settings")
? col.get("detail_settings").toString() : "{}";
saveColumnMeta(tableName, (String) col.get("name"), companyCode,
convertToInputType(col), detailSettings, i);
inputType, detailSettings, i);
}
}
@@ -513,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,9 +5,12 @@ 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;
import java.util.Map;
import java.util.Set;
@Service
@Slf4j
@@ -18,11 +21,22 @@ public class DepartmentService extends BaseService {
//
public List<Map<String, Object>> getDepartments(String companyCode) {
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) {
@@ -30,14 +44,38 @@ 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;
}
/** active 부서만 반환. deleted 면 null. 복구 흐름은 getDepartmentIncludingDeleted 사용 */
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);
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
@@ -65,10 +103,30 @@ public class DepartmentService extends BaseService {
? (String) company.get("company_name")
: companyCode;
// 부서 코드 생성
Map<String, Object> codeResult = sqlSession.selectOne("department.selectNextDeptNumber", null);
long nextNumber = codeResult != null ? ((Number) codeResult.get("next_number")).longValue() : 1L;
String deptCode = "DEPT_" + nextNumber;
// parent_dept_code cross-tenant / 존재 / 삭제 검증
Object parentObj = nullIfBlank(bodyParam(body, "parent_dept_code", "parent_dept_code"));
String parentCode = parentObj != null ? parentObj.toString() : null;
validateParent(parentCode, companyCode);
// 부서 코드 자동 생성 사용자 입력 받지 않음 (정책 변경 2026-05-08)
// 재시도 로직 (race condition 대비, 최대 3회)
String deptCode = null;
for (int attempt = 0; attempt < 3; attempt++) {
Map<String, Object> codeResult = sqlSession.selectOne("department.selectNextDeptNumber", null);
long nextNumber = codeResult != null && codeResult.get("next_number") != null
? ((Number) codeResult.get("next_number")).longValue()
: 1L;
String candidate = "DEPT_" + nextNumber;
Map<String, Object> existing = sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted",
Map.of("dept_code", candidate));
if (existing == null) {
deptCode = candidate;
break;
}
}
if (deptCode == null) {
throw new IllegalStateException("부서 코드 생성 실패 (동시 생성 충돌). 잠시 후 다시 시도해주세요.");
}
// 부서 생성 (전체 필드)
Map<String, Object> insertParams = new HashMap<>();
@@ -76,36 +134,32 @@ public class DepartmentService extends BaseService {
insertParams.put("dept_name", deptName);
insertParams.put("company_code", companyCode);
insertParams.put("company_name", companyName);
insertParams.put("parent_dept_code", nullIfBlank(bodyParam(body, "parent_dept_code", "parent_dept_code")));
insertParams.put("parent_dept_code", parentCode);
insertParams.put("short_name", nullIfBlank(bodyParam(body, "short_name", "short_name")));
insertParams.put("dept_type", bodyParam(body, "dept_type", "dept_type"));
insertParams.put("org_system", nullIfBlank(bodyParam(body, "org_system", "org_system")));
insertParams.put("approval_manager", nullIfBlank(bodyParam(body, "approval_manager", "approval_manager")));
insertParams.put("dept_manager", nullIfBlank(bodyParam(body, "dept_manager", "dept_manager")));
insertParams.put("org_head", nullIfBlank(bodyParam(body, "org_head", "org_head")));
insertParams.put("zipcode", nullIfBlank(bodyParam(body, "zipcode", "zipcode")));
insertParams.put("address1", nullIfBlank(bodyParam(body, "address1", "address1")));
insertParams.put("address2", nullIfBlank(bodyParam(body, "address2", "address2")));
insertParams.put("start_date", nullIfBlank(bodyParam(body, "start_date", "start_date")));
insertParams.put("end_date", nullIfBlank(bodyParam(body, "end_date", "end_date")));
insertParams.put("erp_managed", bodyParam(body, "erp_managed", "erp_managed"));
insertParams.put("show_in_chart", bodyParam(body, "show_in_chart", "show_in_chart"));
insertParams.put("sort_order", bodyParam(body, "sort_order", "sort_order"));
insertParams.put("status", bodyParam(body, "status", "status"));
// dept_info 추가 필드 (master_*, location_*, data_type, sales_yn)
insertParams.put("master_sabun", nullIfBlank(bodyParam(body, "master_sabun", "master_sabun")));
insertParams.put("master_user_id", nullIfBlank(bodyParam(body, "master_user_id", "master_user_id")));
// dept_info 추가 필드 (location 코드만 유지 V019 정리 )
insertParams.put("location", nullIfBlank(bodyParam(body, "location", "location")));
insertParams.put("location_name", nullIfBlank(bodyParam(body, "location_name", "location_name")));
insertParams.put("data_type", bodyParam(body, "data_type", "data_type"));
insertParams.put("sales_yn", bodyParam(body, "sales_yn", "sales_yn"));
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
@@ -115,70 +169,525 @@ public class DepartmentService extends BaseService {
throw new IllegalArgumentException("부서명을 입력해주세요.");
}
// 본인 dept company_code 조회 (validateParent + 중복명 검증에 사용)
Map<String, Object> existingDept = sqlSession.selectOne(
"department.selectDepartmentByCodeIncludingDeleted",
Map.of("dept_code", deptCode)
);
String deptCompanyCode = existingDept != null && existingDept.get("company_code") != null
? existingDept.get("company_code").toString()
: null;
// 사이클 가드 자기 자신/자손을 부모로 지정하려는 시도 차단
Object newParent = nullIfBlank(bodyParam(body, "parent_dept_code", "parent_dept_code"));
String newParentCode = newParent != null ? newParent.toString() : null;
// parent_dept_code cross-tenant / 존재 / 삭제 검증
if (deptCompanyCode != null) {
validateParent(newParentCode, deptCompanyCode);
}
verifyParentCycle(deptCode, newParentCode);
// 부서명 중복 검증 본인 dept_code 제외
if (deptCompanyCode != null) {
Map<String, Object> dupParams = new HashMap<>();
dupParams.put("company_code", deptCompanyCode);
dupParams.put("dept_name", deptName);
Map<String, Object> duplicate = sqlSession.selectOne("department.selectDuplicateDeptName", dupParams);
if (duplicate != null && !deptCode.equals(duplicate.get("dept_code"))) {
throw new DuplicateDeptNameException("\"" + deptName + "\" 부서가 이미 존재합니다.");
}
}
Map<String, Object> params = new HashMap<>();
params.put("dept_code", deptCode);
params.put("dept_name", deptName);
params.put("parent_dept_code", nullIfBlank(bodyParam(body, "parent_dept_code", "parent_dept_code")));
params.put("parent_dept_code", newParent);
params.put("short_name", nullIfBlank(bodyParam(body, "short_name", "short_name")));
params.put("dept_type", bodyParam(body, "dept_type", "dept_type"));
params.put("org_system", nullIfBlank(bodyParam(body, "org_system", "org_system")));
params.put("approval_manager", nullIfBlank(bodyParam(body, "approval_manager", "approval_manager")));
params.put("dept_manager", nullIfBlank(bodyParam(body, "dept_manager", "dept_manager")));
params.put("org_head", nullIfBlank(bodyParam(body, "org_head", "org_head")));
params.put("zipcode", nullIfBlank(bodyParam(body, "zipcode", "zipcode")));
params.put("address1", nullIfBlank(bodyParam(body, "address1", "address1")));
params.put("address2", nullIfBlank(bodyParam(body, "address2", "address2")));
params.put("start_date", nullIfBlank(bodyParam(body, "start_date", "start_date")));
params.put("end_date", nullIfBlank(bodyParam(body, "end_date", "end_date")));
params.put("erp_managed", bodyParam(body, "erp_managed", "erp_managed"));
params.put("show_in_chart", bodyParam(body, "show_in_chart", "show_in_chart"));
params.put("sort_order", bodyParam(body, "sort_order", "sort_order"));
params.put("status", bodyParam(body, "status", "status"));
// dept_info 추가 필드
params.put("master_sabun", nullIfBlank(bodyParam(body, "master_sabun", "master_sabun")));
params.put("master_user_id", nullIfBlank(bodyParam(body, "master_user_id", "master_user_id")));
// dept_info 추가 필드 (location 코드만 유지 V019 정리 )
params.put("location", nullIfBlank(bodyParam(body, "location", "location")));
params.put("location_name", nullIfBlank(bodyParam(body, "location_name", "location_name")));
params.put("data_type", bodyParam(body, "data_type", "data_type"));
params.put("sales_yn", bodyParam(body, "sales_yn", "sales_yn"));
int updated = sqlSession.update("department.updateDepartment", params);
if (updated == 0) {
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);
}
/**
* 부서 soft-delete (V1 slim scope).
* - hard delete 아니라 DELETED_AT = NOW() 마킹
* - USER_DEPT 행은 보존 복구 멤버 그대로 살아남
* - 활성 자식 부서가 있으면 차단 (deleted 자식은 무시)
* - 반환: 0 = soft-delete 성공 (보존된 부서원 수는 복구 시점에 재조회)
* -1 = not found / already deleted
*/
@Transactional
public int deleteDepartment(String deptCode) {
// 하위 부서 확인
// 활성 하위 부서 확인 (deleted 자식은 자식 카운트에서 제외)
Map<String, Object> childParams = new HashMap<>();
childParams.put("dept_code", deptCode);
childParams.put("include_deleted", false);
Number childCountNum = sqlSession.selectOne("department.selectChildDeptCount", childParams);
int childCount = childCountNum != null ? childCountNum.intValue() : 0;
if (childCount > 0) {
throw new IllegalStateException("하위 부서가 있는 부서는 삭제할 수 없습니다. 먼저 하위 부서를 삭제해주세요.");
}
// 부서원 삭제
Map<String, Object> memberParams = new HashMap<>();
memberParams.put("dept_code", deptCode);
int memberCount = sqlSession.delete("department.deleteUserDeptByDeptCode", memberParams);
// 부서 삭제
// soft-delete: DELETED_AT = NOW(). USER_DEPT 보존
Map<String, Object> deptParams = new HashMap<>();
deptParams.put("dept_code", deptCode);
int deleted = sqlSession.delete("department.deleteDepartment", deptParams);
if (deleted == 0) {
return -1; // not found
int updated = sqlSession.update("department.deleteDepartment", deptParams);
if (updated == 0) {
return -1; // not found 또는 이미 deleted
}
log.info("부서 삭제 성공: deptCode={}, 제외된 부서원 수={}", deptCode, memberCount);
return memberCount;
log.info("부서 soft-delete 성공: deptCode={} (USER_DEPT 행 보존)", deptCode);
return 0;
}
/**
* 부서 복구 (V1 slim scope).
* - DELETED_AT = NULL 되돌림
* - 부모가 있고 부모도 deleted 상태면 차단 (orphan 방지)
* - USER_DEPT 행은 soft-delete 시점부터 보존되어왔으므로 자동 복원됨
*/
@Transactional
public RestoreResult restoreDepartment(String deptCode) {
Map<String, Object> dept = getDepartmentIncludingDeleted(deptCode);
if (dept == null) {
return RestoreResult.NOT_FOUND;
}
if (dept.get("deleted_at") == null) {
return RestoreResult.NOT_DELETED;
}
// 부모 deleted 검증
Object parentObj = dept.get("parent_dept_code");
if (parentObj != null && !parentObj.toString().isBlank()) {
String parentCode = parentObj.toString();
Map<String, Object> parent = getDepartmentIncludingDeleted(parentCode);
if (parent != null && parent.get("deleted_at") != null) {
return RestoreResult.PARENT_DELETED;
}
}
// 동일 이름의 active 부서 중복 검증 (복구 시점)
Object companyCodeObj = dept.get("company_code");
Object deptNameObj = dept.get("dept_name");
if (companyCodeObj != null && deptNameObj != null) {
Map<String, Object> dupParams = new HashMap<>();
dupParams.put("company_code", companyCodeObj.toString());
dupParams.put("dept_name", deptNameObj.toString());
Map<String, Object> duplicate = sqlSession.selectOne("department.selectDuplicateDeptName", dupParams);
if (duplicate != null && !deptCode.equals(duplicate.get("dept_code"))) {
throw new IllegalArgumentException("동일한 이름의 활성 부서가 이미 존재합니다.");
}
}
Map<String, Object> params = new HashMap<>();
params.put("dept_code", deptCode);
int restored = sqlSession.update("department.restoreDepartment", params);
if (restored == 0) {
return RestoreResult.NOT_DELETED; // race: 동시 복구
}
log.info("부서 복구 성공: deptCode={}", deptCode);
return RestoreResult.OK;
}
public enum RestoreResult {
OK, NOT_FOUND, NOT_DELETED, PARENT_DELETED
}
/**
* parent_dept_code (a) 존재하고 (b) 같은 회사이며 (c) deleted 아닌지 검증.
* null/blank 검증 스킵 (최상위 부서).
*/
private void validateParent(String parentCode, String companyCode) {
if (parentCode == null || parentCode.isBlank()) return;
Map<String, Object> parent = sqlSession.selectOne(
"department.selectDepartmentByCodeIncludingDeleted",
Map.of("dept_code", parentCode)
);
if (parent == null) {
throw new IllegalArgumentException("상위 부서를 찾을 수 없습니다: " + parentCode);
}
if (parent.get("deleted_at") != null) {
throw new IllegalArgumentException("삭제된 부서를 상위로 지정할 수 없습니다: " + parentCode);
}
Object parentCompany = parent.get("company_code");
if (parentCompany == null || (!companyCode.equals(parentCompany.toString()) && !"*".equals(parentCompany.toString()))) {
throw new IllegalArgumentException("다른 회사의 부서를 상위로 지정할 수 없습니다.");
}
}
/**
* parent_dept_code 변경 사이클 검증.
* deptCode 부모로 newParent 지정하려고 , newParent 또는 ancestor
* 체인에 deptCode 자체가 들어있다면 사이클이 생기므로 차단.
* (newParent == null 최상위로 만들기 항상 안전)
*/
private void verifyParentCycle(String deptCode, String newParent) {
if (newParent == null) return;
if (newParent.equals(deptCode)) {
throw new IllegalArgumentException("자기 자신을 상위 부서로 지정할 수 없습니다.");
}
Set<String> visited = new HashSet<>();
String cur = newParent;
while (cur != null && !visited.contains(cur)) {
if (deptCode.equals(cur)) {
throw new IllegalArgumentException("선택한 부서는 현재 부서의 하위 부서이므로 상위 부서로 지정할 수 없습니다.");
}
visited.add(cur);
Map<String, Object> p = sqlSession.selectOne(
"department.selectDepartmentByCodeIncludingDeleted",
Map.of("dept_code", cur)
);
if (p == null) break;
Object parent = p.get("parent_dept_code");
cur = parent != null ? parent.toString() : null;
}
}
//
// 일괄등록 / 일괄업데이트 (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;
}
//
@@ -234,19 +743,47 @@ public class DepartmentService extends BaseService {
@Transactional
public boolean removeDeptMember(String deptCode, String userId) {
Map<String, Object> params = new HashMap<>();
params.put("user_id", userId);
params.put("dept_code", deptCode);
int deleted = sqlSession.delete("department.deleteDeptMember", params);
// 1. 제거 row primary 였는지 확인
Map<String, Object> existParams = new HashMap<>();
existParams.put("user_id", userId);
existParams.put("dept_code", deptCode);
Map<String, Object> existing = sqlSession.selectOne("department.selectExistingMember", existParams);
boolean wasPrimary = existing != null && Boolean.TRUE.equals(existing.get("is_primary"));
// 2. 제거
int deleted = sqlSession.delete("department.deleteDeptMember", existParams);
if (deleted == 0) {
return false;
}
log.info("부서원 제거 성공: userId={}, deptCode={}", userId, deptCode);
// 3. primary 였으면 다른 USER_DEPT row 하나 promote
if (wasPrimary) {
Map<String, Object> remaining = sqlSession.selectOne("department.selectFirstUserDept",
Map.of("user_id", userId));
if (remaining != null && remaining.get("dept_code") != null) {
Map<String, Object> promote = new HashMap<>();
promote.put("user_id", userId);
promote.put("dept_code", remaining.get("dept_code").toString());
sqlSession.update("department.setUserPrimaryDept", promote);
log.info("주 부서 자동 승격: userId={}, newPrimaryDept={}", userId, remaining.get("dept_code"));
}
}
log.info("부서원 제거 성공: userId={}, deptCode={}, wasPrimary={}", userId, deptCode, wasPrimary);
return true;
}
@Transactional
public void setPrimaryDept(String deptCode, String userId) {
// 멤버십 검증 미소속 부서로 호출 데이터 손상 방지
Map<String, Object> existParams = new HashMap<>();
existParams.put("user_id", userId);
existParams.put("dept_code", deptCode);
Map<String, Object> existing = sqlSession.selectOne("department.selectExistingMember", existParams);
if (existing == null) {
throw new IllegalArgumentException("해당 부서의 부서원이 아닙니다. 먼저 부서원으로 추가해주세요.");
}
// 다른 부서의 부서 해제
Map<String, Object> clearParams = new HashMap<>();
clearParams.put("user_id", userId);
@@ -277,13 +814,118 @@ public class DepartmentService extends BaseService {
return val != null ? val : body.get(camelCase);
}
/** 빈 문자열을 null 로 치환 — DATE 컬럼에 '' 바인딩 시 pg cast 에러 나는 걸 방지 */
/** 빈 문자열 또는 공백만 있는 문자열을 null 로 치환. 그 외엔 trim 한 값을 반환 */
private Object nullIfBlank(Object value) {
if (value == null) return null;
if (value instanceof String s && s.trim().isEmpty()) return null;
if (value instanceof String s) {
String trimmed = s.trim();
return trimmed.isEmpty() ? null : trimmed;
}
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) {
@@ -0,0 +1,38 @@
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.HashMap;
import java.util.List;
import java.util.Map;
@Service
@Slf4j
public class FavoritesService extends BaseService {
public List<Map<String, Object>> getFavoriteMenuList(Map<String, Object> params) {
return sqlSession.selectList("favorites.selectFavoriteMenuList", params);
}
@Transactional
public Map<String, Object> insertFavorite(Map<String, Object> params) {
sqlSession.insert("favorites.insertFavorite", params);
Map<String, Object> result = new HashMap<>();
result.put("user_id", params.get("user_id"));
result.put("menu_objid", params.get("menu_objid"));
return result;
}
@Transactional
public int deleteFavorite(Map<String, Object> params) {
return sqlSession.delete("favorites.deleteFavorite", params);
}
public boolean exists(Map<String, Object> params) {
Integer cnt = sqlSession.selectOne("favorites.selectFavoriteExists", params);
return cnt != null && cnt > 0;
}
}
@@ -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);
@@ -0,0 +1,246 @@
package com.erp.service;
import com.erp.common.BaseService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 대무자(代務者) 관리 서비스.
*
* Spec: .omc/specs/deep-dive-user-substitute-management.md
* Plan: .omc/plans/autopilot-impl.md (T3)
*
* 핵심 규칙:
* - 관리자만 위임 지정/수정/해지. 본인 self-위임 불가.
* - 종료일 필수, 시작일 옵션 (비우면 즉시).
* - 같은 (COMPANY, ORIGINAL, PROXY) 쌍의 활성 기간 겹침 금지 (DB EXCLUDE + 사전 검증).
* - 같은 회사 사용자끼리만. SUPER_ADMIN 대무자로 지정 불가.
*/
@Service
@Slf4j
public class SubstituteService extends BaseService {
private static final String NS = "substitute.";
//
// 조회
//
public Map<String, Object> getSubstituteList(Map<String, Object> params) {
requireAdmin(params);
List<Map<String, Object>> list = sqlSession.selectList(NS + "selectSubstituteList", params);
Integer total = sqlSession.selectOne(NS + "selectSubstituteListCnt", params);
Map<String, Object> result = new HashMap<>();
result.put("list", list);
result.put("total", total == null ? 0 : total);
return result;
}
/**
* ProfileModal read-only: 내가 위임한(proxying_for_me) + 나를 대무 중인(my_proxies) 방향 번에.
* 결과를 Java 단에서 partition.
*/
public Map<String, Object> getMySubstitutes(Map<String, Object> params) {
if (params.get("user_id") == null) {
throw new IllegalArgumentException("user_id 가 필요합니다.");
}
List<Map<String, Object>> rows = sqlSession.selectList(NS + "selectMySubstitutes", params);
List<Map<String, Object>> proxyingForMe = new ArrayList<>();
List<Map<String, Object>> myProxies = new ArrayList<>();
for (Map<String, Object> row : rows) {
Object relation = row.get("relation");
if ("proxying_for_me".equals(relation)) {
proxyingForMe.add(row);
} else if ("my_proxies".equals(relation)) {
myProxies.add(row);
}
}
Map<String, Object> result = new HashMap<>();
result.put("proxying_for_me", proxyingForMe);
result.put("my_proxies", myProxies);
return result;
}
/**
* SubstituteContextFilter 패스. 트랜잭션 없이 가볍게.
* 반환: B 현재 대무 중인 A ID 목록 (없으면 리스트).
*/
public List<String> getActiveOriginalUserIds(String proxyUserId, String companyCode) {
if (proxyUserId == null || companyCode == null || "*".equals(companyCode)) {
return List.of();
}
Map<String, Object> p = new HashMap<>();
p.put("proxy_user_id", proxyUserId);
p.put("company_code", companyCode);
List<String> ids = sqlSession.selectList(NS + "selectActiveOriginalUserIds", p);
return ids == null ? List.of() : ids;
}
public Map<String, Object> getSubstituteInfo(Map<String, Object> params) {
Map<String, Object> row = sqlSession.selectOne(NS + "selectSubstituteInfo", params);
if (row == null) {
throw new IllegalArgumentException("대무 설정을 찾을 수 없습니다.");
}
return row;
}
/**
* ApprovalService 어댑터: B A 대무자로 활성 상태인지 검증.
* 결재 처리 호출.
*/
public Map<String, Object> getActiveProxyForLine(Map<String, Object> params) {
return sqlSession.selectOne(NS + "selectActiveProxyForLine", params);
}
public int checkOverlap(Map<String, Object> params) {
Integer cnt = sqlSession.selectOne(NS + "countOverlap", params);
return cnt == null ? 0 : cnt;
}
//
// 변경
//
public Map<String, Object> insertSubstitute(Map<String, Object> params) {
requireAdmin(params);
validateInsertParams(params);
sqlSession.insert(NS + "insertSubstitute", params);
Map<String, Object> info = new HashMap<>();
info.put("substitute_id", params.get("substitute_id"));
info.put("company_code", params.get("company_code"));
return getSubstituteInfo(info);
}
public Map<String, Object> updateSubstitute(Map<String, Object> params) {
requireAdmin(params);
Map<String, Object> existing = getSubstituteInfo(params);
// 변경되는 사용자 ID 있으면 회사 소속 + SUPER_ADMIN 검증
Object newProxy = params.get("proxy_user_id");
if (newProxy != null && !newProxy.equals(existing.get("proxy_user_id"))) {
validateUserInCompany((String) newProxy, (String) params.get("company_code"), "proxy");
rejectSuperAdminAsProxy((String) newProxy);
}
// 기간/대무자 변경 겹침 재검증
if (params.get("start_date") != null || params.get("end_date") != null
|| params.get("clear_start_date") != null || newProxy != null) {
Map<String, Object> overlapParams = new HashMap<>();
overlapParams.put("company_code", params.get("company_code"));
overlapParams.put("original_user_id", existing.get("original_user_id"));
overlapParams.put("proxy_user_id",
newProxy != null ? newProxy : existing.get("proxy_user_id"));
overlapParams.put("start_date",
Boolean.TRUE.equals(params.get("clear_start_date")) ? null
: (params.get("start_date") != null ? params.get("start_date")
: existing.get("start_date")));
overlapParams.put("end_date",
params.get("end_date") != null ? params.get("end_date")
: existing.get("end_date"));
overlapParams.put("exclude_substitute_id", params.get("substitute_id"));
if (checkOverlap(overlapParams) > 0) {
throw new IllegalArgumentException("같은 대상-대무자 쌍의 활성 기간이 겹칩니다.");
}
}
int updated = sqlSession.update(NS + "updateSubstitute", params);
if (updated == 0) {
throw new IllegalArgumentException("대무 설정 수정에 실패했습니다.");
}
return getSubstituteInfo(params);
}
public void deleteSubstitute(Map<String, Object> params) {
requireAdmin(params);
getSubstituteInfo(params); // 존재 확인
sqlSession.delete(NS + "deleteSubstitute", params);
}
//
// 검증
//
private void validateInsertParams(Map<String, Object> params) {
String companyCode = (String) params.get("company_code");
String original = (String) params.get("original_user_id");
String proxy = (String) params.get("proxy_user_id");
Object endDate = params.get("end_date");
if (companyCode == null || companyCode.isBlank()) {
throw new IllegalArgumentException("회사 코드가 필요합니다.");
}
if (original == null || original.isBlank()) {
throw new IllegalArgumentException("위임자(대상 사용자) 가 필요합니다.");
}
if (proxy == null || proxy.isBlank()) {
throw new IllegalArgumentException("대무자가 필요합니다.");
}
if (original.equals(proxy)) {
throw new IllegalArgumentException("본인을 자기 대무자로 지정할 수 없습니다.");
}
if (endDate == null || (endDate instanceof String && ((String) endDate).isBlank())) {
throw new IllegalArgumentException("종료일은 필수입니다 (무기한 대무 금지).");
}
// B3: 같은 회사 소속 검증
validateUserInCompany(original, companyCode, "original");
validateUserInCompany(proxy, companyCode, "proxy");
// SUPER_ADMIN 대무자로 지정 금지
rejectSuperAdminAsProxy(proxy);
// 사전 겹침 검증
Map<String, Object> overlapParams = new HashMap<>();
overlapParams.put("company_code", companyCode);
overlapParams.put("original_user_id", original);
overlapParams.put("proxy_user_id", proxy);
overlapParams.put("start_date", params.get("start_date"));
overlapParams.put("end_date", endDate);
if (checkOverlap(overlapParams) > 0) {
throw new IllegalArgumentException("같은 대상-대무자 쌍의 활성 기간이 겹칩니다.");
}
}
private void validateUserInCompany(String userId, String companyCode, String which) {
Map<String, Object> p = new HashMap<>();
p.put("user_id", userId);
p.put("company_code", companyCode);
Integer cnt = sqlSession.selectOne(NS + "countUserInCompany", p);
if (cnt == null || cnt == 0) {
throw new IllegalArgumentException(
"original".equals(which)
? "대상 사용자가 회사에 존재하지 않습니다."
: "대무자가 회사에 존재하지 않습니다.");
}
}
private void rejectSuperAdminAsProxy(String userId) {
Map<String, Object> p = new HashMap<>();
p.put("user_id", userId);
Integer cnt = sqlSession.selectOne(NS + "countSuperAdmin", p);
if (cnt != null && cnt > 0) {
throw new IllegalArgumentException("SUPER_ADMIN 은 대무자로 지정할 수 없습니다.");
}
}
private void requireAdmin(Map<String, Object> params) {
String role = (String) params.get("role");
if (!"ADMIN".equals(role) && !"COMPANY_ADMIN".equals(role) && !"SUPER_ADMIN".equals(role)) {
throw new AccessDeniedException("관리자만 대무자를 지정/수정/해지할 수 있습니다.");
}
}
}
@@ -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,6 +28,16 @@ public class TableManagementService extends BaseService {
private static final String NS = "tableManagement.";
/** 로그 테이블 컬럼 정의에 허용하는 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"
);
//
// 테이블 목록
//
@@ -145,20 +157,40 @@ public class TableManagementService extends BaseService {
Map<String, Object> settings, String companyCode) {
ensureTableInLabels(tableName);
String inputType = normalizeInputType((String) settings.get("input_type"));
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);
@@ -183,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);
String finalType = normalizeInputType(inputType, InputTypeContext.USER_UPDATE_TYPE);
Map<String, Object> params = new HashMap<>();
params.put("table_name", tableName);
params.put("column_name", columnName);
@@ -366,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<>();
@@ -455,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);
@@ -603,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);
@@ -617,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(
@@ -634,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);
@@ -848,12 +1271,43 @@ 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" 변환 */
/** "direct" / "auto" → "text" 변환 (legacy 호출처 보호 — system-normalize 동작) */
private String normalizeInputType(String inputType) {
if ("direct".equals(inputType) || "auto".equals(inputType)) {
log.warn("잘못된 inputType 값 감지: {} → 'text'로 변환", inputType);
@@ -862,6 +1316,23 @@ public class TableManagementService extends BaseService {
return inputType != null ? inputType : "text";
}
/**
* context 따라 INPUT_TYPE 정규화 검증.
*/
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개 중 하나여야 합니다: " + InputTypeConstants.USER_SELECTABLE_INPUT_TYPES
+ " (받은 값: " + value + ")"
);
}
return value;
}
// USER_UPDATE_OTHER / SYSTEM_NORMALIZE: 기존 동작 그대로
return normalizeInputType(value);
}
private String toJsonString(Object obj) {
if (obj == null) return "{}";
if (obj instanceof String s) return s.isBlank() ? "{}" : s;
@@ -85,7 +85,11 @@ public class SubdomainResolverFilter extends OncePerRequestFilter {
}
/**
* Host 헤더에서 서브도메인 추출. 포트 제거 + IP/localhost/www/admin 제외.
* Host 헤더에서 서브도메인 추출. 포트 제거 + IP/bare localhost/예약어 제외.
*
* 운영 (3파트, e.g. qnc.invyone.com) 파트
* dev (2파트, {sub}.localhost) 파트 (RFC 6761, 별도 DNS 불필요)
* (invyone.com 같은 베이스 / bare localhost / IP) null (META)
*/
static String extractSubdomain(String host) {
if (host == null || host.isBlank()) return null;
@@ -99,8 +103,18 @@ public class SubdomainResolverFilter extends OncePerRequestFilter {
if (IPV4.matcher(host).matches()) return null;
String[] parts = host.split("\\.");
if (parts.length < 3) return null; // invyone.com (2파트) null
// 2파트 "{sub}.localhost" 허용 (dev 전용). invyone.com 같은 베이스 도메인은 null.
if (parts.length == 2) {
if (!"localhost".equals(parts[1])) return null;
String first = parts[0];
if (first.isEmpty()) return null;
if (ReservedSubdomains.VALUES.contains(first)) return null;
return first;
}
// 3파트 이상 (운영) 번째 파트가 서브도메인
if (parts.length < 3) return null;
String first = parts[0];
if (ReservedSubdomains.VALUES.contains(first)) return null;
@@ -57,8 +57,8 @@ cors:
# 콤마 구분 문자열. setAllowedOriginPatterns 로 매칭됨.
# Spring CORS 문법: 포트 와일드카드는 `[*]` 로 표기. YAML 이 `[...]` 를 sequence 로 해석하지
# 않도록 반드시 따옴표로 감싸기.
# dev 디폴트: localhost + 사무실 Tailscale IP + 테넌트 서브도메인 (모든 포트) 패턴.
allowed-origins: "${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:9772,http://localhost:9771,http://100.126.230.80:9772,http://*.invyone.com:[*],https://*.invyone.com:[*],http://*.invyone.com,https://*.invyone.com}"
# dev 디폴트: localhost + 사무실 Tailscale IP + *.localhost 테넌트 (RFC 6761) + 테넌트 서브도메인 (모든 포트) 패턴.
allowed-origins: "${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:9772,http://localhost:9771,http://100.126.230.80:9772,http://*.localhost:[*],http://*.invyone.com:[*],https://*.invyone.com:[*],http://*.invyone.com,https://*.invyone.com}"
file:
upload-dir: ./uploads
@@ -14,15 +14,19 @@
--
-- 멱등성: SEQ 만 갱신하므로 중복 실행 안전. MENU_NAME_KOR + MENU_TYPE + COMPANY_CODE
-- 로 식별하여 다른 회사/사용자 메뉴는 영향 없음.
--
-- 타입 주의: MENU_INFO.SEQ 컬럼은 character varying 이라 정수 리터럴을 그대로
-- 쓰면 CASE 가 ELSE SEQ(varchar) 와 타입 불일치(42804) 로 실패한다.
-- → THEN 값은 반드시 문자열 리터럴로 줄 것.
UPDATE MENU_INFO
SET SEQ = CASE MENU_NAME_KOR
WHEN '회사관리' THEN 100
WHEN '부서관리' THEN 200
WHEN '사용자관리' THEN 300
WHEN '메뉴관리' THEN 400
WHEN '권한관리' THEN 500
WHEN '권한 그룹관리' THEN 600
WHEN '회사관리' THEN '100'
WHEN '부서관리' THEN '200'
WHEN '사용자관리' THEN '300'
WHEN '메뉴관리' THEN '400'
WHEN '권한관리' THEN '500'
WHEN '권한 그룹관리' THEN '600'
ELSE SEQ
END
WHERE MENU_TYPE = '0'
@@ -0,0 +1,18 @@
-- V018: invyone 부서관리 V1 - soft-delete
-- 부서 삭제를 hard-delete → soft-delete 로 전환하기 위한 schema 변경.
-- Additive only: 기존 22 컬럼 / 11 endpoint 무변경.
-- 멱등: IF NOT EXISTS 가드. 중복 실행 안전.
--
-- 멀티테넌트: 메타 DB 는 본 Flyway 가, 활성 테넌트는 StartupSchemaMigrator 가
-- 동일 statement 를 부팅 시점에 적용.
--
-- 후속 작업 (Slice 2.1): mapper/department.xml 의 deleteDepartment 를 UPDATE 로 교체,
-- restoreDepartment 신규, list/byCode 는 DELETED_AT IS NULL 옵션 처리.
-- (1) DEPT_INFO 소프트삭제 컬럼
ALTER TABLE DEPT_INFO ADD COLUMN IF NOT EXISTS DELETED_AT TIMESTAMP NULL;
-- (2) DEPT_INFO 활성 부서 부분 인덱스 (DELETED_AT IS NULL 쿼리 가속)
CREATE INDEX IF NOT EXISTS IDX_DEPT_INFO_ACTIVE
ON DEPT_INFO (COMPANY_CODE, PARENT_DEPT_CODE)
WHERE DELETED_AT IS NULL;
@@ -0,0 +1,22 @@
-- V019: 부서관리 미사용/중복 컬럼 정리
-- 기준: 부서관리 모듈 내부에서만 사용 + 사용처 0 + 다른 컬럼과 중복.
-- DROP IF EXISTS 로 멱등성 보장.
--
-- 대상 컬럼 (8개):
-- MASTER_SABUN - 부서장 사번 (DEPT_MANAGER 와 중복)
-- MASTER_USER_ID - 부서장 user_id (DEPT_MANAGER 와 중복, UI 미노출)
-- ORG_HEAD - 조직장 (DEPT_MANAGER 와 중복, 한국 SaaS 표준은 부서장 1명)
-- LOCATION_NAME - 위치명 (LOCATION 코드만 유지)
-- SALES_YN - 영업조직 Y/N (ORG_SYSTEM='sales' 와 중복)
-- SHOW_IN_CHART - 조직도 표시 (V2 까지 dead 로직)
-- ERP_MANAGED - ERP 관리 (분기 로직 없음)
-- DATA_TYPE - real/temp (DEPT_TYPE='temp' 와 충돌)
ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS MASTER_SABUN;
ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS MASTER_USER_ID;
ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS ORG_HEAD;
ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS LOCATION_NAME;
ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS SALES_YN;
ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS SHOW_IN_CHART;
ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS ERP_MANAGED;
ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS DATA_TYPE;
@@ -0,0 +1,16 @@
-- V020: 사용자별 메뉴 즐겨찾기 테이블
-- 로그인 사용자가 사이드바 메뉴 항목을 즐겨찾기에 등록/해제하면 한 행씩 쌓이고,
-- 사이드바 최상단 '즐겨찾기' 섹션이 이 행들을 읽어 표시한다.
-- 테넌트 DB 별로 격리 (회사마다 메뉴가 달라 cross-tenant 공용으로 묶지 않음).
CREATE TABLE IF NOT EXISTS USER_MENU_FAVORITES (
OBJID BIGSERIAL PRIMARY KEY,
USER_ID VARCHAR(100) NOT NULL,
MENU_OBJID VARCHAR(50) NOT NULL,
SORT_ORDER INTEGER NOT NULL DEFAULT 0,
CREATED_AT TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT UQ_USER_MENU_FAVORITES UNIQUE (USER_ID, MENU_OBJID)
);
CREATE INDEX IF NOT EXISTS IDX_USER_MENU_FAVORITES_USER
ON USER_MENU_FAVORITES (USER_ID);
@@ -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'
);
@@ -0,0 +1,149 @@
<?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">
<!--
Cross-tenant 어드민 합산 전용 mapper.
핵심 규칙 (notes/gbpark/2026-04-27-cross-tenant-admin-aggregation.md §6.2):
- SELECT 만. UPDATE/DELETE/INSERT 금지 (수정은 회사 도메인 컨텍스트로 위임).
- WHERE 에 COMPANY_CODE 필터 박지 말 것 — fan-out 시 각 회사 DB 안에서만 실행되므로
그 회사 데이터로 정의상 한정됨.
- SELECT 절에도 COMPANY_CODE 박지 말 것 — Aggregator 가 메타 DB 라우팅 정보 기준으로
응답 행에 박아준다 (회사 DB 안에 저장된 stale COMPANY_CODE 값 우선시 금지).
- JOIN 은 회사 DB 내부 테이블끼리만. 메타 DB 조인 금지.
namespace 단일: "admin-cross-tenant"
-->
<mapper namespace="admin-cross-tenant">
<!--
사용자 목록 — 단일 회사 admin.selectUserList 와 컬럼 동일 (COMPANY_CODE 제외).
company_code 는 Aggregator 가 응답에 박는다.
cross-tenant 1차 구현은 페이지네이션 없이 회사당 전체 반환 (설계서 §9.3).
필터는 search / status / dept_code 만 지원 (단일 회사 화면과 동일).
-->
<select id="listUsers" parameterType="map" resultType="map">
SELECT
SABUN AS sabun
, USER_ID AS user_id
, USER_NAME AS user_name
, COALESCE(USER_NAME_ENG, '') AS user_name_eng
, COALESCE(DEPT_CODE, '') AS dept_code
, COALESCE(DEPT_NAME, '') AS dept_name
, COALESCE(POSITION_CODE, '') AS position_code
, COALESCE(POSITION_NAME, '') AS position_name
, COALESCE(EMAIL, '') AS email
, COALESCE(TEL, '') AS tel
, COALESCE(CELL_PHONE, '') AS cell_phone
, COALESCE(USER_TYPE, '') AS user_type
, COALESCE(USER_TYPE_NAME, '') AS user_type_name
, COALESCE(TO_CHAR(CREATED_DATE, 'YYYY-MM-DD'), '') AS reg_date
, STATUS AS status
, COALESCE(LOCALE, '') AS locale
FROM USER_INFO
WHERE 1=1
<if test="search != null and search != ''">
AND (USER_ID ILIKE #{search}
OR USER_NAME ILIKE #{search}
OR DEPT_NAME ILIKE #{search}
OR POSITION_NAME ILIKE #{search}
OR USER_TYPE_NAME ILIKE #{search}
OR SABUN ILIKE #{search}
OR EMAIL ILIKE #{search}
OR TEL ILIKE #{search}
OR CELL_PHONE ILIKE #{search})
</if>
<if test="status != null and status != ''">
AND STATUS = #{status}
</if>
<if test="dept_code != null and dept_code != ''">
AND DEPT_CODE = #{dept_code}
</if>
ORDER BY CREATED_DATE DESC, USER_NAME ASC
<if test="per_company_limit_plus_one != null">
LIMIT #{per_company_limit_plus_one}
</if>
</select>
<!--
권한 그룹 목록 — 단일 회사 role.getRoleGroupList 와 컬럼 동일 (COMPANY_CODE 제외).
member_count / menu_count 서브쿼리 그대로 유지. 회사 DB 안에서 동작하므로
그 회사의 AUTHORITY_SUB_USER / AUTHORITY_SUB_MENU 만 카운트됨.
-->
<select id="listRoleGroups" parameterType="map" resultType="map">
SELECT
AM.OBJID AS objid
, AM.AUTH_NAME AS auth_name
, AM.AUTH_CODE AS auth_code
, AM.STATUS AS status
, AM.WRITER AS writer
, AM.CREATED_DATE AS created_date
, (SELECT COUNT(*) FROM AUTHORITY_SUB_USER WHERE MASTER_OBJID = AM.OBJID) AS member_count
, (SELECT COUNT(*) FROM AUTHORITY_SUB_MENU WHERE MASTER_OBJID = AM.OBJID) AS menu_count
FROM AUTHORITY_MASTER AM
WHERE 1=1
<if test="search != null and search != ''">
AND AM.AUTH_NAME ILIKE #{search}
</if>
ORDER BY AM.CREATED_DATE DESC
<if test="per_company_limit_plus_one != null">
LIMIT #{per_company_limit_plus_one}
</if>
</select>
<!--
배치 목록 — 단일 회사 batch.getBatchList 와 동일하게 SELECT *.
PostgreSQL 컬럼명이 그대로 lowercase Map key 로 떨어짐.
페이지네이션은 cross-tenant 1차 구현엔 비지원 (회사당 전체 반환).
-->
<select id="listBatches" parameterType="map" resultType="map">
SELECT *
FROM BATCH_CONFIGS
WHERE 1=1
<if test="search != null and search != ''">
AND (BATCH_NAME ILIKE '%' || #{search} || '%'
OR DESCRIPTION ILIKE '%' || #{search} || '%')
</if>
<if test="is_active != null and is_active != ''">
AND IS_ACTIVE = #{is_active}
</if>
ORDER BY CREATED_DATE DESC
<if test="per_company_limit_plus_one != null">
LIMIT #{per_company_limit_plus_one}
</if>
</select>
<!--
다국어 키 목록 — 단일 회사 multilang.getMultilangKeyList 와 컬럼 동일.
회사 DB 안에서 동작하므로 filter_company_code 분기 불필요 (그 회사 데이터로 정의상 한정).
1차 구현은 category_id 재귀 필터 비지원 — 필요해지면 후속 추가.
-->
<select id="listLangKeys" parameterType="map" resultType="map">
SELECT
KEY_ID AS key_id
, USAGE_NOTE AS usage_note
, LANG_KEY AS lang_key
, DESCRIPTION AS description
, IS_ACTIVE AS is_active
, CATEGORY_ID AS category_id
, CREATED_DATE AS created_date
, CREATED_BY AS created_by
, UPDATED_DATE AS updated_date
, UPDATED_BY AS updated_by
FROM MULTI_LANG_KEY_MASTER
WHERE 1=1
<if test="menu_code != null and menu_code != ''">
AND USAGE_NOTE = #{menu_code}
</if>
<if test="search != null and search != ''">
AND (LANG_KEY ILIKE #{search}
OR DESCRIPTION ILIKE #{search})
</if>
ORDER BY CREATED_DATE DESC, KEY_ID DESC
<if test="per_company_limit_plus_one != null">
LIMIT #{per_company_limit_plus_one}
</if>
</select>
</mapper>
@@ -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>
@@ -728,14 +716,9 @@
DEPT_CODE
, PARENT_DEPT_CODE
, DEPT_NAME
, MASTER_SABUN
, MASTER_USER_ID
, LOCATION
, LOCATION_NAME
, CASE WHEN CREATED_DATE IS NOT NULL THEN TO_CHAR(CREATED_DATE, 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') ELSE NULL END AS CREATED_DATE
, DATA_TYPE
, STATUS
, SALES_YN
, COMPANY_CODE
, COMPANY_NAME
FROM DEPT_INFO
@@ -746,8 +729,7 @@
</if>
<if test="search != null and search != ''">
AND (DEPT_NAME ILIKE '%' || #{search} || '%'
OR DEPT_CODE ILIKE '%' || #{search} || '%'
OR LOCATION_NAME ILIKE '%' || #{search} || '%')
OR DEPT_CODE ILIKE '%' || #{search} || '%')
</if>
ORDER BY PARENT_DEPT_CODE ASC NULLS FIRST, DEPT_NAME ASC
</select>
@@ -214,12 +214,15 @@
AND EXISTS (
SELECT 1 FROM APPROVAL_LINES L
WHERE L.REQUEST_ID = R.REQUEST_ID
AND L.APPROVER_ID = #{user_id}
AND L.APPROVER_ID IN
<foreach collection="effective_user_ids" item="uid" open="(" separator="," close=")">
#{uid}
</foreach>
AND L.STATUS = 'pending'
AND L.COMPANY_CODE = R.COMPANY_CODE
)
</if>
ORDER BY R.CREATED_DATE DESC
ORDER BY R.CREATED_AT DESC
<if test="page_limit != null">
LIMIT #{page_limit} OFFSET #{page_offset}
</if>
@@ -248,7 +251,10 @@
AND EXISTS (
SELECT 1 FROM APPROVAL_LINES L
WHERE L.REQUEST_ID = R.REQUEST_ID
AND L.APPROVER_ID = #{user_id}
AND L.APPROVER_ID IN
<foreach collection="effective_user_ids" item="uid" open="(" separator="," close=")">
#{uid}
</foreach>
AND L.STATUS = 'pending'
AND L.COMPANY_CODE = R.COMPANY_CODE
)
@@ -459,14 +465,17 @@
SELECT L.*,
R.TITLE, R.TARGET_TABLE, R.TARGET_RECORD_ID,
R.REQUESTER_NAME, R.REQUESTER_DEPT,
R.CREATED_DATE AS REQUEST_CREATED_DATE
R.CREATED_AT AS REQUEST_CREATED_DATE
FROM APPROVAL_LINES L
JOIN APPROVAL_REQUESTS R
ON L.REQUEST_ID = R.REQUEST_ID AND L.COMPANY_CODE = R.COMPANY_CODE
WHERE L.APPROVER_ID = #{user_id}
WHERE L.APPROVER_ID IN
<foreach collection="effective_user_ids" item="uid" open="(" separator="," close=")">
#{uid}
</foreach>
AND L.STATUS = 'pending'
AND (L.COMPANY_CODE = #{company_code} OR L.COMPANY_CODE = '*')
ORDER BY R.CREATED_DATE ASC
ORDER BY R.CREATED_AT ASC
</select>
<!-- ================================================================
@@ -536,12 +545,14 @@
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</delete>
<!-- 어댑터: USER_SUBSTITUTES 참조 (T7, 086 마이그레이션 이후). -->
<select id="selectActiveProxyForLine" parameterType="map" resultType="map">
SELECT * FROM APPROVAL_PROXY_SETTINGS
SELECT *
FROM USER_SUBSTITUTES
WHERE ORIGINAL_USER_ID = #{original_user_id}
AND PROXY_USER_ID = #{proxy_user_id}
AND IS_ACTIVE = 'Y'
AND START_DATE &lt;= CURRENT_DATE
AND IS_ACTIVE = TRUE
AND (START_DATE IS NULL OR START_DATE &lt;= CURRENT_DATE)
AND END_DATE &gt;= CURRENT_DATE
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
LIMIT 1
@@ -143,18 +143,31 @@
ORDER BY COUNT DESC, U.USER_NAME ASC
</select>
<!-- 감사 로그 INSERT -->
<!-- 감사 로그 INSERT.
PROCESSOR_ID/PROCESSOR_NAME 은 대무(代務) 처리 추적용 (086 마이그레이션 이후).
평시는 USER_ID == PROCESSOR_ID. -->
<insert id="insertAuditLog" parameterType="map">
INSERT INTO SYSTEM_AUDIT_LOG (
COMPANY_CODE, USER_ID, USER_NAME, ACTION, RESOURCE_TYPE,
RESOURCE_ID, RESOURCE_NAME, TABLE_NAME, SUMMARY, CHANGES,
IP_ADDRESS, REQUEST_PATH
IP_ADDRESS, REQUEST_PATH,
PROCESSOR_ID, PROCESSOR_NAME
) VALUES (
#{company_code}, #{user_id}, #{user_name}, #{action}, #{resource_type},
#{resource_id}, #{resource_name}, #{table_name}, #{summary},
CAST(#{changes} AS JSONB),
#{ip_address}, #{request_path}
#{ip_address}, #{request_path},
#{processor_id}, #{processor_name}
)
</insert>
<!-- 처리자 이름 lookup (대무 시 USER_INFO 에서 1회 조회). -->
<select id="selectUserNameById" parameterType="map" resultType="string">
SELECT USER_NAME
FROM USER_INFO
WHERE USER_ID = #{user_id}
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
LIMIT 1
</select>
</mapper>
@@ -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>
@@ -3,7 +3,7 @@
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="department">
<!-- 부서 목록 조회 (회사별, 부서원 수 포함) -->
<!-- 부서 목록 조회 (회사별, 부서원 수 포함). soft-delete: 기본 active 만, include_deleted=true 시 deleted 포함 -->
<select id="selectDepartments" parameterType="map" resultType="map">
SELECT
D.DEPT_CODE,
@@ -15,29 +15,40 @@
D.ORG_SYSTEM,
D.APPROVAL_MANAGER,
D.DEPT_MANAGER,
D.ORG_HEAD,
D.ZIPCODE,
D.ADDRESS1,
D.ADDRESS2,
D.START_DATE,
D.END_DATE,
D.ERP_MANAGED,
D.SHOW_IN_CHART,
D.SORT_ORDER,
D.STATUS,
COUNT(DISTINCT UD.USER_ID) AS MEMBER_COUNT
D.DELETED_AT,
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, D.ORG_HEAD,
D.SHORT_NAME, D.DEPT_TYPE, D.ORG_SYSTEM, D.APPROVAL_MANAGER, D.DEPT_MANAGER,
D.ZIPCODE, D.ADDRESS1, D.ADDRESS2, D.START_DATE, D.END_DATE,
D.ERP_MANAGED, D.SHOW_IN_CHART, D.SORT_ORDER, D.STATUS
D.SORT_ORDER, D.STATUS, D.DELETED_AT
ORDER BY COALESCE(D.SORT_ORDER, 9999), D.DEPT_NAME
</select>
<!-- 부서 단건 조회 -->
<!-- 부서 단건 조회 (active 만) -->
<select id="selectDepartmentByCode" parameterType="map" resultType="map">
SELECT
DEPT_CODE,
@@ -49,33 +60,70 @@
ORG_SYSTEM,
APPROVAL_MANAGER,
DEPT_MANAGER,
ORG_HEAD,
ZIPCODE,
ADDRESS1,
ADDRESS2,
START_DATE,
END_DATE,
ERP_MANAGED,
SHOW_IN_CHART,
SORT_ORDER,
STATUS
STATUS,
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
</select>
<!-- 부서 단건 조회 (deleted 포함) — 복구 검증·복구 처리용 -->
<select id="selectDepartmentByCodeIncludingDeleted" parameterType="map" resultType="map">
SELECT
DEPT_CODE,
DEPT_NAME,
COMPANY_CODE,
PARENT_DEPT_CODE,
SHORT_NAME,
DEPT_TYPE,
ORG_SYSTEM,
APPROVAL_MANAGER,
DEPT_MANAGER,
ZIPCODE,
ADDRESS1,
ADDRESS2,
START_DATE,
END_DATE,
SORT_ORDER,
STATUS,
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>
<!-- 중복 부서명 확인 -->
<!-- 중복 부서명 확인 (per-tenant, 활성 부서만, 공백/대소문자 무관) -->
<select id="selectDuplicateDeptName" parameterType="map" resultType="map">
SELECT DEPT_CODE, DEPT_NAME
FROM DEPT_INFO
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
AND DEPT_NAME = #{dept_name}
WHERE COMPANY_CODE = #{company_code}
AND DELETED_AT IS NULL
AND TRIM(LOWER(DEPT_NAME)) = TRIM(LOWER(#{dept_name}))
</select>
<!-- 회사명 조회 -->
<!-- 회사명 조회 (정확 매칭, '*' 글로벌 fallback 제거 — selectOne 에서 다중 row 충돌 방지) -->
<select id="selectCompanyName" parameterType="map" resultType="map">
SELECT COMPANY_NAME
FROM COMPANY_MNG
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
WHERE COMPANY_CODE = #{company_code}
LIMIT 1
</select>
<!-- 다음 부서 코드 번호 조회 (전역 카운트) -->
@@ -98,22 +146,14 @@
ORG_SYSTEM,
APPROVAL_MANAGER,
DEPT_MANAGER,
ORG_HEAD,
ZIPCODE,
ADDRESS1,
ADDRESS2,
START_DATE,
END_DATE,
ERP_MANAGED,
SHOW_IN_CHART,
SORT_ORDER,
STATUS,
MASTER_SABUN,
MASTER_USER_ID,
LOCATION,
LOCATION_NAME,
DATA_TYPE,
SALES_YN,
CREATED_DATE
) VALUES (
#{dept_code},
@@ -126,22 +166,14 @@
#{org_system},
#{approval_manager},
#{dept_manager},
#{org_head},
#{zipcode},
#{address1},
#{address2},
#{start_date}::date,
#{end_date}::date,
COALESCE(#{erp_managed}, 'Y'),
COALESCE(#{show_in_chart}, 'Y'),
COALESCE(#{sort_order}, 10),
COALESCE(#{status}, 'active'),
#{master_sabun},
#{master_user_id},
#{location},
#{location_name},
COALESCE(#{data_type}, 'real'),
COALESCE(#{sales_yn}, 'N'),
NOW()
)
</insert>
@@ -157,43 +189,43 @@
ORG_SYSTEM = #{org_system},
APPROVAL_MANAGER = #{approval_manager},
DEPT_MANAGER = #{dept_manager},
ORG_HEAD = #{org_head},
ZIPCODE = #{zipcode},
ADDRESS1 = #{address1},
ADDRESS2 = #{address2},
START_DATE = #{start_date}::date,
END_DATE = #{end_date}::date,
ERP_MANAGED = #{erp_managed},
SHOW_IN_CHART = #{show_in_chart},
SORT_ORDER = #{sort_order},
STATUS = #{status},
MASTER_SABUN = #{master_sabun},
MASTER_USER_ID = #{master_user_id},
LOCATION = #{location},
LOCATION_NAME = #{location_name},
DATA_TYPE = #{data_type},
SALES_YN = #{sales_yn}
LOCATION = #{location}
WHERE DEPT_CODE = #{dept_code}
AND DELETED_AT IS NULL
</update>
<!-- 하위 부서 수 조회 -->
<!-- 하위 부서 수 조회 (기본 active 자식만, include_deleted=true 시 deleted 자식도 카운트) -->
<select id="selectChildDeptCount" parameterType="map" resultType="int">
SELECT COUNT(*)
FROM DEPT_INFO
WHERE PARENT_DEPT_CODE = #{dept_code}
<if test="include_deleted == null or include_deleted == false">
AND DELETED_AT IS NULL
</if>
</select>
<!-- 부서 삭제 전 user_dept 삭제 -->
<delete id="deleteUserDeptByDeptCode" parameterType="map">
DELETE FROM USER_DEPT
<!-- 부서 삭제 (soft-delete: DELETED_AT = NOW()). USER_DEPT 보존 — 복구 시 멤버 그대로 살아남 -->
<update id="deleteDepartment" parameterType="map">
UPDATE DEPT_INFO
SET DELETED_AT = NOW()
WHERE DEPT_CODE = #{dept_code}
</delete>
AND DELETED_AT IS NULL
</update>
<!-- 부서 삭제 -->
<delete id="deleteDepartment" parameterType="map">
DELETE FROM DEPT_INFO
<!-- 부서 복구 (DELETED_AT = NULL). 호출 전에 부모 deleted 여부 service 에서 검증 -->
<update id="restoreDepartment" parameterType="map">
UPDATE DEPT_INFO
SET DELETED_AT = NULL
WHERE DEPT_CODE = #{dept_code}
</delete>
AND DELETED_AT IS NOT NULL
</update>
<!-- 부서원 목록 조회 -->
<select id="selectDeptMembers" parameterType="map" resultType="map">
@@ -208,7 +240,7 @@
D.DEPT_NAME,
UD.IS_PRIMARY
FROM USER_DEPT UD
JOIN USER_INFO U ON UD.USER_ID = U.USER_ID
LEFT JOIN USER_INFO U ON UD.USER_ID = U.USER_ID
JOIN DEPT_INFO D ON UD.DEPT_CODE = D.DEPT_CODE
WHERE UD.DEPT_CODE = #{dept_code}
ORDER BY UD.IS_PRIMARY DESC, U.USER_NAME
@@ -225,8 +257,8 @@
FROM USER_INFO
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
AND (
USER_ID ILIKE #{search}
OR USER_NAME ILIKE #{search}
USER_ID ILIKE #{search} ESCAPE '\'
OR USER_NAME ILIKE #{search} ESCAPE '\'
)
ORDER BY USER_NAME
LIMIT 20
@@ -239,14 +271,23 @@
WHERE USER_ID = #{user_id}
</select>
<!-- 기존 부서원 확인 -->
<!-- 기존 부서원 확인 (IS_PRIMARY 포함 — 제거 시 자동 승격 판단용) -->
<select id="selectExistingMember" parameterType="map" resultType="map">
SELECT USER_ID, DEPT_CODE
SELECT USER_ID, DEPT_CODE, IS_PRIMARY
FROM USER_DEPT
WHERE USER_ID = #{user_id}
AND DEPT_CODE = #{dept_code}
</select>
<!-- 사용자의 USER_DEPT row 중 첫 번째 (primary 자동 승격용) -->
<select id="selectFirstUserDept" parameterType="map" resultType="map">
SELECT USER_ID, DEPT_CODE
FROM USER_DEPT
WHERE USER_ID = #{user_id}
ORDER BY CREATED_DATE ASC
LIMIT 1
</select>
<!-- 사용자의 주 부서 확인 -->
<select id="selectUserPrimaryDept" parameterType="map" resultType="map">
SELECT USER_ID, DEPT_CODE
@@ -283,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) -->

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