Compare commits

...

778 Commits

Author SHA1 Message Date
hjlee 147d187901 Merge pull request 'lhj' (#372) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/372
2026-01-19 17:26:05 +09:00
leeheejin d09a6977f7 검색필터 업그레이드 2026-01-19 17:25:12 +09:00
leeheejin faf4100056 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-19 13:17:10 +09:00
kjs 410b4a7b14 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node 2026-01-19 12:07:30 +09:00
kjs e4667cce5f refactor: 테이블 관리 서비스에서 쿼리 및 로깅 개선
- 다중 값 배열 검색 시 조건 처리 로직 개선
- 쿼리에서 main. 접두사 추가하여 명확한 테이블 참조 보장
- 불필요한 공백 제거 및 코드 가독성 향상
- 엔티티 관계 감지 로깅 개선으로 디버깅 용이성 증가
- 새로운 수주관리 및 거래처 테이블 추가로 멀티테넌시 지원 강화
2026-01-19 12:07:29 +09:00
leeheejin c282d5c611 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj 2026-01-19 10:14:48 +09:00
leeheejin d4afc06f4a Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-19 10:14:38 +09:00
leeheejin f2ab4f11bd 진짜 해결했음 진짜진짜로 2026-01-19 10:14:20 +09:00
hjlee 514d852fa6 Merge pull request '배포 다시..' (#371) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/371
2026-01-19 09:50:47 +09:00
leeheejin 8603fddbcb 배포 다시.. 2026-01-19 09:50:25 +09:00
hjlee 58adc0a100 Merge pull request 'lhj' (#370) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/370
2026-01-16 18:15:42 +09:00
leeheejin 0382c94d73 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-16 18:15:21 +09:00
leeheejin 49f67451eb 피벗수정오늘최종 2026-01-16 18:14:55 +09:00
DDD1542 e3852aca5d Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node 2026-01-16 17:41:21 +09:00
DDD1542 df8065503d feat: 메뉴 삭제 및 동기화 기능 개선
- 메뉴 삭제 시 하위 메뉴를 재귀적으로 수집하여 관련 데이터 정리 기능 추가
- 메뉴 삭제 성공 시 삭제된 메뉴와 하위 메뉴 수를 포함한 응답 메시지 개선
- 메뉴 복제 시 항상 활성화 상태로 설정
- 화면-메뉴 동기화 진행 상태를 사용자에게 알리기 위한 프로그레스 메시지 추가
- 최상위 회사 폴더는 메뉴로 생성하지 않고 스킵하는 로직 추가
- 동기화 진행 중 오버레이 UI 개선
2026-01-16 17:41:19 +09:00
hjlee 0a85146564 Merge pull request 'lhj' (#369) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/369
2026-01-16 17:40:11 +09:00
leeheejin ad3b853d04 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-16 17:39:55 +09:00
leeheejin 2a3cc7ba00 배포다시 되게 고쳐놓음 2026-01-16 17:39:35 +09:00
hjlee ee273c5103 Merge pull request 'lhj' (#368) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/368
2026-01-16 16:51:03 +09:00
leeheejin 50a25cb9de Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-16 16:50:34 +09:00
leeheejin d1631d15ff 안닫히게 수정 2 2026-01-16 16:49:59 +09:00
hjlee a020985630 Merge pull request 'lhj' (#367) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/367
2026-01-16 16:25:12 +09:00
leeheejin 351ecbb35d 배포에서 오류 안나게 수정 2026-01-16 16:24:43 +09:00
leeheejin d32e933c03 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-16 16:12:09 +09:00
leeheejin 4497985104 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj 2026-01-16 16:11:34 +09:00
kjs b97b0cc7d7 refactor: 화면 그룹 관련 API에서 AuthenticatedRequest 타입 사용
- 화면 그룹 목록 조회, 상세 조회, 생성, 수정, 삭제 API에서 Request 타입을 AuthenticatedRequest로 변경하여 사용자 인증 정보를 명확히 처리
- companyCode를 req.user?.companyCode || "*"로 설정하여 기본값 처리 개선
- 관련 API의 일관성 있는 타입 사용으로 코드 가독성 및 유지보수성 향상
2026-01-16 15:52:35 +09:00
hjlee 160ad87395 Merge pull request 'lhj' (#364) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/364
2026-01-16 15:18:32 +09:00
leeheejin 4972f26cee Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-16 15:18:09 +09:00
leeheejin 02eee979ea 고치기 완료 2026-01-16 15:17:49 +09:00
DDD1542 08de1372c5 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node 2026-01-16 14:48:21 +09:00
DDD1542 ab52c49492 feat: 화면 관리 기능 개선 (복제/삭제/그룹 관리/테이블 설정)
- 화면 관리 시스템의 복제, 삭제, 수정 및 테이블 설정 기능을 전면 개선
- 그룹 삭제 시 하위 그룹과의 연관성 정리 및 로딩 프로그레스 바 추가
- 화면 수정 기능 추가: 이름, 그룹, 역할, 정렬 순서 변경
- 테이블 설정 모달에 관련 기능 추가 및 데이터 일관성 유지
- 메뉴-화면 그룹 동기화 API 추가 및 관련 상태 관리 기능 구현
- 검색어 필터링 로직 개선: 다중 키워드 지원
- 관련 파일 및 진행 상태 업데이트
2026-01-16 14:48:15 +09:00
leeheejin 8a865ac1f4 이상한 부분 수정 피벗그리드 2026-01-16 14:29:19 +09:00
hjlee 0a89cc2fb0 Merge pull request '피벗' (#363) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/363
2026-01-16 14:03:31 +09:00
leeheejin ab3a493abb 피벗 2026-01-16 14:03:07 +09:00
hjlee ac0f461832 Merge pull request 'lhj' (#362) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/362
2026-01-16 10:19:35 +09:00
leeheejin c2256de8ec Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-16 10:19:05 +09:00
leeheejin 484c98da9e 피벗그리드 고침 2026-01-16 10:18:11 +09:00
DDD1542 b2dc06d0f2 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node 2026-01-15 17:36:41 +09:00
DDD1542 efa95af4b9 refactor: 코드 정리 및 불필요한 주석 제거
- EntityJoinController에서 중복 제거 설정 관련 주석 및 코드 삭제
- screenGroupController와 tableManagementController에서 AuthenticatedRequest 타입을 일반 Request로 변경
- 불필요한 로그 및 주석 제거로 코드 가독성 향상
- tableManagementController에서 에러 메시지 개선
2026-01-15 17:36:38 +09:00
SeongHyun Kim e8bdcbb95c fix: 발주/입고관리 그룹 편집 시 단건만 저장되던 문제 수정
EditModal.tsx: conditional-container 존재 시 onSave 미전달 로직 수정
ModalRepeaterTableComponent.tsx: groupedData prop 우선 사용하도록 변경
2026-01-15 17:05:01 +09:00
SeongHyun Kim 60ae073606 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node 2026-01-15 16:56:44 +09:00
SeongHyun Kim a36802ab10 fix: RepeaterFieldGroup 저장 경로에 채번 규칙 할당 로직 추가 2026-01-15 16:54:02 +09:00
hjlee 98c489ee22 Merge pull request '코드로 보이던 문제 최소한의 코드만 고침' (#360) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/360
2026-01-15 16:53:40 +09:00
leeheejin c77c6290d3 코드로 보이던 문제 최소한의 코드만 고침 2026-01-15 16:53:27 +09:00
gbpark 9dc549be09 Merge pull request 'feat: 화면 관리 기능 개선 (복제/삭제/그룹 관리)' (#359) from feature/v2-renewal into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/359
2026-01-15 14:59:21 +09:00
gbpark 40a226ca30 Merge branch 'main' into feature/v2-renewal 2026-01-15 14:59:12 +09:00
DDD1542 5d89b69451 feat: 화면 관리 기능 개선 (복제/삭제/그룹 관리)
- 단일 화면 복제 및 그룹 전체 복제 기능 추가
- 정렬 순서 유지 및 일괄 이름 변경 기능 구현
- 삭제 기능 개선: 단일 화면 삭제 및 그룹 삭제 시 옵션 추가
- 회사 코드 지원 기능 추가: 복제된 그룹/화면에 선택한 회사 코드 적용
- 관련 파일 및 진행 상태 업데이트
2026-01-15 14:58:12 +09:00
SeongHyun Kim 7fd3364aef Merge branch 'ksh' 2026-01-15 13:27:01 +09:00
hjlee 2326c3548b Merge pull request 'lhj' (#358) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/358
2026-01-15 12:08:46 +09:00
leeheejin 220ce57be1 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-15 12:07:51 +09:00
leeheejin 0ac83b1551 분할패널에서 수정해도 리스트에서 삭제가 안되던 문제 코드 해결 2026-01-15 12:07:26 +09:00
kjs 3f474ecddd 오류수정 2026-01-15 11:00:30 +09:00
kjs ddf5ed4006 빌드에러 2026-01-15 10:52:54 +09:00
kjs c4ee084a1d 빌드에러 수정 2026-01-15 10:47:58 +09:00
SeongHyun Kim 2e02ace388 feat(repeater): 하위 데이터 조회 및 조건부 입력 기능 구현, 테이블 선택 데이터 동기화 개선
Repeater 컴포넌트에 하위 데이터 조회 기능 추가 (재고/단가 조회)
조건부 입력 활성화 및 최대값 제한 기능 구현
필드 정의 순서 변경 기능 추가 (드래그앤드롭, 화살표 버튼)
TableListComponent의 DataProvider 클로저 문제 해결
ButtonPrimaryComponent에 modalDataStore fallback 로직 추가
2026-01-15 10:35:34 +09:00
kjs 435eb90763 Merge pull request 'feat/multilang' (#357) from feat/multilang into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/357
2026-01-14 18:28:20 +09:00
kjs 98870b3348 Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into feat/multilang 2026-01-14 17:14:42 +09:00
kjs b7b750d134 번역현황 표시 2026-01-14 17:14:27 +09:00
kjs ac334db0b1 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feat/multilang 2026-01-14 16:33:38 +09:00
kjs 16c9c71a23 다국어 화면에서 수정기능 구현 2026-01-14 16:33:22 +09:00
DDD1542 059ea6b30a feat: 테이블 설정 모달의 컬럼 저장 로직 개선
- 변경된 컬럼들만 저장하도록 로직을 개선하여 불필요한 API 호출을 줄임
- 기존 컬럼 정보와 편집된 값을 병합하여 최종 설정을 구성
- 엔티티 및 코드 타입에 대한 세부 설정 처리 로직 추가
- 저장 성공 시 사용자에게 피드백 제공 및 편집 상태 초기화 기능 구현
2026-01-14 16:09:00 +09:00
kjs 14f8714ea1 Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into feat/multilang 2026-01-14 15:39:04 +09:00
kjs a27cb85007 코드 정리 및 최적화: InteractiveScreenViewer 컴포넌트의 불필요한 공백 제거 및 Select 컴포넌트의 props 정리 2026-01-14 15:38:52 +09:00
kjs b5d2195cd5 Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into feat/multilang 2026-01-14 15:38:41 +09:00
DDD1542 0a3d42f3ad feat: 테이블 타입 관리 모달 추가 및 기존 테이블 생성 모달 제거
- 테이블 설정 모달에 테이블 타입 관리 모달을 추가하여 데이터베이스 테이블과 컬럼의 타입을 관리할 수 있도록 개선
- 기존의 테이블 생성 모달을 제거하고, 버튼 텍스트를 "새 테이블 생성"에서 "테이블 타입 관리"로 변경
- 테이블 타입 관리 모달의 헤더 및 설명 추가, 모달 닫기 버튼 기능 구현
- 테이블 데이터 새로고침 기능을 추가하여 모달 닫기 시 데이터가 최신 상태로 유지되도록 개선
2026-01-14 15:37:29 +09:00
kjs b5c2e85496 화면 다국어 처리 2026-01-14 15:33:57 +09:00
DDD1542 f321aaf7aa feat: 화면 디자이너 모달 및 제어 관리 탭 기능 추가
- 화면 설정 모달에 "제어 관리" 탭 추가하여 버튼 제어 설정을 간편하게 관리
- 버튼 액션 설정 기능 구현: 버튼 목록 표시 및 각 버튼의 액션 타입 수정 가능
- 화면 디자이너 모달 통합: 전체화면 Dialog 내부에 ScreenDesigner 임베드
- URL 쿼리 파라미터로 화면 디자이너 자동 열기 기능 추가
- 화면 캔버스 크기 자동 조절 기능 구현: 최소 크기 보장 및 여유 마진 추가
- 필드 추가/제거 기능 개선: 기존 그리드 컬럼 변경 로직과 통합하여 사용자 경험 향상
2026-01-14 14:35:27 +09:00
kjs 26bb93ab6e 다국어 생성후 매핑 자동저장 2026-01-14 13:26:41 +09:00
kjs f9575d7b5f 다국어 버튼 자동매핑 2026-01-14 13:08:44 +09:00
kjs c26b346054 다국어설정 모달생성 2026-01-14 11:51:24 +09:00
kjs 24315215de 다국어 키 자동생성 로직 2026-01-14 11:05:57 +09:00
SeongHyun Kim ca73685bc2 Merge remote-tracking branch 'origin/main' into ksh 2026-01-14 10:22:58 +09:00
kjs 61a7f585b4 다국어 자동생성 2026-01-14 10:20:27 +09:00
SeongHyun Kim cf97db7fbf feat(universal-form-modal): Select 필드 직접 입력(Combobox) 모드 추가
SelectOptionConfig에 allowCustomInput 옵션 추가
FieldDetailSettingsModal에 "직접 입력 허용" Switch UI 추가
CascadingSelectField에 Combobox 모드 구현 (Popover+Command)
SelectField에 Combobox 모드 구현
목록 선택과 직접 입력 동시 지원
2026-01-13 18:44:59 +09:00
kjs 18b5161398 Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into feat/multilang 2026-01-13 18:32:04 +09:00
kjs b576837f18 다국어 관리 시스템 개선: 카테고리 및 키 자동 생성 기능 추가 2026-01-13 18:28:11 +09:00
SeongHyun Kim ef27e0e38f feat(universal-form-modal): 연쇄 드롭다운(Cascading Dropdown) 기능 구현
- SelectOptionConfig에 cascading 타입 및 설정 객체 추가
- FieldDetailSettingsModal에 연쇄 드롭다운 설정 UI 구현
  - 부모 필드 선택 (섹션별 그룹핑 콤보박스)
  - 관계 코드 선택 시 상세 설정 자동 채움
  - 소스 테이블, 부모 키 컬럼, 값/라벨 컬럼 설정
- UniversalFormModalComponent에 자식 필드 초기화 로직 추가
- selectOptions.cascading 방식 CascadingSelectField 렌더링 지원
2026-01-13 18:26:41 +09:00
SeongHyun Kim d7d7dabe84 fix: Select 옵션의 참조 테이블 컬럼 자동 로드 추가
- FieldDetailSettingsModal에서 모달 열릴 때 Select 옵션의 참조 테이블 컬럼을 자동으로 로드하도록 useEffect 추가
- field.selectOptions.tableName이 설정되어 있고 해당 테이블의 컬럼이 로드되지 않은 경우 onLoadTableColumns 호출
- 기존 linkedFieldGroup의 sourceTable 로드 로직과 동일한 패턴 적용
2026-01-13 17:05:16 +09:00
hjlee d22fd078be Merge pull request 'lhj' (#356) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/356
2026-01-13 15:43:38 +09:00
leeheejin 28fe908704 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-13 15:43:24 +09:00
leeheejin 1b5ae5fe1c 일단 오류 수정 2026-01-13 15:43:04 +09:00
DDD1542 905a9f62c3 feat: 프리뷰 모드에서 회사 코드 오버라이드 기능 추가
- 최고 관리자만 다른 회사 코드로 오버라이드 가능하도록 로직 개선
- entityJoinController 및 tableManagementController에서 회사 코드 오버라이드 처리 추가
- 관련 API 호출 시 오버라이드된 회사 코드 적용
- 프리뷰 모드 감지 및 UI 개선을 위한 코드 추가
2026-01-13 13:28:50 +09:00
kjs 989b7e53a7 Merge pull request 'feature/screen-management' (#355) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/355
2026-01-13 10:25:20 +09:00
kjs 20e144af36 Merge branch 'main' into feature/screen-management 2026-01-13 10:25:08 +09:00
hjlee e2a22bb853 Merge pull request 'lhj' (#354) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/354
2026-01-13 10:04:41 +09:00
leeheejin 0deb466557 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-13 10:04:19 +09:00
leeheejin f64279d084 카테고리 연속입력 2026-01-13 10:04:05 +09:00
SeongHyun Kim c74e97d66e chore: update react-is to version 18.3.1 and downgrade recharts to version 3.2.1 in package.json and package-lock.json 2026-01-13 09:35:01 +09:00
kjs 0beb8b20a3 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2026-01-13 09:30:59 +09:00
hjlee 054da65a26 Merge pull request 'lhj' (#353) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/353
2026-01-13 09:30:27 +09:00
kjs 75e6c9eb1a 엑셀업로드 제어로직 개선 2026-01-13 09:30:19 +09:00
leeheejin 0f2d0bb053 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-13 09:30:11 +09:00
leeheejin 306de370f1 탭 값 필터링 2026-01-13 09:29:58 +09:00
kjs b6fefe2ebd 엑셀 업로드 로직 개선 2026-01-12 17:43:34 +09:00
SeongHyun Kim f799402564 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into ksh 2026-01-12 17:28:11 +09:00
leeheejin 033f5eaf7e Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-12 17:27:28 +09:00
leeheejin d094b58ebf Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj 2026-01-12 17:27:18 +09:00
hjlee 3fa57ad2ae Merge pull request 'lhj' (#352) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/352
2026-01-12 17:26:48 +09:00
SeongHyun Kim 821955cfac fix: 렉 구조 등록 카테고리 데이터 저장 방식 통일 및 중복 체크 수정
RackStructureComponent: context에 카테고리 코드 필드(floorCode, zoneCode 등) 추가
기존 데이터 조회 시 warehouse_id -> warehouse_code 컬럼명 수정
미리보기 생성 시 카테고리 코드로 저장하도록 변경
미리보기 테이블에서 카테고리 코드를 라벨로 변환하여 표시
buttonActions: 중복 체크 API 호출 시 filters -> search 파라미터로 변경
types: RackStructureContext 인터페이스에 카테고리 코드 필드 추가
2026-01-12 17:25:52 +09:00
leeheejin b358a46c33 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-12 17:25:29 +09:00
hjjeong b2add92abf Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node 2026-01-12 17:25:22 +09:00
leeheejin c2836a0209 일단 퇴사일 입력하면 필터링 적용되게 수정해놓음 2026-01-12 17:25:12 +09:00
hjjeong 472fc8633c 상단마스터정보+하단품목정보 수정시 오류 수정 2026-01-12 17:24:25 +09:00
kjs 4801ee5ca4 Merge pull request 'feature/screen-management' (#351) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/351
2026-01-12 17:20:23 +09:00
kjs 87189c792e 카테고리값 자동감지 2026-01-12 16:08:02 +09:00
kjs 9cc5bbbf05 엑셀 업로드 문제 수정 2026-01-12 13:53:57 +09:00
kjs 5f991db9c4 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2026-01-12 10:33:01 +09:00
kjs 9e7253a293 카테고리 라벨 보이지 않는 문제 수정 2026-01-12 10:32:41 +09:00
leeheejin 31e87e0bca Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-12 09:25:13 +09:00
DDD1542 0773989c74 feat: 데이터 흐름 조회 기능 개선 및 프리뷰 모드 추가
- 데이터 흐름 조회 API에 source_screen_id 파라미터 추가하여 특정 화면에서 시작하는 데이터 흐름만 조회 가능
- 화면 관리 페이지에서 선택된 그룹에 company_code 필드 추가하여 회사 코드 정보 포함
- 프리뷰 모드에서 URL 쿼리로 company_code를 받아와 데이터 조회 시 우선 사용하도록 로직 개선
- 화면 관계 흐름 및 서브 테이블 정보에서 company_code를 활용하여 필터링 및 시각화 개선
2026-01-09 18:26:37 +09:00
kjs 6732e7d969 Merge branch 'feature/screen-management' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2026-01-09 18:22:51 +09:00
kjs 35f83c1937 로그 정리 2026-01-09 18:22:50 +09:00
kjs 8aa6008351 Merge pull request 'feature/screen-management' (#350) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/350
2026-01-09 17:57:08 +09:00
kjs 47b61a9a35 Merge branch 'main' into feature/screen-management 2026-01-09 17:57:01 +09:00
kjs d22c2ec96e Merge branch 'feature/screen-management' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2026-01-09 17:56:49 +09:00
kjs 3677c77da0 단일테이블 엑셀 업로드 채번 구현 2026-01-09 17:56:48 +09:00
SeongHyun Kim c11e80a43c Merge remote-tracking branch 'origin/main' into ksh 2026-01-09 17:46:44 +09:00
SeongHyun Kim f8fb7d687e fix: SelectedItemsDetailInput 수정 모드에서 다중 레코드 로드 오류 수정
DynamicComponentRenderer에 selected-items-detail-input groupedData 전달 추가
SelectedItemsDetailInput에서 groupedData 우선 사용하도록 변경
ScreenModal editData 배열 처리 시 formData/selectedData 분리 저장
TextInput 수정 모드에서 채번 규칙 스킵 로직 추가
2026-01-09 17:42:33 +09:00
DDD1542 a6569909a2 feat: 저장 테이블 제외 조건 추가 및 포커싱 개선
- 저장 테이블 쿼리에 table-list와 체크박스가 활성화된 화면, openModalWithData 버튼이 있는 화면을 제외하는 조건 추가
- 화면 그룹 클릭 시 새 그룹 진입 시 포커싱 없이 시작하도록 로직 개선
- 관련 문서에 제외 조건 및 SQL 예시 추가
2026-01-09 17:03:00 +09:00
leeheejin 5c9dda6826 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-09 15:58:26 +09:00
leeheejin bcf512d2b5 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj 2026-01-09 15:58:16 +09:00
kjs 4d41cb40b6 Merge pull request '빌드에러 수정' (#349) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/349
2026-01-09 15:57:15 +09:00
kjs bf74dd0f92 Merge branch 'main' into feature/screen-management 2026-01-09 15:57:09 +09:00
kjs 85ae1c1521 빌드에러 수정 2026-01-09 15:56:56 +09:00
leeheejin 38455325dd Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-09 15:48:39 +09:00
kjs f493f8ac80 Merge pull request 'feature/screen-management' (#348) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/348
2026-01-09 15:46:41 +09:00
kjs 7fc341bca8 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2026-01-09 15:46:24 +09:00
kjs ba2a281245 엑셀 업로드 제어로직 설정 가능하도록 수정 2026-01-09 15:46:09 +09:00
kjs aa0698556e 엑셀 업로드,다운로드 기능 개선 2026-01-09 15:32:02 +09:00
leeheejin c76123a927 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-09 15:21:12 +09:00
leeheejin ba20a2bf42 피벗 테스트만 하면 됨 기능은 완료 2026-01-09 15:11:30 +09:00
SeongHyun Kim 23c9604672 Merge origin/main into ksh - resolve conflicts 2026-01-09 14:55:16 +09:00
SeongHyun Kim 64c6942de3 feat(split-panel-layout): 멀티 테이블 탭 기능 추가
AdditionalTabConfig 타입 정의 (우측 패널과 동일한 설정 구조)
설정 패널에 추가 탭 설정 UI 구현 (테이블, 조인키, 컬럼, 필터, 중복제거, 버튼)
컴포넌트에 탭 상태 관리 및 데이터 로딩 로직 추가
탭 바 UI 및 탭별 컨텐츠 렌더링 구현
기존 기능 유지 (탭 미사용 시 동일 동작)
2026-01-09 14:52:32 +09:00
leeheejin f07448ac17 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-09 14:41:57 +09:00
leeheejin d49883d25f 피벗 추가 2026-01-09 14:41:27 +09:00
hjjeong 217e390fe9 Merge pull request 'fix/split-panel-edit-group-records' (#347) from fix/split-panel-edit-group-records into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/347
2026-01-09 14:34:16 +09:00
hjjeong 363ef44586 Merge remote-tracking branch 'origin/main' into fix/split-panel-edit-group-records 2026-01-09 14:33:02 +09:00
hjjeong 48aa004a7f fix: SplitPanelLayout 수정 버튼 클릭 시 그룹 레코드 조회 개선
- 수정 버튼 클릭 시 groupByColumns 기준으로 모든 관련 레코드 조회
- search 대신 dataFilter(equals) 사용하여 정확 매칭 조회
- deduplication 명시적 비활성화로 모든 레코드 반환
- supplier_mng, customer_mng 등 회사별 데이터 테이블 DB 조인 강제 (캐시 미사용)
- entityJoinController에 deduplication 파라미터 처리 추가
- ScreenModal에서 배열 형태 editData 처리 지원
2026-01-09 14:11:51 +09:00
kjs ee3a648917 삭제버튼 제어 동작하지 않던 오류 수정 2026-01-09 13:43:14 +09:00
leeheejin 819a281df4 fix: 피벗그리드 컴포넌트 레지스트리 등록 누락 수정 2026-01-09 13:34:55 +09:00
leeheejin dd1d3bb44d Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-09 13:28:40 +09:00
leeheejin 52e6824e76 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj 2026-01-09 13:28:26 +09:00
kjs 80cf20e142 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2026-01-09 12:17:27 +09:00
hjlee abddb67a30 Merge pull request 'lhj' (#346) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/346
2026-01-09 11:52:42 +09:00
leeheejin a0a9253d2c Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-09 11:51:48 +09:00
leeheejin 222a00b8a9 그리드랑 노드에서 delete 가 where 입력했는데도 저장이 안되던 오류 해결 2026-01-09 11:51:35 +09:00
leeheejin e8516d9d6b feat: DELETE 노드 WHERE 조건에 소스 필드 선택 기능 추가
- 소스 필드 목록을 연결된 입력 노드에서 자동으로 로딩
- WHERE 조건에 소스 필드 선택 Combobox 추가
- 정적 값과 소스 필드 중 선택 가능
- 조건 변경 시 자동 저장 기능 추가
2026-01-09 11:44:14 +09:00
kjs 150a40e2a8 Merge branch 'feature/screen-management' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2026-01-09 11:21:17 +09:00
kjs cea3aa53ae 엑셀 업로드 분할테이블 지원 2026-01-09 11:21:16 +09:00
DDD1542 af4072cef1 feat: 저장 테이블 정보 및 애니메이션 기능 추가
- 화면 서브 테이블에서 저장 테이블 정보를 추출하는 쿼리 추가
- 저장 테이블 정보 구조를 TableNodeData 인터페이스에 통합
- 저장 테이블의 시각적 표현을 위한 애니메이션 효과 추가
- 필터링 및 참조 관계 뱃지 레이아웃 개선
- 테이블 높이 부드러운 애니메이션 및 스크롤 기능 구현
2026-01-09 11:19:30 +09:00
kjs a50222e7d5 Merge pull request '이미지 문제 수정' (#345) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/345
2026-01-09 09:55:48 +09:00
kjs 69711f4e4b Merge branch 'main' into feature/screen-management 2026-01-09 09:55:39 +09:00
kjs 2eccd1982c 이미지 문제 수정 2026-01-09 09:55:24 +09:00
hjjeong 0baffafac1 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node 2026-01-08 18:01:07 +09:00
hjjeong 910d070055 fix: SelectedItemsDetailInput 저장 시 NULL 레코드 생성 버그 수정 2026-01-08 17:59:27 +09:00
hjjeong 8f4c95d20d fix: SelectedItemsDetailInput 저장 시 NULL 레코드 생성 버그 수정
- beforeFormSave 이벤트에 skipDefaultSave 플래그 추가
- UPSERT 처리 시 기본 저장 로직 건너뛰기
- 빈 엔트리 필터링으로 불필요한 레코드 생성 방지
2026-01-08 17:40:50 +09:00
kjs 65e1c1a995 Merge pull request '분할패널 수정버튼 동작하게 수정' (#344) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/344
2026-01-08 17:37:15 +09:00
kjs d2c15d519d 분할패널 수정버튼 동작하게 수정 2026-01-08 17:36:40 +09:00
hjlee 583c6c8c79 Merge pull request 'lhj' (#343) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/343
2026-01-08 17:15:03 +09:00
leeheejin a52ab0b206 refactor: pivot-grid 컴포넌트 개선 2026-01-08 17:11:46 +09:00
leeheejin 551e893f15 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-08 17:10:04 +09:00
leeheejin 85f8637ce0 fix: 채번 규칙 할당 로직 개선 - 복사 시 품목코드 자동생성 수정 2026-01-08 17:06:28 +09:00
leeheejin b85b3cd578 그리드? 일단 추가랑 복사기능 되게 했음 2026-01-08 17:05:27 +09:00
DDD1542 b8c8b31033 feat: 서브 테이블 조인 및 필터 관계 정보 개선
- rightPanel.columns에서 참조하는 외부 테이블 및 조인 컬럼 정보를 수집하는 로직 추가
- 서브 테이블의 조인 컬럼 참조 정보를 포함하여 시각화 개선
- 필터 관계를 선 없이 뱃지로 표시하여 겹침 방지 및 시각적 표현 개선
- TableNodeData 인터페이스에 조인 컬럼 및 참조 정보 필드 추가
- 화면 관계 흐름에서 조인 컬럼 및 필터 관계 정보 표시 기능 개선
2026-01-08 16:20:51 +09:00
kjs 0f57309d74 Merge pull request '분할패널 설정변경' (#342) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/342
2026-01-08 15:56:30 +09:00
kjs 4dfa82d3dd 분할패널 설정변경 2026-01-08 15:56:06 +09:00
SeongHyun Kim 34e48993e4 feat(SplitPanelLayout2): 개별 수정 버튼에 모달 화면 선택 UI 추가
- 좌측/우측 패널의 개별 수정 버튼 설정에 수정 모달 화면 선택 Combobox 추가
- 수정 버튼 ON 시 모달 화면 선택 UI 표시
- editModalScreenId 설정값 저장 및 사용
- 기존 폴백 로직 유지 (editModalScreenId 없으면 addModalScreenId 사용)
2026-01-08 15:37:22 +09:00
kjs 9821afe9cd Merge pull request 'feature/screen-management' (#341) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/341
2026-01-08 14:49:45 +09:00
kjs 38599a1bef Merge branch 'main' into feature/screen-management 2026-01-08 14:49:39 +09:00
kjs 11e25694b9 엔티티타입 입력 셀렉트박스 다중선택 기능 2026-01-08 14:49:24 +09:00
DDD1542 8928d851ca feat: 서브 테이블 정보 및 관계 시각화 개선
- 화면 서브 테이블에서 valueField, parentFieldId, cascadingParentField, controlField 정보를 추출하는 쿼리 추가
- 서브 테이블의 관계 유형을 추론하기 위한 추가 정보 필드(originalRelationType, foreignKey, leftColumn) 포함
- 필터링에 사용되는 FK 컬럼을 TableNodeData 인터페이스에 추가하여 시각화 개선
- 관계 유형별 색상 정의 및 시각적 관계 유형 추론 함수 추가
- 화면 관계 흐름에서 서브 테이블 연결선 및 필터링 참조 정보 표시 기능 개선
2026-01-08 14:24:33 +09:00
kjs 3f81c449ad 코드병합기능 개선 2026-01-08 14:24:07 +09:00
hjjeong 00006bf2e2 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node 2026-01-08 14:15:53 +09:00
hjjeong 3e9bf29bcf fix: SplitPanelLayout 그룹 삭제 시 groupByColumns 기준 삭제 및 멀티테넌시 보호 추가(영업관리_거래처별 품목 등록 등에서,,) 2026-01-08 14:13:19 +09:00
SeongHyun Kim 34ac1b0c42 Merge remote-tracking branch 'origin/main' into ksh 2026-01-08 14:06:07 +09:00
leeheejin df94d73662 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-08 12:34:39 +09:00
leeheejin dc449f6c69 글씨크기 조정 2026-01-08 12:34:19 +09:00
kjs dcf3a63d9b Merge pull request 'feature/screen-management' (#340) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/340
2026-01-08 12:33:15 +09:00
kjs a3c83c834e 드래그핸들 기능 수정 2026-01-08 12:32:50 +09:00
kjs 980c929d83 리도,언도기능 2026-01-08 12:28:48 +09:00
kjs a146667615 드래그 핸들 수정 2026-01-08 12:25:52 +09:00
SeongHyun Kim 2645d627da fix: 수주관리 수정 모달 저장 오류 수정 - UPDATE 폴백 로직 추가, 데이터 병합 순서 수정, 삭제 파라미터 순서 수정, id 타입 비교 통일 2026-01-08 12:25:35 +09:00
kjs f33d989202 복사된 셀 표시 2026-01-08 12:23:00 +09:00
kjs 6a1343b847 복사 붙여넣기 기능 2026-01-08 12:14:04 +09:00
kjs b61cb17aea 자동 채우기 핸들 2026-01-08 12:04:31 +09:00
kjs 83eb92cb27 엑셀 업로드 단계 통합 2026-01-08 11:51:02 +09:00
kjs 5321ea5b80 엑셀 업로드 템플릿 기능 구현 2026-01-08 11:45:39 +09:00
kjs d90a403ed9 엑셀 업로드 카테고리 타입 자동 감지 2026-01-08 11:09:40 +09:00
kjs c181385f11 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2026-01-08 10:40:01 +09:00
kjs 23ebae95d6 검색필터 틀고정기능 오류 수정 2026-01-08 10:39:48 +09:00
SeongHyun Kim 17498b1b2b refactor: UniversalFormModalComponent 자체 저장 기능 제거
saveSingleRow, saveWithCustomApi, handleSave, handleReset 함수 삭제
saving 상태 및 저장/초기화 버튼 UI 삭제
설정 패널에서 저장 버튼 관련 설정 UI 삭제
ModalConfig 타입에서 버튼 관련 속성 삭제
저장 처리는 button-primary (action: save)로 위임
약 468줄 코드 삭제
2026-01-08 10:04:05 +09:00
SeongHyun Kim 384106dd95 Merge remote-tracking branch 'origin/main' into ksh 2026-01-07 18:27:00 +09:00
SeongHyun Kim 6f4c9b7fdd ix: 부모-자식 모달 데이터 전달 문제 해결 및 미사용 multiRowSave 기능 제거
InteractiveScreenViewerDynamic: 생성 모드에서 formData를 initialData로 전달하도록 수정
UniversalFormModal: saveMultipleRows 함수 및 multiRowSave 관련 코드 전체 제거
types/config: MultiRowSaveConfig 인터페이스 및 기본값 제거
FieldDetailSettingsModal: receiveFromParent UI 옵션 제거
SaveSettingsModal: 저장 모드 설명 개선
DB: multiRowSave.enabled=true인 화면 3개 설정 수정
2026-01-07 17:42:40 +09:00
kjs 26c61ee5b6 Merge pull request 'feature/screen-management' (#339) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/339
2026-01-07 16:11:15 +09:00
kjs d8ff49d1db Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2026-01-07 16:11:02 +09:00
kjs 8c525673ab Merge branch 'feature/screen-management' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2026-01-07 16:10:12 +09:00
kjs 47ac9ecd8a 범용폼모달 외부소스 지원 2026-01-07 16:10:11 +09:00
hjlee 1b633e55d2 Merge pull request 'lhj' (#338) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/338
2026-01-07 16:07:50 +09:00
DDD1542 b279f8d58d feat: 화면 관리 기능 개선 및 서브 테이블 정보 추가
- 화면 선택 시 그룹을 재설정하지 않도록 로직 개선하여 데이터 재로드 방지
- 테이블 노드 데이터 구조에 필드 매핑 정보 추가
- 서브 테이블과 조인 관계를 시각화하기 위한 컬럼 강조 및 스타일링 개선
- 화면 관계 흐름에서 서브 테이블 연결선 강조 기능 추가
- 사용 컬럼 및 조인 컬럼 정보를 화면별로 매핑하여 관리
2026-01-07 14:50:03 +09:00
DDD1542 48e9840fa0 feat: 화면 그룹 생성 및 업데이트 기능 개선
- 화면 그룹 생성 시 회사 코드 결정 로직 추가: 최고 관리자가 특정 회사를 선택할 수 있도록 변경
- 부모 그룹이 있는 경우 그룹 레벨 및 계층 경로 계산 로직 추가
- 화면 그룹 생성 후 계층 경로 업데이트 기능 구현
- 화면 그룹 업데이트 시 불필요한 코드 제거 및 최적화
- 프론트엔드에서 화면 선택 시 그룹 및 서브 테이블 정보 연동 기능 개선
2026-01-07 14:49:49 +09:00
leeheejin 62226918a7 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-07 14:32:23 +09:00
leeheejin df70538027 pop화면 2026-01-07 14:31:04 +09:00
kjs 26020a29a0 Merge pull request 'feature/screen-management' (#337) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/337
2026-01-07 13:31:14 +09:00
kjs 52df163fbb Merge branch 'main' into feature/screen-management 2026-01-07 13:31:07 +09:00
kjs 777429af48 Merge branch 'feature/screen-management' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2026-01-07 13:30:57 +09:00
kjs 856db80a36 빌드 에러수정 2026-01-07 13:30:57 +09:00
kjs cd7adce874 Merge pull request '테이블에 존재하는지 확인하는 제어 추가' (#336) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/336
2026-01-07 11:55:53 +09:00
kjs ca260aa260 Merge branch 'main' into feature/screen-management 2026-01-07 11:55:45 +09:00
SeongHyun Kim 42d1a3fc5e Merge remote-tracking branch 'origin/main' into ksh 2026-01-07 10:28:47 +09:00
SeongHyun Kim 7c165a724e feat: 출고관리 수정 모달 저장 기능 개선 및 그룹화 컬럼 설정 UI 추가
ButtonConfigPanel: 수정 액션에 그룹화 컬럼 선택 드롭다운 추가 (영문/한글 검색 지원)
ScreenSplitPanel/EmbeddedScreen: groupedData prop 전달 경로 추가
buttonActions: RepeaterFieldGroup 저장 시 공통 필드 우선 적용되도록 병합 순서 변경
2026-01-07 10:24:01 +09:00
hjjeong 0ce0860dcc Merge pull request '입고 처리 시 재고 테이블 저장 및 재고이력 stock_id 전달 수정' (#335) from feature/inbound-inventory-fix into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/335
2026-01-07 10:06:37 +09:00
hjjeong c6ff839e54 입고 처리 시 재고 테이블 저장 및 재고이력 stock_id 전달 수정 2026-01-07 10:05:32 +09:00
kjs e308fd0ccc 테이블에 존재하는지 확인하는 제어 추가 2026-01-07 09:55:19 +09:00
hjlee f2cb7d14ca Merge pull request 'lhj' (#334) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/334
2026-01-07 09:48:46 +09:00
leeheejin b5b229122b Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-07 09:37:16 +09:00
leeheejin 126da9b46f 하이큐마그 점검항목 추가 후 주기명/점검방법명 오류 해결 2026-01-07 09:37:02 +09:00
SeongHyun Kim c365f06ed7 Merge remote-tracking branch 'origin/main' into ksh 2026-01-07 09:06:29 +09:00
hjlee 563081fa1c Merge pull request 'lhj' (#333) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/333
2026-01-06 17:57:31 +09:00
leeheejin 24331687d4 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-06 17:57:05 +09:00
leeheejin ea848b97ee 검색필터 업데이트 2026-01-06 17:56:31 +09:00
hjlee 15fc166683 Merge pull request 'lhj' (#332) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/332
2026-01-06 17:40:25 +09:00
leeheejin 26fdab5b4e Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-06 17:39:53 +09:00
leeheejin 12d3419b7f 구분 필터링 업데이트 2026-01-06 17:39:36 +09:00
SeongHyun Kim a2b701a4bf feat: 조건부 컨테이너 initialData 전달 체계 구현
InteractiveScreenViewerDynamic: originalData를 initialData로 추가 전달
DynamicComponentRenderer: initialData 우선순위 로직 추가
ConditionalContainerComponent: initialData props 추가 및 하위 전달
ConditionalSectionViewer: initialData props 추가 및 하위 전달
types.ts: initialData 타입 정의 추가
수정 모드에서 조건부 컨테이너 내부 컴포넌트 초기값 표시 지원
2026-01-06 17:29:41 +09:00
kjs 2213ad51b2 Merge pull request '수정' (#331) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/331
2026-01-06 17:02:54 +09:00
kjs 7120d5edc3 수정 2026-01-06 17:02:42 +09:00
kjs 0eb005ce35 Merge pull request '창고코드 같이 올라가게 수정' (#330) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/330
2026-01-06 15:34:02 +09:00
kjs 4828488c72 Merge branch 'main' into feature/screen-management 2026-01-06 15:33:55 +09:00
kjs c1425be57f 창고코드 같이 올라가게 수정 2026-01-06 15:33:44 +09:00
SeongHyun Kim 25b7e637de feat: 테이블 데이터 저장 시 존재하지 않는 컬럼 자동 필터링
- tableManagementService.addTableData: 테이블 스키마 기반 컬럼 필터링 로직 추가
- 무시된 컬럼 정보를 API 응답에 포함 (skippedColumns, savedColumns)
- 프론트엔드 콘솔에 무시된 컬럼 경고 출력
- conditional-container의 UI 제어용 필드(condition) 등으로 인한 저장 에러 방지
2026-01-06 15:29:26 +09:00
SeongHyun Kim ad39374e54 Merge remote-tracking branch 'origin/main' into ksh 2026-01-06 15:04:36 +09:00
SeongHyun Kim 77bb917248 feat: RepeaterFieldGroup 상위 폼 필드 전달 방식 개선
- 하드코딩된 masterDetailFields 배열을 규칙 기반 필터링으로 변경
- 제외 규칙: comp_ 접두사, _numberingRuleId 접미사, 배열/객체 타입, 빈 값 등
- 새 필드 추가 시 코드 수정 불필요하도록 개선
- 에러 로깅 상세 정보 추가 (status, data, message, fullError)
2026-01-06 15:03:22 +09:00
hjjeong 6bf914d9b1 Merge pull request '거래처 품목정보 거래처품번/단가 입력 없이 저장되도록' (#329) from fix/daejin-bugs into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/329
2026-01-06 15:02:38 +09:00
hjjeong e08c50c771 거래처 품목정보 거래처품번/단가 입력 없이 저장되도록 2026-01-06 15:01:50 +09:00
hjlee 0f027f2382 Merge pull request 'lhj' (#328) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/328
2026-01-06 14:45:07 +09:00
leeheejin 09d574fb8a Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-06 14:44:23 +09:00
leeheejin 6ae0778b4c 필터의 라벨도 코드말고 설정한걸로 나오게 수정 2026-01-06 14:43:57 +09:00
kjs 58b0e1b79b Merge pull request '범용 폼모달 라벨로 뜨게 수정' (#327) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/327
2026-01-06 14:24:52 +09:00
kjs f0322a49ad 범용 폼모달 라벨로 뜨게 수정 2026-01-06 14:24:30 +09:00
kjs 5e27d21257 Merge pull request 'feature/screen-management' (#326) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/326
2026-01-06 13:46:00 +09:00
kjs efc9175fec Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2026-01-06 13:45:48 +09:00
kjs eb61506acd 분할패널 수정동작 수정 2026-01-06 13:43:47 +09:00
SeongHyun Kim 75b5530d04 Merge remote-tracking branch 'origin/main' into ksh 2026-01-06 13:23:00 +09:00
kjs cded99d644 로그 제거 2026-01-06 13:08:33 +09:00
SeongHyun Kim 40fd5f9055 feat: 채번규칙 editable 옵션 수동 모드 감지 기능 구현
모달 오픈 시 채번 미리보기 원본값 저장 (numberingOriginalValues)
handleFieldChange에서 원본값 비교하여 수동/자동 모드 전환
사용자 수정 시 ruleId 제거하여 저장 시 채번 스킵
원본값 복구 시 ruleId 복구하여 자동 모드 복원
handleSave에서 채번 할당 조건 분기 처리
2026-01-06 13:06:28 +09:00
kjs 0709b8df25 Merge pull request '범용 폼 모달 사전필터 기능 수정' (#325) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/325
2026-01-06 11:43:38 +09:00
kjs 6bfc1a97a3 범용 폼 모달 사전필터 기능 수정 2026-01-06 11:43:26 +09:00
kjs 9ea0f1b84f Merge pull request 'feature/screen-management' (#324) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/324
2026-01-06 10:28:30 +09:00
kjs 7cb8026979 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2026-01-06 10:28:17 +09:00
kjs 4f77c38207 권한기능 임시 비활성화 2026-01-06 10:27:54 +09:00
leeheejin 68017ed0e9 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-06 10:21:07 +09:00
hjjeong 338c885cfa Merge pull request 'fix/daejin-bugs' (#323) from fix/daejin-bugs into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/323
2026-01-06 09:20:44 +09:00
SeongHyun Kim b3ee2b50e8 fix: 카테고리 Select 필드 저장 시 라벨값 대신 코드값 저장되도록 수정
- UniversalFormModalComponent.tsx: 카테고리 옵션 value를 valueLabel에서 valueCode로 변경
- 제어 로직 조건 비교 정상화 및 500 에러 해결
2026-01-05 18:41:49 +09:00
hjjeong 64105bf525 발주관리 수정시 공급처 표시 오류 수정 2026-01-05 18:21:29 +09:00
DDD1542 6925e3af3f feat: 화면 서브 테이블 정보 조회 기능 추가
- 화면 그룹에 대한 서브 테이블 관계를 조회하는 API 및 라우트 구현
- 화면 그룹 목록에서 서브 테이블 정보를 포함하여 데이터 흐름을 시각화
- 프론트엔드에서 화면 선택 시 그룹 및 서브 테이블 정보 연동 기능 추가
- 화면 노드 및 관계 시각화 컴포넌트에 서브 테이블 정보 통합
2026-01-05 18:18:26 +09:00
hjjeong 714698c20f 구매관리_발주관리 저장 시 마스터정보 품목에 전달 2026-01-05 17:08:47 +09:00
hjjeong 2a7066b6fd 테이블에 존재하는 컬럼만 업데이트 2026-01-05 17:08:03 +09:00
leeheejin 5fbc76f85d Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-05 14:47:26 +09:00
SeongHyun Kim e747162058 Merge remote-tracking branch 'origin/main' into ksh 2026-01-05 13:58:56 +09:00
SeongHyun Kim 914f3d57f3 fix: TableList 카테고리 라벨 표시를 위한 멀티테넌시 fallback 로직 추가
getColumnInputTypes API에서 회사별 설정이 없을 때 기본설정() fallback 적용
table_type_columns, category_column_mapping 조회 시 DISTINCT ON + ORDER BY CASE WHEN 패턴 사용
영향 범위: 모든 TableList 컴포넌트의 카테고리 컬럼 표시
2026-01-05 13:58:13 +09:00
kjs 12baad75c9 Merge branch 'feature/screen-management' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2026-01-05 13:54:42 +09:00
kjs 85519e302f 행선택시에만 버튼 활성화 기능 수정 2026-01-05 13:54:41 +09:00
kjs 98e96a1fb0 Merge pull request '즉시저장 액션 필수항목 체크 추가' (#322) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/322
2026-01-05 13:38:09 +09:00
kjs 76d7d5149b Merge branch 'main' into feature/screen-management 2026-01-05 13:38:02 +09:00
kjs 239e4800c7 즉시저장 액션 필수항목 체크 추가 2026-01-05 13:37:39 +09:00
leeheejin 6c75adb61d Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-05 10:10:23 +09:00
DDD1542 7caf2dea94 feat: 화면 그룹 관리 기능 추가
- 화면 그룹 CRUD API 및 라우트 구현
- 화면 그룹 목록 조회, 생성, 수정, 삭제 기능 추가
- 화면-그룹 연결 및 데이터 흐름 관리 기능 포함
- 프론트엔드에서 화면 그룹 필터링 및 시각화 기능
2026-01-05 10:05:31 +09:00
SeongHyun Kim ad76bfe3b0 Merge remote-tracking branch 'origin/main' into ksh 2026-01-05 09:28:12 +09:00
SeongHyun Kim 4ad58ba942 feat: 폼 모달 자동 채번 기능 구현 및 임베디드 스크린 렌더링 최적화
UniversalFormModalComponent에 폼 초기화 시 채번 규칙을 가져와 적용하는 generateNumberingValues 구현
채번 생성 중복 호출 방지를 위한 useRef 로직 추가
데이터 업데이트 시 불필요한 리마운트 및 포커스 분실을 방지하기 위해 EmbeddedScreen 컴포넌트 key에서 formDataVersion 제거
2026-01-04 17:41:07 +09:00
DDD1542 36a723b1a0 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node 2026-01-01 02:29:57 +09:00
DDD1542 5a94afc1d5 기존 회사변경수정 2026-01-01 02:29:53 +09:00
leeheejin 2889e4c82c Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2025-12-31 14:53:06 +09:00
kjs c15ec8f7b9 Merge pull request 'feature/screen-management' (#321) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/321
2025-12-31 14:17:53 +09:00
kjs eb868965df 조건부 계산식 2025-12-31 14:17:39 +09:00
kjs 417e1d297b 폼 조건별 계산식 설정기능 2025-12-31 13:53:30 +09:00
leeheejin 0b1dc98e5c Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2025-12-31 11:01:18 +09:00
kjs fff10a1911 Merge pull request '범용 폼모달 제어로직 연동' (#320) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/320
2025-12-31 10:54:28 +09:00
kjs 2842930dba Merge branch 'main' into feature/screen-management 2025-12-31 10:54:20 +09:00
kjs 5bdc903b0d 범용 폼모달 제어로직 연동 2025-12-31 10:54:07 +09:00
kjs 4ce0411809 Merge pull request 'feature/screen-management' (#319) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/319
2025-12-30 17:45:50 +09:00
kjs bd49db16c6 1 2025-12-30 17:45:38 +09:00
kjs 113ef24bdf Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-12-30 15:51:26 +09:00
kjs 7d6bff49aa 폼 채번 오작동 문제 수정 2025-12-30 15:36:28 +09:00
DDD1542 7b773f57b4 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node 2025-12-30 15:28:13 +09:00
DDD1542 58233e51de 각각 별도 TSX 병합 및 회사선택기능 추가 2025-12-30 15:28:05 +09:00
kjs 4421ccaa71 Merge pull request 'feature/screen-management' (#318) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/318
2025-12-30 14:19:44 +09:00
kjs 183f68e89a Merge branch 'main' into feature/screen-management 2025-12-30 14:19:36 +09:00
kjs fb82d2f5a1 분할패널에서 버튼 비활성화기능 수정 2025-12-30 14:19:15 +09:00
kjs 84a3956b02 버튼 버그 수정 2025-12-30 14:13:26 +09:00
kjs c78326bae1 버튼 비활성화 로직 2025-12-30 14:11:42 +09:00
kjs b45f4870e8 신규등록 버튼 버그 수정 2025-12-30 14:03:29 +09:00
kjs fd58e9cce2 행추가,모달 동시입력기능 구현 2025-12-30 13:32:49 +09:00
kjs c7efe8ec33 Merge branch 'feature/screen-management' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-12-30 12:33:18 +09:00
kjs 3c4e251e9b 폼 다중테이블 저장 지원 2025-12-30 12:33:17 +09:00
kjs e902987e44 Merge pull request '로그테이블 관련 내용 추가' (#317) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/317
2025-12-30 10:54:31 +09:00
kjs 54ca51258c Merge branch 'main' into feature/screen-management 2025-12-30 10:54:23 +09:00
kjs 06d5069566 로그테이블 관련 내용 추가 2025-12-30 10:54:06 +09:00
kjs e1d6c1740f Merge pull request '테이블 생성규칙' (#316) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/316
2025-12-30 10:49:01 +09:00
kjs c32bd8a4bf 테이블 생성규칙 2025-12-30 10:48:11 +09:00
leeheejin e040b94a62 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2025-12-30 09:41:56 +09:00
DDD1542 6476a83d86 변경추가 2025-12-29 18:19:17 +09:00
DDD1542 ea3b6d2083 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node 2025-12-29 17:58:27 +09:00
DDD1542 87caa4b3ca 커밋 메세지 메뉴별 대중소 정리 2025-12-29 17:56:26 +09:00
hyeonsu e30b1cc01a Merge pull request '마지막 merge' (#315) from reportMng into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/315
2025-12-29 17:53:24 +09:00
dohyeons 89ce2a9cd0 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into reportMng 2025-12-29 17:52:39 +09:00
SeongHyun Kim ef991b3b26 feat(universal-form-modal): 테이블 컬럼 저장 설정 및 참조 표시 기능 구현
컬럼별 저장 여부 설정 (saveToTarget: true/false)
저장 안 함 컬럼: 참조 ID로 소스 테이블 조회하여 표시만 함
수정 모드에서 참조 컬럼 값 자동 조회 (loadReferenceColumnValues)
Select 컴포넌트 빈 값 필터링으로 안정성 개선
조건 탭 변경 시 소스 데이터 즉시 로드
컬럼 필드 선택 안 함 옵션 추가 (표시 전용 컬럼)
2025-12-29 17:42:30 +09:00
leeheejin 22e0ce1fc5 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2025-12-29 15:54:07 +09:00
SeongHyun Kim 00376202fd feat(universal-form-modal): 조건부 테이블, 동적 Select 옵션, 서브 테이블 수정 로드 기능 구현
- 조건부 테이블: 체크박스/탭으로 조건 선택 시 다른 테이블 데이터 관리
- 동적 Select 옵션: 소스 테이블에서 드롭다운 옵션 동적 로드
- 행 선택 모드: Select 값 변경 시 같은 소스 행의 연관 컬럼 자동 채움
- 수정 모드 서브 테이블 로드: loadOnEdit 옵션으로 반복 섹션 데이터 자동 로드
- SplitPanelLayout2 메인 테이블 병합: 서브 테이블 수정 시 메인 데이터 함께 조회
- 연결 필드 그룹 표시 형식: subDisplayColumn 추가로 메인/서브 컬럼 분리 설정
- UX 개선: 컬럼 선택 UI를 검색 가능한 Combobox로 전환
- saveMainAsFirst 로직 개선: items 없어도 메인 데이터 저장 가능
2025-12-29 09:06:07 +09:00
SeongHyun Kim 6365ce4921 Merge remote-tracking branch 'origin/main' into ksh 2025-12-29 09:05:54 +09:00
SeongHyun Kim 47b23d1aa3 feat(universal-form-modal): 조건부 테이블, 동적 Select 옵션, 서브 테이블 수정 로드 기능 구현
조건부 테이블: 체크박스/탭으로 조건 선택 시 다른 테이블 데이터 관리
동적 Select 옵션: 소스 테이블에서 드롭다운 옵션 동적 로드
행 선택 모드: Select 값 변경 시 같은 소스 행의 연관 컬럼 자동 채움
수정 모드 서브 테이블 로드: loadOnEdit 옵션으로 반복 섹션 데이터 자동 로드
SplitPanelLayout2 메인 테이블 병합: 서브 테이블 수정 시 메인 데이터 함께 조회
연결 필드 그룹 표시 형식: subDisplayColumn 추가로 메인/서브 컬럼 분리 설정
UX 개선: 컬럼 선택 UI를 검색 가능한 Combobox로 전환
saveMainAsFirst 로직 개선: items 없어도 메인 데이터 저장 가능
2025-12-28 19:32:13 +09:00
kjs f63399c1e1 Merge pull request '2레벨메뉴 복사오류 해결' (#314) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/314
2025-12-24 18:38:15 +09:00
kjs 7ece757d3d Merge branch 'main' into feature/screen-management 2025-12-24 18:38:08 +09:00
kjs 722b4787e2 2레벨메뉴 복사오류 해결 2025-12-24 18:37:53 +09:00
SeongHyun Kim 486e5ee29b feat(SplitPanelLayout2): 좌우 패널 수정/삭제 기능 및 모달 자동 닫기 구현
- 좌측 패널에 수정/삭제 버튼 기능 추가
- 좌측 패널 설정에 개별 수정/삭제 UI 추가
- 삭제 API 호출을 백엔드 라우트에 맞게 수정 (DELETE /tables/{tableName}/delete)
- UniversalFormModal 저장 완료 후 closeEditModal 이벤트 발생하여 모달 자동 닫기
- ModalConfig에 showSaveButton 타입 추가
2025-12-24 14:01:38 +09:00
SeongHyun Kim 171ed6e938 fix: 생성 모드에서 부모 데이터가 UniversalFormModal에 전달되지 않는 문제 수정
- DynamicComponentRenderer에서 _initialData 전달 시 빈 객체 체크 추가
- 생성 모드(isCreateMode)에서 originalData가 빈 객체일 때 formData를 사용하도록 수정
- 부모 화면(SplitPanelLayout2)에서 전달한 dept_code, dept_name이 모달에서 정상 수신됨
2025-12-24 13:11:52 +09:00
dohyeons c20e393a1a 텍스트 인라인 편집 기능 추가 2025-12-24 10:58:41 +09:00
dohyeons f300b637d1 복제 및 스타일 복사 기능 추가 2025-12-24 10:42:34 +09:00
dohyeons 386ce629ac 다중 선택 컴포넌트 동시 이동 및 잠금 컴포넌트 복사 제한 2025-12-24 10:20:21 +09:00
dohyeons a299195b42 다중 선택 후 함께 이동하는 기능을 구현 2025-12-24 10:16:37 +09:00
dohyeons 352f9f441f 드래그 영역 선택(Marquee Selection) 기능 추가 2025-12-24 10:10:52 +09:00
dohyeons aa283d11da 텍스트 컴포넌트 더블 클릭 시 컨텐츠 크기에 맞게 자동 조절 2025-12-24 09:48:37 +09:00
leeheejin 6bd25c8a9e Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2025-12-24 09:25:29 +09:00
SeongHyun Kim 9878f1f502 fix(select): Radix UI Select v2.x value="" 에러 수정
Radix UI Select v2.0부터 빈 문자열 value=""가 금지됨 (placeholder 예약어)

수정 파일:
- FieldDetailSettingsModal.tsx: saveColumn "__default__"
- TableLogViewer.tsx: 전체 필터 "__all__"
- FlowStepPanel.tsx: disabled placeholder "__placeholder__"
- MapConfigPanel.tsx: 선택 안 함 "__none__" (2곳)
- DataMappingSettings.tsx: disabled placeholder "__placeholder__" (2곳)
- ScreenAssignmentTab.tsx: disabled placeholder "__placeholder__"
- multilang/page.tsx: 전체 메뉴/타입 "__all__" (2곳)
2025-12-24 09:24:56 +09:00
SeongHyun Kim 3396834417 feat(split-panel-layout2): 그룹핑, 탭 필터링, 설정 모달 기능 추가
- types.ts: GroupingConfig, TabConfig, ColumnDisplayConfig 등 타입 확장
- Component: groupData, generateTabs, filterDataByTab 함수 추가
- ConfigPanel: SearchableColumnSelect, 설정 모달 상태 관리 추가
- 신규 모달: ActionButtonConfigModal, ColumnConfigModal, DataTransferConfigModal
- UniversalFormModal: 연결필드 소스 테이블 Combobox로 변경
2025-12-24 09:08:16 +09:00
SeongHyun Kim 718788110a Merge remote-tracking branch 'origin/main' into ksh 2025-12-24 09:01:04 +09:00
hyeonsu ed2e0a1c6b Merge pull request '수정사항 반영' (#313) from reportMng into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/313
2025-12-23 17:38:05 +09:00
dohyeons 9fe22bc422 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into reportMng 2025-12-23 17:37:37 +09:00
dohyeons 859d68fff8 인쇄 기능 개선 - 중복 호출 제거 및 레이아웃 정확도 향상 2025-12-23 17:37:22 +09:00
kjs a7edd74574 Merge pull request 'feature/screen-management' (#312) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/312
2025-12-23 17:32:43 +09:00
kjs 755bbc0c58 복사 진짜진짜 수정 2025-12-23 17:32:27 +09:00
leeheejin 67471b2518 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2025-12-23 17:20:32 +09:00
kjs 542c0bae94 복사 원본데이터 참조 오류 수정 2025-12-23 17:06:21 +09:00
dohyeons 82a7ff62ee 서명 밑줄 옵션 완전히 제거 2025-12-23 16:00:25 +09:00
dohyeons 83f171189b 페이지 사이즈 변경 시 컴포넌트 위치/크기 비율 자동 조정 2025-12-23 15:12:21 +09:00
dohyeons 050a183c96 feat(report): 리포트-메뉴 연결 기능 추가 2025-12-23 14:34:49 +09:00
dohyeons e1567d3f77 워드 export 레이아웃 및 바코드/서명 렌더링 개선 2025-12-23 13:56:15 +09:00
dohyeons da195200a8 UX 개선 - 입력값 검증 및 confirm을 모달로 변경 2025-12-23 09:49:44 +09:00
kjs c910572754 Merge pull request 'feature/screen-management' (#311) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/311
2025-12-23 09:40:34 +09:00
kjs 4187ec0745 Merge branch 'main' into feature/screen-management 2025-12-23 09:40:26 +09:00
kjs 73cc969bd8 분할패널 상단헤더 크기 조정기능 2025-12-23 09:37:40 +09:00
kjs 5f406fbe88 공통코드 계층구조 구현 2025-12-23 09:31:18 +09:00
SeongHyun Kim 533eaf5c9f feat(TableSection): 테이블 컬럼 부모값 받기 기능 추가
TableColumnConfig에 receiveFromParent, parentFieldName 속성 추가
allComponents에서 부모 화면 필드 자동 추출
컬럼 설정에 "부모값" 스위치 및 부모 필드 선택 UI 추가
handleAddItems()에서 부모값 자동 적용 로직 구현
2025-12-23 09:24:59 +09:00
dohyeons 7875d8ab86 페이지 크기에 최솟값 1 설정 2025-12-22 18:20:16 +09:00
dohyeons e1a032933d 화면 여백에 최솟값 0 설정 2025-12-22 18:17:58 +09:00
dohyeons 99c0960325 서명 생성 시 폰트가 일부 글자에만 적용되는 문제 수정 2025-12-22 17:42:35 +09:00
hyeonsu ae6d917ec4 Merge pull request '리포트 관리 수정' (#310) from reportMng into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/310
2025-12-22 17:08:15 +09:00
dohyeons 5f26e998e3 워터마크를 전체 페이지 공유 방식으로 변경 2025-12-22 17:06:11 +09:00
kjs b85b888007 Merge branch 'feature/screen-management' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-12-22 16:39:47 +09:00
kjs 9493d81903 카테고리 복사에러 수정 2025-12-22 16:39:46 +09:00
dohyeons d7f015b37d Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into reportMng 2025-12-22 15:45:35 +09:00
dohyeons e8b581f5da 미리보기 모달 페이지 크기 불일치 수정 2025-12-22 15:45:17 +09:00
dohyeons d90e68905e 워터마크 기능 추가 2025-12-22 15:40:31 +09:00
leeheejin f1c4891924 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2025-12-22 15:31:17 +09:00
dohyeons 002c71f9e8 서명 생성 시 한글 폰트가 일부 글자만 표시되는 문제 수정 2025-12-22 15:21:14 +09:00
dohyeons 117912045f 캔버스 스케일 팩터를 고정 px 단위로 통일 2025-12-22 15:13:49 +09:00
SeongHyun Kim 6a4ebf362c feat(UniversalFormModal): 저장 버튼 표시 설정 옵션 추가
ConfigPanel에 showSaveButton 체크박스 추가
체크 해제 시 모달 하단 저장 버튼 숨김 가능
SaveSettingsModal SelectItem key 중복 해결
서브 테이블 삭제 버튼 클릭 이벤트 충돌 수정
2025-12-22 14:36:13 +09:00
dohyeons 0decfe95de 미리보기/인쇄에 바코드, QR코드, 체크박스 렌더링 추가 2025-12-22 13:58:12 +09:00
dohyeons 2b912105a8 QR코드 다중 필드 JSON 및 모든 행 포함 기능 추가 2025-12-22 13:36:42 +09:00
dohyeons acc867e38d QR코드 정사각형 강제 2025-12-22 11:51:19 +09:00
dohyeons c5cb4336e5 바코드/QR코드 투명 배경 처리 및 QR코드 에러 복구 버그 수정 2025-12-22 11:29:35 +09:00
kjs 01778661ed Merge pull request '복사에러 수정' (#309) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/309
2025-12-22 09:53:53 +09:00
kjs 6fced32e29 Merge branch 'main' into feature/screen-management 2025-12-22 09:53:45 +09:00
kjs 1cadafea0e 복사에러 수정 2025-12-22 09:53:22 +09:00
kjs 9162e3aa96 Merge pull request 'feature/screen-management' (#308) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/308
2025-12-22 09:30:18 +09:00
kjs 79b3c19c68 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-12-22 09:29:49 +09:00
kjs 43ae8d1c49 Merge branch 'feature/screen-management' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-12-19 18:31:21 +09:00
kjs a8bc7983c0 복사에러 수정 2025-12-19 18:31:20 +09:00
dohyeons 506a31df02 컴포넌트 WYSIWYG 개선 및 구분선 리사이즈 방향 제한 2025-12-19 18:24:18 +09:00
dohyeons 8789b2b864 구분선 리사이즈 개선 2025-12-19 18:19:29 +09:00
dohyeons 8d34b73a45 체크박스 컴포넌트 추가 2025-12-19 18:06:25 +09:00
dohyeons ea01309158 리포트 디자이너에 바코드/QR코드 컴포넌트 추가 2025-12-19 17:59:54 +09:00
kjs 45749c99c8 Merge pull request '채번 복사 오류 수정' (#307) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/307
2025-12-19 17:42:01 +09:00
kjs 43a6fb675f Merge branch 'main' into feature/screen-management 2025-12-19 17:41:55 +09:00
kjs 961e7e9a14 채번 복사 오류 수정 2025-12-19 17:41:14 +09:00
SeongHyun Kim f38447be8e Merge origin/main into ksh - resolve conflicts 2025-12-19 16:38:12 +09:00
SeongHyun Kim a1b05b8982 feat(UniversalFormModal): 수정 모드 INSERT/UPDATE/DELETE 지원
_groupedData를 테이블 섹션에 초기화하여 기존 품목 표시
originalGroupedData로 원본 데이터 보관하여 변경 추적
handleUniversalFormModalTableSectionSave()에 INSERT/UPDATE/DELETE 분기 로직 구현
EditModal, ConditionalSectionViewer에서 UniversalFormModal 감지 시 onSave 미전달
저장 완료 후 closeEditModal 이벤트 발생
2025-12-19 16:08:27 +09:00
kjs 932eb288c6 Merge pull request 'feature/screen-management' (#306) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/306
2025-12-19 16:08:09 +09:00
kjs 09f477172c Merge main into feature/screen-management (menuCopyService 충돌 해결) 2025-12-19 16:07:41 +09:00
kjs 958624012d 복사기능 오류수정 2025-12-19 16:01:57 +09:00
kjs 483dbf8a1f fix: scope_type=menu 채번규칙 삭제 시 check constraint 위반 해결
- scope_type='menu'인 채번규칙: 메뉴 삭제 시 함께 삭제 (파트 포함)
- scope_type!='menu'인 채번규칙: menu_objid만 NULL로 설정 (규칙 보존)
- check_menu_scope_requires_menu_objid 제약조건 준수
2025-12-19 15:52:53 +09:00
SeongHyun Kim 9fb94da493 feat(UniversalFormModal): 섹션별 저장 방식 설정 기능 추가
SectionSaveMode 타입 추가 (공통 저장/개별 저장)
SaveSettingsModal에 섹션별/필드별 저장 방식 설정 UI 추가
saveSingleRow()에 공통 필드 + 품목 병합 저장 로직 구현
buttonActions.ts에 외부 저장 버튼용 병합 저장 처리 추가
2025-12-19 14:53:16 +09:00
hyeonsu f1c775b691 Merge pull request 'reportMng' (#305) from reportMng into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/305
2025-12-19 14:12:32 +09:00
dohyeons 69754a31cb 디지털 3d 뷰어 10초단위 갱신 구현 2025-12-19 14:12:05 +09:00
SeongHyun Kim 9684a83f37 feat(TableSection): 날짜 컬럼 일괄 적용 기능 추가
TableColumnConfig에 batchApply 속성 추가
날짜 타입 컬럼 설정 시 "일괄 적용" 스위치 표시
첫 번째 날짜 입력 시 모든 행에 자동 적용
중복 적용 방지를 위한 batchAppliedFields 플래그 관리
데이터 전체 삭제 시 플래그 리셋
2025-12-19 14:07:35 +09:00
dohyeons 2e7a215066 오른쪽 그리드 크기 조절 2025-12-19 14:00:38 +09:00
kjs 228c497569 fix: 메뉴 복사 로직 개선 - FK 에러 해결 및 성능 최적화
- numbering_rules FK 에러 해결 (menu_objid NULL 설정)
- category_column_mapping FK 에러 해결 (삭제 후 재복사)
- 채번규칙 매핑 보완 로직 추가 (화면에서 참조하는 채번규칙을 이름으로 찾아 매핑)
- 기존 채번규칙/카테고리 매핑의 menu_objid 갱신 로직 추가
- N+1 쿼리 최적화 (배치 조회/삽입으로 변경)
  - 메뉴 삭제: N개 쿼리 → 1개
  - 화면 할당/플로우 수집: N개 쿼리 → 1개
  - 화면 정의 조회: N개 쿼리 → 1개
  - 레이아웃 삽입: N개 쿼리 → 화면당 1개
  - 채번규칙/카테고리 매핑 업데이트: CASE WHEN 배치 처리
- 예상 성능 개선: ~10배
2025-12-19 13:50:13 +09:00
hyeonsu 01422e035b Merge pull request '위젯 컴팩트 모드 제거' (#304) from reportMng into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/304
2025-12-19 13:48:07 +09:00
dohyeons adb21a5308 위젯 컴팩트 모드 제거 2025-12-19 13:47:30 +09:00
SeongHyun Kim 228fd33a2a fix(RepeaterTable): 조회 컬럼 헤더 라벨 개선 및 코드 정리
헤더에 "컬럼명 - 옵션라벨" 형식으로 전체 정보 표시
옵션 변경 시 컬럼 너비 자동 재계산
API 검색 시 정확한 일치 검색(equals) 적용
디버그 로그 제거
설정 UI 라벨 사용자 친화적으로 변경
2025-12-19 13:43:26 +09:00
SeongHyun Kim c86140fad3 feat(UniversalFormModal): 테이블 섹션 컬럼 조회(Lookup) 기능 구현
- LookupConfig, LookupOption, LookupCondition 타입 정의
- sourceType 4가지 유형 지원 (currentRow, sourceTable, sectionField, externalTable)
- TableColumnSettingsModal에 "조회 설정" 탭 추가
- TableSectionSettingsModal에 간단 조회 설정 UI 추가
- fetchExternalValue, fetchExternalLookupValue 함수 구현
- 헤더 드롭다운에서 조회 옵션 선택 기능
2025-12-19 11:48:46 +09:00
hyeonsu 9902b65598 Merge pull request '외부 업체 전용 뷰어 모드 구현' (#303) from reportMng into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/303
2025-12-19 09:42:11 +09:00
dohyeons 981ec27ed7 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into reportMng 2025-12-19 09:41:29 +09:00
kjs 849343ecfd 컴포넌트 통합 계획 2025-12-19 09:27:11 +09:00
kjs 51c788cae8 복사기능 2025-12-19 09:26:44 +09:00
dohyeons 06d2cf7f72 외부 업체 전용 뷰어 모드 구현 2025-12-18 18:14:27 +09:00
SeongHyun Kim fdb9ef9167 feat(RepeaterTable): 컬럼 너비 자동 맞춤 기능 추가
- 균등 분배 / 자동 맞춤 토글 방식으로 변경
- measureTextWidth(): 한글/영문/숫자별 픽셀 계산
- applyAutoFitWidths(): 글자 너비 기준 컬럼 조정
- 계산 규칙 결과 필드를 드롭다운으로 변경
2025-12-18 16:39:10 +09:00
kjs 84efaed1eb 에러 수정 2025-12-18 16:35:55 +09:00
hyeonsu bdb70ce5b7 Merge pull request '3d 전체화면 수정' (#302) from reportMng into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/302
2025-12-18 16:32:04 +09:00
dohyeons 8306d7961c Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into reportMng 2025-12-18 16:31:37 +09:00
dohyeons 61ceab1a7b 외부 업체일 때만 전체화면 되도록 수정 2025-12-18 16:31:25 +09:00
hyeonsu 90d136ca85 Merge pull request '티라유텍 수정사항 적용' (#301) from reportMng into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/301
2025-12-18 16:04:27 +09:00
dohyeons da24db8f37 외부 업체 전용 모드 및 3D 캔버스 전체 화면 기능 구현 2025-12-18 16:03:47 +09:00
dohyeons a617c26721 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into reportMng 2025-12-18 16:02:54 +09:00
kjs 66bd21ee65 엔티티타입 표시오류 수정 2025-12-18 15:24:20 +09:00
SeongHyun Kim 1c6eb2ae61 feat(UniversalFormModal): 테이블 섹션 기능 추가
- FormSectionConfig에 type("fields"|"table") 및 tableConfig 필드 추가
- TableSectionRenderer, TableSectionSettingsModal 신규 컴포넌트 생성
- ItemSelectionModal에 모달 필터 기능 추가 (소스 테이블 distinct 값 조회)
- 설정 패널에서 테이블 섹션 추가/설정 UI 구현
2025-12-18 15:19:59 +09:00
kjs cf8a5a3d93 연쇄관계 자식 라벨표시 2025-12-18 15:16:34 +09:00
dohyeons a24654c867 디지털 트윈 자재 목록 테이블 가독성 개선 2025-12-18 15:11:03 +09:00
dohyeons 79c1a456f0 리스트 위젯 컴팩트 모드 추가 (세로 1칸 대응) 2025-12-18 15:04:55 +09:00
dohyeons ca86c0a10f 위젯 컴팩트 모드 추가 (1x1 사이즈 대응) 2025-12-18 14:42:58 +09:00
kjs 4e987f208a Merge pull request 'feature/screen-management' (#300) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/300
2025-12-18 14:34:54 +09:00
kjs bca6de9811 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-12-18 14:34:38 +09:00
kjs f03b247db2 카테고리 설정 구현 2025-12-18 14:12:48 +09:00
hyeonsu 176e9cf421 Merge pull request '타입 에러 수정' (#299) from reportMng into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/299
2025-12-18 13:32:59 +09:00
dohyeons 7ca4eea5c1 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into reportMng 2025-12-18 13:32:47 +09:00
dohyeons 41442dccc2 타입 에러 수정 2025-12-18 13:32:22 +09:00
hyeonsu c5f24dc789 Merge pull request 'reportMng' (#298) from reportMng into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/298
2025-12-18 13:28:16 +09:00
dohyeons ac8961160d Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into reportMng 2025-12-18 13:27:25 +09:00
dohyeons 36bac321b8 후판정보 조회 방식 개선 2025-12-18 13:26:11 +09:00
dohyeons 403bd0f8a1 계산 컴포넌트 연산자 로직 개선 2025-12-18 11:41:48 +09:00
kjs 75e5326b3e 메뉴 복사로직 개선 2025-12-18 10:55:26 +09:00
dohyeons 1fd428c016 카드 컴포넌트 추가 및 페이지번호/쿼리 버그 수정 2025-12-18 10:39:57 +09:00
kjs c3f066f88f 테이블 틀고정기능 2025-12-18 10:15:33 +09:00
kjs 061fd45bc8 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-12-18 09:55:28 +09:00
kjs f1a670ca9a Merge branch 'feature/screen-management' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-12-18 09:53:27 +09:00
kjs ff3c51c457 탭 컴포넌트 외부 검색필터 동작 구현 2025-12-18 09:53:26 +09:00
dohyeons 0ed8e686c0 레포트에 페이지번호 컴포넌트 추가 2025-12-18 09:45:07 +09:00
dohyeons 0abe87ae1a 레포트 테이블 수정 방식 수정 2025-12-17 17:59:01 +09:00
dohyeons 6c7807e1d1 리포트 복사 기능 개선 2025-12-17 17:43:21 +09:00
dohyeons c6f0750050 오류 해결 2025-12-17 17:42:38 +09:00
kjs ffd31fc923 Merge pull request '엔티티컬럼 표시설정 수정' (#297) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/297
2025-12-17 17:41:49 +09:00
kjs 7b30f6c7f2 Merge branch 'main' into feature/screen-management 2025-12-17 17:41:43 +09:00
kjs 3589e4a5b9 엔티티컬럼 표시설정 수정 2025-12-17 17:41:29 +09:00
dohyeons 60b4bffdf9 레이아웃 저장/로드 데이터 구조 수정 2025-12-17 17:30:40 +09:00
dohyeons fb4b5b7e26 텍스트 컴포넌트를 textarea로 변경 2025-12-17 17:10:26 +09:00
SeongHyun Kim 8687c88f70 Merge remote-tracking branch 'origin/main' into ksh 2025-12-17 17:07:06 +09:00
SeongHyun Kim 6dcace3135 fix(RepeaterTable): 숫자 필드 포맷팅 로직 개선
- 정수/소수점 자동 구분 처리
- 천 단위 구분자(toLocaleString) 적용
- null/undefined/NaN 예외 처리 추가
2025-12-17 17:04:45 +09:00
dohyeons b7b881ee86 레이블 컴포넌트 제거 2025-12-17 17:02:26 +09:00
dohyeons f47a0c770b 템플릿 팔레트에서 시스템 템플릿 섹션 제거 2025-12-17 16:51:19 +09:00
dohyeons 6f7a76febe AccordionTrigger 내 버튼 중첩 에러 수정 2025-12-17 16:43:29 +09:00
kjs 44f5265105 Merge pull request 'feature/screen-management' (#296) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/296
2025-12-17 16:38:46 +09:00
kjs e50ddd03d3 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-12-17 16:38:32 +09:00
kjs ae38e0f249 엔티티 타입 에러수정 및 배지 색상없음 오류 수정 2025-12-17 16:38:12 +09:00
SeongHyun Kim 52db6fd43c feat(backend): created_date/updated_date 컬럼 자동 설정 기능 추가
- tableManagementService: insertData()에 created_date 자동 설정
- tableManagementService: updateData()에 updated_date 자동 설정
- dynamicFormService: updateFormRecord()에 updated_date 자동 설정
- 레거시 테이블(sales_order_mng 등) 날짜 컬럼 지원
2025-12-17 16:36:10 +09:00
dohyeons 7acb4981b5 리포트 디자이너 UI 개선 2025-12-17 16:31:58 +09:00
dohyeons 5e0dae0aae Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into reportMng 2025-12-17 16:12:11 +09:00
dohyeons 2e122b0703 Word 변환 WYSIWYG 개선 - 위치/크기/줄바꿈/가로배치 지원 2025-12-17 16:11:52 +09:00
SeongHyun Kim 132cf4cd7d fix(universal-form-modal): 수정 모드에서 옵셔널 필드 그룹 자동 활성화
- 기존 데이터의 triggerField 값이 triggerValueOnAdd와 일치하면 그룹 자동 활성화
- 활성화된 그룹의 필드값도 기존 데이터로 초기화
- 신규 등록 모드에서는 기존대로 비활성화 상태 유지
2025-12-17 15:27:28 +09:00
kjs 0ec6d082d6 Merge pull request 'feature/screen-management' (#295) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/295
2025-12-17 15:00:52 +09:00
SeongHyun Kim 0810debd2b fix(universal-form-modal): 옵셔널 필드 그룹 연동 필드 기본값 설정
- 모달 초기화 시 optionalFieldGroups의 triggerField에 기본값 설정
- triggerValueOnRemove 값을 기본값으로 사용 (비활성화 상태 기본값)
- 수정 모드에서는 기존 데이터 값 유지
2025-12-17 15:00:45 +09:00
kjs 857e46eab6 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-12-17 15:00:30 +09:00
kjs be916d3db7 탭안에있는 화면 검색필터링 기능 2025-12-17 15:00:15 +09:00
SeongHyun Kim ccbbf46faf feat(universal-form-modal): 옵셔널 필드 그룹 및 카테고리 Select 옵션 기능 추가
- 옵셔널 필드 그룹: 섹션 내 선택적 필드 그룹 지원 (추가/제거, 연동 필드 자동 변경)
- 카테고리 Select: table_column_category_values 테이블 값을 Select 옵션으로 사용
- 전체 카테고리 컬럼 조회 API: GET /api/table-categories/all-columns
- RepeaterFieldGroup 저장 시 공통 필드 자동 병합
2025-12-17 14:30:29 +09:00
kjs 1995c3dca4 엑셀 업로드 기능 개선 2025-12-17 12:01:16 +09:00
kjs 3d287bb883 엔티티타입 연쇄관계관리 설정 추가 2025-12-17 11:48:05 +09:00
SeongHyun Kim 31746e8a0b Merge remote-tracking branch 'origin/main' into ksh 2025-12-16 18:49:03 +09:00
kjs 0832e7b6eb 카드 디스플레이 표시개선 2025-12-16 18:06:15 +09:00
kjs 00afa77d87 Merge pull request 'feature/screen-management' (#294) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/294
2025-12-16 18:02:36 +09:00
kjs d6f40f3cd3 버튼별로 데이터 필터링기능 2025-12-16 18:02:08 +09:00
kjs a73b37f558 좌측 선택데이터에 대한 우측에 데이터표시및 버튼표시 컴포넌트 2025-12-16 16:13:43 +09:00
kjs 3a55ea3b64 분할패널 라벨검색 가능하게 수정 2025-12-16 15:32:43 +09:00
kjs 963e0c2d24 카드 디스플레이
선택안함 옵션 추가
2025-12-16 14:56:31 +09:00
kjs f7e3c1924c 엔티티 즉시저장기능 추가 2025-12-16 14:38:03 +09:00
SeongHyun Kim 342042d761 feat(repeater-table): 행 드래그 앤 드롭 및 컬럼 너비 관리 기능 추가
- @dnd-kit 라이브러리로 행 순서 드래그 앤 드롭 구현
- SortableRow 컴포넌트로 드래그 가능한 테이블 행 구현
- GripVertical 아이콘 드래그 핸들 추가
- 드래그 시 선택된 행 인덱스 자동 재계산
- "균등 분배" 버튼으로 컬럼 너비 컨테이너에 맞게 균등 분배
- 컬럼 헤더 더블클릭으로 데이터 기준 자동 확장/복구 토글
- Input 컴포넌트 min-w-0 w-full 적용으로 컬럼 너비 초과 방지
2025-12-16 13:58:30 +09:00
kjs d8329d31e4 우측화면 데이터 필터링 수정 2025-12-16 11:49:10 +09:00
SeongHyun Kim 56608001ff feat(modal-repeater-table): 체크박스 기반 일괄 삭제 기능 추가
- RepeaterTable: 체크박스 컬럼 추가 (전체 선택/개별 선택 지원)
- RepeaterTable: 선택된 행 시각적 피드백 (bg-blue-50)
- RepeaterTable: 기존 개별 삭제 버튼 컬럼 제거
- ModalRepeaterTableComponent: selectedRows 상태 및 handleBulkDelete 함수 추가
- ModalRepeaterTableComponent: "선택 삭제" 버튼 UI 추가
- RepeatScreenModalConfigPanel: 행 번호 컬럼 선택에서 빈 값 필터링
2025-12-16 11:39:30 +09:00
kjs 4e74c7b5ba 카드 디스플레이 분할패널 설정 2025-12-16 10:46:43 +09:00
kjs b3e6613d66 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-12-16 10:46:15 +09:00
hyeonsu 27b5f54a7c Merge pull request 'common/feat/dashboard-map' (#293) from common/feat/dashboard-map into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/293
2025-12-16 10:30:00 +09:00
dohyeons eb56aec0a7 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map 2025-12-16 10:29:41 +09:00
dohyeons 270b97eec9 RepeaterInput 항목 삭제 개선 및 우측 패널 리렌더링 최적화 2025-12-16 10:29:23 +09:00
hyeonsu 9420b14836 Merge pull request 'common/feat/dashboard-map' (#292) from common/feat/dashboard-map into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/292
2025-12-16 10:03:04 +09:00
dohyeons 7688cb8078 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map 2025-12-16 10:02:32 +09:00
dohyeons a2582a28e4 날짜 입력 시 하루 밀리는 타임존 버그 수정 2025-12-16 10:02:16 +09:00
SeongHyun Kim 4cff9e4cec fix(button-actions): 출하계획 모달 데이터 전달 오류 수정
- handleModal: context.selectedRowsData를 selectedData로 복원하여 출하계획 등 모달에서 사용 가능
- handleOpenModalWithData: modalDataStore 데이터를 selectedData/selectedIds로 이벤트에 포함
- ButtonConfigPanel: split-panel-layout2 타입 소스 테이블 감지 지원 추가
- ButtonConfigPanel: column_name/display_name 컬럼 형식 폴백 추가
- ButtonConfigPanel: currentTableName 폴백으로 테이블명 감지 안정성 향상
- ButtonConfigPanel: 필드 매핑 UI를 세로 배치로 변경하여 가독성 개선
2025-12-16 09:13:42 +09:00
SeongHyun Kim cee9903f94 Merge remote-tracking branch 'origin/main' into ksh 2025-12-15 18:43:32 +09:00
SeongHyun Kim f6051e8bbd fix(button-actions): openModalWithData 액션에서 선택된 데이터 전달 누락 수정
- handleOpenModalWithData에서 modalDataStore 데이터를 selectedData/selectedIds로 이벤트에 포함
- RepeatScreenModal에서 groupedData로 사용할 수 있도록 데이터 전달 경로 완성
- ButtonConfigPanel 필드 매핑 UI를 세로 배치로 변경하여 가독성 개선
- split-panel-layout2 컴포넌트 타입 소스 테이블 감지 지원 추가
- currentTableName 폴백 로직 추가로 테이블명 감지 안정성 향상
2025-12-15 18:39:59 +09:00
kjs cb38864ad8 카드 디스플레이 삭제기능 구현 2025-12-15 18:29:18 +09:00
SeongHyun Kim ffb59b4e1b 출하계획 모달 수정 2025-12-15 18:09:55 +09:00
hyeonsu 0ac5402b0b Merge pull request 'common/feat/dashboard-map' (#291) from common/feat/dashboard-map into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/291
2025-12-15 18:01:58 +09:00
dohyeons 8cc189da17 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map 2025-12-15 18:01:42 +09:00
dohyeons 8425dece7f 분할 패널 좌측 선택 시 우측 폼 데이터가 갱신되지 않는 문제 수정 2025-12-15 17:47:16 +09:00
SeongHyun Kim 2f66fe1913 Merge origin/main into ksh - resolve conflicts 2025-12-15 17:28:32 +09:00
leeheejin 109380b9e5 이제 디비에 한글로 출발지 목적지 저장되도록 2025-12-15 17:01:04 +09:00
hjlee a495088068 Merge pull request 'lhj' (#290) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/290
2025-12-15 16:55:44 +09:00
leeheejin 54c674f3c9 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2025-12-15 16:54:52 +09:00
leeheejin 7f15861b6e 출발지도착지 디비에서 끌어옴 2025-12-15 16:54:03 +09:00
leeheejin c1be1893f5 지금은 출발지목적지가 달라도 강제로 수정이 가능합니다. 2025-12-15 16:45:26 +09:00
hyeonsu a73f8ae7b3 Merge pull request 'common/feat/dashboard-map' (#289) from common/feat/dashboard-map into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/289
2025-12-15 15:59:30 +09:00
dohyeons c52efddae9 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map 2025-12-15 15:59:10 +09:00
dohyeons 93443c98ee 분할 패널 RepeaterFieldGroup 저장 및 DB webType 자동 매핑 구현 2025-12-15 15:40:29 +09:00
kjs 6449eb5ac3 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-12-15 14:51:54 +09:00
kjs 3c73c20292 화면 복사문제 수정 2025-12-15 14:51:41 +09:00
SeongHyun Kim 16885225a0 feat(edit-modal): 저장 완료 후 제어로직(노드 플로우) 자동 실행 기능 추가
- EditModal에서 INSERT/UPDATE/그룹 저장 완료 후 제어로직 자동 실행
- loadSaveButtonConfig(): 모달 내부 저장 버튼의 제어로직 설정 조회
- findSaveButtonInComponents(): 재귀적으로 저장 버튼 탐색 (conditional-container 내부 포함)
- buttonActions.ts: openEditModal 이벤트에 buttonConfig, buttonContext 전달
- executeAfterSaveControl()을 public으로 변경하여 외부 호출 가능
- 제어로직 실행 오류 시 저장 성공 유지, 경고 토스트만 표시
2025-12-15 14:46:32 +09:00
hjlee 81c3e1b4ba Merge pull request '시간쪽 관련된거' (#288) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/288
2025-12-15 13:46:55 +09:00
leeheejin 93b37e99e6 시간쪽 관련된거 2025-12-15 13:46:42 +09:00
hyeonsu f31fca0115 Merge pull request 'common/feat/dashboard-map' (#287) from common/feat/dashboard-map into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/287
2025-12-15 11:28:21 +09:00
dohyeons c2d473bf59 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map 2025-12-15 11:27:44 +09:00
dohyeons 23a1dd6321 분할 패널 테이블에서 셀 클릭 시 행 선택 및 자동 필터링 구현 2025-12-15 11:17:10 +09:00
hyeonsu 4e10449b3f Merge pull request '3D 야드 위젯 새로고침 버튼구현' (#286) from common/feat/dashboard-map into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/286
2025-12-15 09:47:58 +09:00
dohyeons d21c4acf0f Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map 2025-12-15 09:47:45 +09:00
dohyeons 95cbd62b1a 3D 야드 위젯 새로고침 버튼구현 2025-12-15 09:46:26 +09:00
SeongHyun Kim f7384cb450 fix(modal-repeater-table): 외부 테이블 조인 시 ID 타입 변환 추가 2025-12-15 09:25:14 +09:00
kjs 665d1b51d8 Merge pull request 'feature/screen-management' (#285) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/285
2025-12-12 18:29:20 +09:00
kjs 036380d267 다중 제어기능 구현 2025-12-12 18:28:58 +09:00
kjs 4777c2bc0a Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-12-12 17:09:41 +09:00
kjs b755f8f017 분할패널 소스컬럼 추론 2025-12-12 17:08:36 +09:00
hjlee e8bc770439 Merge pull request 'lhj' (#284) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/284
2025-12-12 15:47:15 +09:00
leeheejin 8f6af5018c Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2025-12-12 15:46:43 +09:00
leeheejin 76f6bd7f27 설정들 고친거 2025-12-12 15:45:57 +09:00
kjs 722718b7ed 설비 수정모달 데이터 안넘어오는 현상 수정 2025-12-12 14:37:24 +09:00
SeongHyun Kim 7ad70462d5 Merge branch 'ksh' 2025-12-12 14:14:58 +09:00
SeongHyun Kim 51099ba858 fix(modal-repeater-table): 외부 테이블 조인 시 ID 타입 변환 추가
문제:
- 외부 테이블 조인 시 ID 값이 문자열로 전달되어 백엔드에서 ILIKE 검색 수행
- 문자열 '189'로 검색하면 '189', '1890', '18900' 등 모두 매칭되는 문제
- 발주 등록 화면에서 품목 참조 데이터 조회 실패

해결:
- fetchReferenceValue 함수: 조인 조건 값 타입 변환 추가
- resolveDynamicValue 함수 (단순 테이블 조회): 조인 조건 값 타입 변환 추가
- resolveDynamicValue 함수 (복합 조인): 조인 조건 값 타입 변환 추가

변환 로직:
- targetField가 '_id'로 끝나거나 'id'인 경우 Number()로 변환
- NaN 체크로 변환 불가능한 값은 원본 유지
- 백엔드에서 숫자는 = 비교, 문자열은 ILIKE 검색 수행하므로 정확한 매칭 필요

영향 범위:
- modal-repeater-table 컴포넌트를 사용하는 모든 화면
- 발주 등록, 수주 등록 등 품목 참조 테이블 조회
2025-12-12 14:12:33 +09:00
SeongHyun Kim 11215e3316 chore: 미사용 수주 등록 모듈(orderController) 삭제
- 백엔드: orderController.ts, orderRoutes.ts 삭제
- 프론트엔드: components/order/, order-registration-modal/ 삭제
- app.ts, index.ts, getComponentConfigPanel.tsx에서 참조 제거
- 현재 sales_order_mng 기반 수주 시스템 사용으로 구 모듈 불필요
2025-12-12 14:02:17 +09:00
kjs 309d4be31d Merge pull request 'feature/screen-management' (#283) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/283
2025-12-12 13:50:49 +09:00
kjs add98673bb Merge branch 'main' into feature/screen-management 2025-12-12 13:50:43 +09:00
kjs 3a6af2fb71 분할패널 조인문제 수정 2025-12-12 13:50:33 +09:00
SeongHyun Kim c85841b59f fix(repeat-screen-modal): 외부 테이블 조인 시 ID 타입 변환 추가
- 조인 키가 '_id' 또는 'id'인 경우 문자열을 숫자로 변환
- 백엔드 ILIKE 검색 방지로 정확한 ID 매칭 보장
- API 호출 파라미터 로깅 추가 (디버깅용)
2025-12-12 13:46:20 +09:00
hjlee ac3de6ab07 Merge pull request 'lhj' (#282) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/282
2025-12-12 13:45:29 +09:00
SeongHyun Kim 1680163c61 fix: ModalRepeaterTable 빈 행 자동 표시 문제 해결
- 신규 등록 모달 오픈 시 빈 객체 필터링 기능 추가
- isEmptyRow 함수로 안전한 빈 객체 판단 (id 필드 체크)
- useState 초기화 및 useEffect 동기화에 필터링 적용
- 수정 모달의 실제 데이터는 id 필드로 보호
2025-12-12 11:10:51 +09:00
SeongHyun Kim a9135165d9 fix: UniversalFormModal 채번 규칙 자동 생성 기능 개선
- 모달 재오픈 시 동일 번호 유지 (previewCode 사용)
- 저장 시 정상적인 순번 증가 (allocateCode에서 nextSequence 사용)
- refreshKey를 React key로 전달하여 컴포넌트 강제 리마운트
- ruleId를 부모 컴포넌트까지 전달하여 buttonActions에서 감지
- 미리보기와 저장 번호 일치 (currentSequence + 1 통일)
2025-12-12 10:55:09 +09:00
kjs 0ee49b77ae 설비 품목 하나만 추가되는 오류 수정 2025-12-12 10:44:59 +09:00
SeongHyun Kim 5ed80df2d4 Merge branch 'ksh' 2025-12-12 09:15:26 +09:00
SeongHyun Kim ab8b5a2c91 fix: UniversalFormModal 채번규칙 중복 호출 문제 해결
- generateNumberingValues 중복 호출 방지 (ref 플래그 추가)
- generateOnOpen 시 allocateCode 직접 호출로 변경
- config 변경 시 initializeForm 재호출 비활성화
- cleanup 함수에서 플래그 초기화 추가
- 저장 시점 채번 로직 간소화 (generateOnSave만 처리)
2025-12-11 19:14:55 +09:00
kjs e8af7ae4c6 Merge pull request '분할패널 버튼 이동 가능하게 수정' (#281) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/281
2025-12-11 18:40:57 +09:00
kjs 016b8f707b 분할패널 버튼 이동 가능하게 수정 2025-12-11 18:40:39 +09:00
SeongHyun Kim 038c5a0973 fix(numbering-rule): 채번 미리보기 순번 수정 및 저장 시 재할당 로직 추가
- 미리보기 시 currentSequence + 1로 다음 순번 표시
- UniversalFormModal에서 미리보기/실제할당 분리
- _needsAllocation 플래그로 저장 시 재할당 여부 판단
- RepeatScreenModal 외부 데이터 소스 조인/필터 설정 UI 추가
2025-12-11 18:26:33 +09:00
hyeonsu 88024b4e60 Merge pull request 'fix: tiptap 버전 충돌 해결 및 legacy-peer-deps 적용' (#280) from common/feat/dashboard-map into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/280
2025-12-11 16:32:33 +09:00
dohyeons fcc709684b Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map 2025-12-11 16:32:15 +09:00
dohyeons b208b0be34 fix: tiptap 버전 충돌 해결 및 legacy-peer-deps 적용 2025-12-11 16:31:47 +09:00
leeheejin c2a6dbea3b Merge branch 'main' into lhj - resolve TableListComponent conflict 2025-12-11 16:28:46 +09:00
leeheejin 563acb7c00 봄 에러 뜨던거 고침2 2025-12-11 16:09:58 +09:00
leeheejin 30361e0f45 돔 에러 뜨던거 고침 2025-12-11 16:07:16 +09:00
leeheejin 84bd1ce154 첨부파일 이름으로 나오게 함 2025-12-11 15:50:28 +09:00
leeheejin a489e2c155 첨부파일 기능 완료 2025-12-11 15:46:05 +09:00
SeongHyun Kim 6a676dcf5c refactor(universal-form-modal): ConfigPanel 모달 분리 및 설정 패널 오버플로우 수정
- UniversalFormModalConfigPanel을 3개 모달로 분리 (2300줄 → 300줄)
  - FieldDetailSettingsModal: 필드 상세 설정
  - SaveSettingsModal: 저장 설정
  - SectionLayoutModal: 섹션 레이아웃 설정
- FloatingPanel, DetailSettingsPanel 가로 스크롤 오버플로우 수정
- SelectOptionConfig에 saveColumn 필드 추가 (저장 값 별도 지정)
2025-12-11 15:29:37 +09:00
dohyeons 308c78b067 배포 오류 해결 2025-12-11 15:29:25 +09:00
hyeonsu 3f1ecfab15 Merge pull request '외부 DB 연결 끊김 오류 해결' (#279) from common/feat/dashboard-map into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/279
2025-12-11 15:26:16 +09:00
dohyeons ab9ddaa190 외부 DB 연결 끊김 오류 해결 2025-12-11 15:25:48 +09:00
dohyeons b03132595c 패키지 다운그레이드 2025-12-11 14:38:57 +09:00
dohyeons e67b5f76a8 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node 2025-12-11 14:34:48 +09:00
dohyeons 1d97bcaa9f 배포 오류 해결 2025-12-11 14:34:42 +09:00
kjs d3b4c4c42e Merge pull request 'feature/screen-management' (#278) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/278
2025-12-11 14:32:34 +09:00
kjs f2b0ac8fd5 Merge branch 'main' into feature/screen-management 2025-12-11 14:32:27 +09:00
kjs a4cf11264d 테이블리스트컴포넌트 합산기능 2025-12-11 14:32:15 +09:00
kjs 215242b676 검색필터 분할패널 데이터 합산기능 추가 2025-12-11 14:25:28 +09:00
SeongHyun Kim 190a677067 feat(modal-repeater-table): 컬럼 너비 리사이즈 기능 및 엑셀 스타일 UI 개선
- 컬럼 헤더 드래그로 너비 조정 기능 추가 (최소 60px)
- 헤더 더블클릭으로 기본 너비 복원 기능 추가
- 엑셀 스타일 테두리 및 색상 적용 (border-b border-r)
- 테이블 최대 높이 240px → 400px 확장
- 입력 필드 높이 및 포커스 스타일 개선
2025-12-11 14:05:34 +09:00
hjlee 4247c3bb70 Merge pull request 'lhj' (#277) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/277
2025-12-11 13:53:52 +09:00
leeheejin 99fd8336a5 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2025-12-11 13:48:57 +09:00
leeheejin c486a31787 파일업로드 로직 중간저장(다듬기하면됨) 2025-12-11 13:48:34 +09:00
SeongHyun Kim 9463d8d0b6 수주관리 수정 모달 수정 2025-12-11 13:25:13 +09:00
hyeonsu 1bb6448b1f Merge pull request 'common/feat/dashboard-map' (#276) from common/feat/dashboard-map into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/276
2025-12-11 13:15:56 +09:00
dohyeons 011f0556d2 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map 2025-12-11 13:15:19 +09:00
dohyeons f6e0e02ddf 회사 관리자 메뉴 권한 필터링 적용 2025-12-11 13:15:09 +09:00
SeongHyun Kim 84095ace3b feat(button-actions): 저장 후 노드 플로우 실행 기능 추가 및 RepeatScreenModal props 수신 개선
- dataflowConfig.flowConfig 설정 시 저장 완료 후 노드 플로우 자동 실행
- executeNodeFlow API 동적 import로 번들 최적화
- RepeatScreenModal에서 _groupedData props 수신 지원 추가
- tiptap 라이브러리 버전 업그레이드 (2.11.5 → 2.27.1)
2025-12-11 13:05:12 +09:00
kjs fc5ffb03b2 엔티티 조인컬럼 표시문제 수정 2025-12-11 12:01:00 +09:00
SeongHyun Kim 0e60f11084 Merge remote-tracking branch 'origin/main' into ksh 2025-12-11 11:47:43 +09:00
kjs 2327dbe35c Merge branch 'feature/screen-management' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-12-11 11:41:39 +09:00
kjs b9ee860e71 분할 패널 수정,삭제버튼 on,off기능 2025-12-11 11:41:38 +09:00
kjs fcdaa68ddc Merge pull request '소스필드 못찾는 버그 수정' (#275) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/275
2025-12-11 11:37:58 +09:00
kjs 91f9bb9d12 Merge branch 'main' into feature/screen-management 2025-12-11 11:37:52 +09:00
kjs 2b747a1030 소스필드 못찾는 버그 수정 2025-12-11 11:37:40 +09:00
hyeonsu 6735142db4 Merge pull request '모바일 환경 세션 타임아웃 연장 (30분 → 24시간)' (#274) from common/feat/dashboard-map into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/274
2025-12-11 11:18:47 +09:00
dohyeons c4e81dd740 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map 2025-12-11 11:18:30 +09:00
dohyeons 7725cd1e87 모바일 환경 세션 타임아웃 연장 (30분 → 24시간) 2025-12-11 11:17:52 +09:00
hyeonsu d729d299a9 Merge pull request 'common/feat/dashboard-map' (#273) from common/feat/dashboard-map into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/273
2025-12-11 10:50:02 +09:00
dohyeons d6c5b3418d Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map 2025-12-11 10:49:12 +09:00
dohyeons bccb8a6330 리스트 위젯 REST API 기능 개선 2025-12-11 10:48:48 +09:00
kjs 288e553221 Merge pull request 'feature/screen-management' (#272) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/272
2025-12-11 10:44:16 +09:00
kjs 5bbbd37553 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-12-11 10:43:09 +09:00
kjs f272f0c4c7 제어관리 회사코드 저장 안되는 문제 수정 2025-12-11 10:41:28 +09:00
SeongHyun Kim 1a68ae792e fix(repeat-screen-modal): 외부 테이블 행 삭제를 즉시 DELETE API 호출 방식으로 변경
- 기존 소프트 삭제(_isDeleted 플래그) 방식에서 즉시 삭제로 변경
- DB에 저장된 기존 행: DELETE API 즉시 호출 후 UI에서 제거
- 새로 추가된 행: UI에서만 제거 (DB에 없음)
- _originalData.id 존재 여부로 DB 데이터 판단
- 삭제 후 집계 재계산 정상 작동
2025-12-11 09:30:37 +09:00
SeongHyun Kim 512e1e30d1 feat(repeat-screen-modal): 연동 저장, 자동 채번, SUM_EXT 참조 제한 기능 추가
- SyncSaveConfig: 모달 저장 시 다른 테이블에 집계 값 동기화 기능
- RowNumberingConfig: 행 추가 시 채번 규칙 적용하여 자동 번호 생성
- externalTableRefs: SUM_EXT 함수가 참조할 외부 테이블 제한 기능
- triggerRepeatScreenModalSave: 외부에서 저장 트리거 가능한 이벤트 리스너
- TableColumnConfig.hidden: 테이블 컬럼 숨김 기능 (데이터는 유지, 화면만 숨김)
- beforeFormSave: FK 자동 채우기 및 _isNew 행 포함 로직 개선
2025-12-11 09:24:47 +09:00
SeongHyun Kim ae6f022f88 feat(repeat-screen-modal): 복수 외부 테이블 집계 지원 및 집계 설정 모달 UI 추가
- 여러 외부 테이블 데이터를 합산하여 집계 계산 지원
- 집계 설정 전용 모달(AggregationSettingsModal) 추가
- AggregationConfig에 hidden 속성 추가 (연산에만 사용, 표시 제외)
- 채번 규칙 API 에러 처리 개선 (조용히 무시, 로그 최소화)
2025-12-11 09:17:57 +09:00
leeheejin d09c8e0787 파일업로드 수정 2025-12-10 18:38:16 +09:00
kjs 088596480f 수식 노드 구현 2025-12-10 18:28:27 +09:00
leeheejin fa6c00b6be 모달 잘리는거 해결 2025-12-10 17:41:41 +09:00
hjlee e84764dc2b Merge pull request 'lhj' (#271) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/271
2025-12-10 16:49:21 +09:00
leeheejin bc10f2101a Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2025-12-10 16:48:19 +09:00
leeheejin 65c1855eba 기본정보 눌렀을때 뜨는 오류해결 2025-12-10 16:47:48 +09:00
hyeonsu d0801e2ccc Merge pull request 'common/feat/dashboard-map' (#270) from common/feat/dashboard-map into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/270
2025-12-10 16:25:51 +09:00
kjs 3188bc0513 입고테이블 생성날짜 저장에러ㅏ 수정 2025-12-10 16:06:47 +09:00
kjs 08575c296e 연쇄 통합관리 2025-12-10 15:59:04 +09:00
dohyeons 48300146e6 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map 2025-12-10 15:54:09 +09:00
hjlee 5cd3bc52e3 Merge pull request 'lhj' (#269) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/269
2025-12-10 15:53:33 +09:00
leeheejin 6707e2afd2 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2025-12-10 15:53:11 +09:00
dohyeons 90db4756e8 3D 야드 자재 개수 표시 버그 수정 및 빈 Location 표시 추가 2025-12-10 15:41:43 +09:00
dohyeons e6b8212d39 외부 커넥션 모달에 스크롤 생성 2025-12-10 15:40:33 +09:00
leeheejin d1c9aeca18 리스트위젯 조금 더 개선된 버전 2025-12-10 15:29:23 +09:00
leeheejin f75c3e43ed 리스트 위젯 업그레이드 2025-12-10 15:15:06 +09:00
dohyeons d7e96327a7 변경 이력 모달의 작업 컬럼 스타일을 수정 2025-12-10 15:11:46 +09:00
hyeonsu 9998045013 Merge pull request '플로우 위젯 체크박스 선택 버그 수정 - 인덱스 기반에서 Primary Key 기반으로 변경' (#268) from common/feat/dashboard-map into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/268
2025-12-10 14:39:38 +09:00
dohyeons c7ae04859d 플로우 위젯 체크박스 선택 버그 수정 - 인덱스 기반에서 Primary Key 기반으로 변경 2025-12-10 14:28:11 +09:00
kjs c71b958a05 연쇄관계 관리 2025-12-10 13:53:44 +09:00
leeheejin c64c94c07b 최근이동한 내역들 2025-12-10 13:48:57 +09:00
hjlee 28ca4f2088 Merge pull request '중간저장' (#267) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/267
2025-12-10 10:30:41 +09:00
leeheejin 3608d9f9c3 중간저장 2025-12-10 10:27:54 +09:00
hjlee 990f667481 Merge pull request '테이블 툴바 설정 제대로 됩니당' (#266) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/266
2025-12-10 09:17:32 +09:00
leeheejin dde65a2d1e 테이블 툴바 설정 제대로 됩니당 2025-12-10 09:17:11 +09:00
hjlee d11ffb1b00 Merge pull request '툴바 선택' (#265) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/265
2025-12-09 18:26:55 +09:00
leeheejin 93ec294be3 툴바 선택 2025-12-09 18:26:36 +09:00
kjs ba817980f0 투명색 설정 가능하게 구현 2025-12-09 17:08:10 +09:00
hjlee 74da2cd97f Merge pull request '데이터 증식하는 문제 해결' (#264) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/264
2025-12-09 17:04:12 +09:00
leeheejin 36a7529da2 데이터 증식하는 문제 해결 2025-12-09 16:54:47 +09:00
hyeonsu 893ae06f19 Merge pull request 'common/feat/dashboard-map' (#263) from common/feat/dashboard-map into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/263
2025-12-09 16:43:21 +09:00
dohyeons 8b200ba9f3 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map 2025-12-09 16:42:37 +09:00
dohyeons 94b371ca0f 공차등록/운행알림 기능 개선 - vehicles 테이블 출발지/도착지 저장 및 운행종료 시 초기화 2025-12-09 16:38:47 +09:00
SeongHyun Kim 5e97a3a5e9 fix: 화면 복사 코드 생성 로직 개선 및 UniversalFormModal beforeFormSave 이벤트 연동
- screenManagementService: PostgreSQL regexp_replace로 정확한 최대 번호 조회
- CopyScreenModal: linkedScreens 의존성 추가로 모달 코드 생성 보장
- UniversalFormModal: beforeFormSave 이벤트 리스너로 ButtonPrimary 연동
- 설정된 필드만 병합하여 의도치 않은 덮어쓰기 방지
2025-12-09 16:11:04 +09:00
hjlee 0f8817835e Merge pull request '테이블리스트로 세금계산서 만들기' (#262) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/262
2025-12-09 15:13:16 +09:00
leeheejin a8cbc289f6 테이블리스트로 세금계산서 만들기 2025-12-09 15:12:59 +09:00
SeongHyun Kim d550959cb7 feat(modal-repeater-table): 동적 데이터 소스 전환 기능 및 UniversalFormModal 저장 버튼 옵션 추가
- ModalRepeaterTable: 컬럼 헤더 클릭으로 데이터 소스 동적 전환
- 단순 조인, 복합 조인(다중 테이블), 전용 API 호출 지원
- DynamicDataSourceConfig, MultiTableJoinStep 타입 추가
- 설정 패널에 동적 데이터 소스 설정 모달 추가
- UniversalFormModal: showSaveButton 옵션 추가
2025-12-09 14:55:49 +09:00
kjs 1506389757 메일발송기능 데이터 모달로 전달 2025-12-09 14:17:45 +09:00
kjs ece7f21bd3 메일 본문 내용 사용자 경험 개선 2025-12-09 13:50:17 +09:00
kjs 1ee1287b8a 메일 수신자 컴포넌트 구현 2025-12-09 13:29:20 +09:00
hyeonsu a2e99b30e6 Merge pull request '작동하지 않는 버튼 비활성화' (#261) from common/feat/dashboard-map into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/261
2025-12-09 12:16:47 +09:00
dohyeons 2479e3a0c4 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map 2025-12-09 12:16:39 +09:00
dohyeons 167c3cd26b 작동하지 않는 버튼 비활성화 2025-12-09 12:14:32 +09:00
kjs bb98e9319f 외부호출 노드들 2025-12-09 12:13:30 +09:00
hyeonsu 6de40bea0c Merge pull request 'UTC DB 환경인 실 서비스에서의 9시간 지연 표시 문제 해결' (#260) from common/feat/dashboard-map into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/260
2025-12-09 12:05:33 +09:00
dohyeons 612b46236f UTC DB 환경인 실 서비스에서의 9시간 지연 표시 문제 해결 2025-12-09 12:05:12 +09:00
hyeonsu d7e03d6b83 Merge pull request 'flowExecutionService 트랜잭션 처리 개선 및 데이터 변경 추적 로직 수정' (#259) from common/feat/dashboard-map into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/259
2025-12-09 11:16:36 +09:00
dohyeons 3cf99dbad9 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map 2025-12-09 11:16:23 +09:00
dohyeons 0aaab45329 flowExecutionService 트랜잭션 처리 개선 및 데이터 변경 추적 로직 수정 2025-12-09 11:15:18 +09:00
kjs cf73ce6ebb Merge branch 'feature/screen-management' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-12-09 10:47:15 +09:00
kjs 987120f13b 참조조회 노드 제거 2025-12-09 10:47:15 +09:00
hyeonsu aa78c0c0cb Merge pull request 'common/feat/dashboard-map' (#258) from common/feat/dashboard-map into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/258
2025-12-09 10:38:05 +09:00
dohyeons 8d07458c94 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map 2025-12-09 10:36:49 +09:00
hjlee 84f47a021b Merge pull request '지역 필터링 기능 추가' (#257) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/257
2025-12-09 10:34:24 +09:00
leeheejin 469c8b2e57 지역 필터링 기능 추가 2025-12-09 10:18:07 +09:00
SeongHyun Kim 7ac6bbc2c6 Merge remote-tracking branch 'origin/main' into ksh 2025-12-09 09:24:33 +09:00
SeongHyun Kim fa59235cd2 fix(split-panel-layout2): 좌측 패널 항목 선택 상태 비교 로직 개선
- idColumn 자동 감지 로직 추가 (id > dept_code > code 순 폴백)
- isSelected 비교 시 객체 동일성 및 undefined 체크 추가
- hierarchyConfig.idColumn 미설정 시에도 정상 동작
2025-12-09 09:22:10 +09:00
SeongHyun Kim d908de7f66 fix(numbering-rule): 채번규칙 저장 시 allocateNumberingCode로 실제 순번 할당
- generateNumberingCode를 allocateNumberingCode로 변경 (순번 실제 증가)

- saveSingleRow/saveMultipleRows/saveWithMultiTable 모두 적용

- NumberingRuleCard: 파트 타입 변경 시 defaultAutoConfig 적용

- NumberingRuleDesigner: 저장 시 partsWithDefaults로 기본값 병합

- sequenceLength/numberLength 기본값 4에서 3으로 변경

- 불필요한 console.log 제거
2025-12-08 19:10:07 +09:00
kjs 531ba3ffdb Merge pull request '빌드 에러 수정' (#256) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/256
2025-12-08 18:26:56 +09:00
kjs fb4c9574d3 Merge branch 'main' into feature/screen-management 2025-12-08 18:26:49 +09:00
kjs e53515481b 빌드 에러 수정 2025-12-08 18:26:38 +09:00
SeongHyun Kim b15b6e21ea fix(UniversalFormModal): 반복 섹션 linkedFieldGroup 매핑 및 서브 테이블 저장 로직 개선
- renderFieldWithColumns()에 repeatContext 파라미터 추가

- linkedFieldGroup 선택 시 repeatContext 유무에 따라 formData/repeatSections 분기 저장

- multiTableSave: UPSERT 대신 SELECT-UPDATE/INSERT 명시적 분기로 변경

- ON CONFLICT 조건 불일치 에러 방지

- 서브 테이블 저장 상세 로그 추가
2025-12-08 18:23:28 +09:00
hjlee 37fb9a13f8 Merge pull request 'lhj' (#255) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/255
2025-12-08 18:21:51 +09:00
leeheejin a20712d48e Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2025-12-08 18:17:27 +09:00
leeheejin 5b456765ad 대시보드 통계카드 위젯에 소수점 자릿수 표시 할 수 있는 기능 추가 2025-12-08 18:16:59 +09:00
kjs 33e7767f75 Merge pull request 'feature/screen-management' (#254) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/254
2025-12-08 17:59:31 +09:00
kjs 2b055757e2 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-12-08 17:59:09 +09:00
kjs 92a7e0eb3a 렉구조 중복등록 방지 2025-12-08 17:56:56 +09:00
SeongHyun Kim a278ceca3f feat(universal-form-modal): 범용 다중 테이블 저장 기능 추가 2025-12-08 17:54:11 +09:00
kjs 5609e6353f 창고 렉 구조 등록 컴포넌트 중복 방지기능 추가 2025-12-08 17:13:14 +09:00
dohyeons f0f6c42b3c flow-widget 인라인 편집 시 changed_by에 사용자 ID 기록되도록 수정 2025-12-08 16:49:28 +09:00
dohyeons ad5c7f643c Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map 2025-12-08 16:37:13 +09:00
hjlee 0df53f46b3 Merge pull request 'lhj' (#253) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/253
2025-12-08 16:19:19 +09:00
leeheejin ed1626d391 세금계산서 업그레이드 2025-12-08 16:18:44 +09:00
dohyeons 11a99a5c2e flow-widgdt 인라인 편집 및 검색 하이라이트 기능 추가 2025-12-08 16:06:43 +09:00
leeheejin 0ffec7f443 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2025-12-08 16:02:19 +09:00
leeheejin ab1308efe8 세금계산서 발행 완료 2025-12-08 16:01:59 +09:00
kjs ec65ad6b9e 데이터전달 모달열기 액션에 컬럼 매핑기능 추가 2025-12-08 15:50:58 +09:00
SeongHyun Kim 3dc67dd60a Merge remote-tracking branch 'origin/main' into ksh 2025-12-08 15:35:38 +09:00
SeongHyun Kim 61c1f10495 feat(ModalRepeaterTable): 항목 검색 모달 컬럼 라벨 설정 기능 추가
- sourceColumnLabels 타입 정의 (Record<string, string>)

- ConfigPanel에 소스 컬럼별 표시 라벨 입력 UI 추가

- columnLabels 생성 시 sourceColumnLabels 우선 적용

- 컬럼 삭제 시 해당 라벨도 함께 삭제

- 빈 상태 안내 메시지 추가
2025-12-08 15:34:19 +09:00
SeongHyun Kim 892278853c feat(UniversalFormModal): 전용 API 저장 기능 및 사원+부서 통합 저장 API 구현
- CustomApiSaveConfig 타입 정의 (apiType, mainDeptFields, subDeptFields)

- saveWithCustomApi() 함수 추가로 테이블 직접 저장 대신 전용 API 호출

- adminController에 saveUserWithDept(), getUserWithDept() API 추가

- user_info + user_dept 트랜잭션 저장, 메인 부서 변경 시 자동 겸직 전환

- ConfigPanel에 전용 API 저장 설정 UI 추가

- SplitPanelLayout2: getColumnValue()로 조인 테이블 컬럼 값 추출 개선

- 검색 컬럼 선택 시 표시 컬럼 기반으로 변경
2025-12-08 15:31:40 +09:00
SeongHyun Kim a5055cae15 feat(SplitPanelLayout2): 추가 조인 테이블 기능 구현
- JoinTableConfig 타입 정의 (joinTable, joinType, mainColumn, joinColumn, selectColumns)

- RightPanelConfig.joinTables 배열 추가로 다중 조인 지원

- loadJoinTableData(), mergeJoinData() 함수로 클라이언트 사이드 조인 처리

- JoinTableItem 컴포넌트로 조인 테이블 설정 UI 제공

- 표시 컬럼에 sourceTable 추가로 테이블별 컬럼 구분

- 메인+조인 테이블 컬럼 통합 로드 기능
2025-12-08 15:27:28 +09:00
SeongHyun Kim de1fe9865a refactor(UniversalFormModal): 다중 컬럼 저장 기능을 필드 레벨로 이동
- 섹션 레벨 linkedFieldGroups 제거, 필드 레벨 linkedFieldGroup으로 변경

- FormFieldConfig에 linkedFieldGroup 속성 추가 (enabled, sourceTable, displayColumn, displayFormat, mappings)

- select 필드 렌더링에서 linkedFieldGroup 활성화 시 다중 컬럼 저장 처리

- API 응답 파싱 개선 (responseData.data 구조 지원)

- 저장 실패 시 상세 에러 메시지 표시

- ConfigPanel에 다중 컬럼 저장 설정 UI 및 HelpText 추가
2025-12-08 15:16:45 +09:00
kjs 274078ef2c Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-12-08 15:16:07 +09:00
kjs e05af3c6f9 렉 구조등록 컴포넌트 2025-12-08 15:15:44 +09:00
SeongHyun Kim 0c57609ee9 feat(UniversalFormModal): 연동 필드 그룹 기능 추가
- LinkedFieldGroup, LinkedFieldMapping 타입 정의

- 소스 테이블 데이터 캐싱 및 드롭다운 렌더링

- 선택 시 여러 컬럼에 자동 값 매핑 처리

- 설정 패널에 연동 필드 그룹 관리 UI 추가

- 일반 섹션/반복 섹션 모두 지원
2025-12-08 15:13:33 +09:00
leeheejin f04a3e3505 세금계산서 하기 전에 저장 2025-12-08 14:34:18 +09:00
kjs 09d2d7573d 화면 같이 줄어들게 수정 2025-12-08 11:44:07 +09:00
dohyeons 2cc0a7b309 배치 스케줄러 타임존을 Asia/Seoul로 설정 2025-12-08 10:34:37 +09:00
hjlee 7f296afc17 Merge pull request 'lhj' (#252) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/252
2025-12-08 10:24:23 +09:00
leeheejin 8ec5c987de restapi 도 경로보기 가능, 출발지목적지 동시에 같은거 못하게, 자물쇠걸면 컬럼 수정 못함 tablelistcomponent 2025-12-08 10:23:54 +09:00
leeheejin 7a596cad3d Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2025-12-08 09:24:33 +09:00
hyeonsu c98257a794 Merge pull request '지도 위젯 REST API Request Body 전달 오류 수정' (#251) from common/feat/dashboard-map into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/251
2025-12-05 18:33:51 +09:00
dohyeons 4c4e7965d7 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map 2025-12-05 18:33:38 +09:00
leeheejin c39794d1a7 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2025-12-05 18:30:44 +09:00
dohyeons 46ef858c1d 지도 위젯 REST API Request Body 전달 오류 수정 2025-12-05 18:29:32 +09:00
kjs cbe5cb4607 토큰 자동 갱신 기능 추가 및 에러 처리 개선 2025-12-05 17:46:22 +09:00
leeheejin 65227c5e03 자물쇠 누르면 컬럼 값 변경 안됩니다. 2025-12-05 17:42:35 +09:00
kjs 47552bc35c 집계함수 제어 수정 2025-12-05 17:28:44 +09:00
leeheejin 417d77729d 일단 월요일에 상의해야해서 여기에다만 커밋 2025-12-05 16:44:58 +09:00
kjs e713f55442 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-12-05 15:22:28 +09:00
kjs 96321f502f 제어 집계함수 노드 추가 2025-12-05 15:18:55 +09:00
SeongHyun Kim 7a185ca1ed Merge ksh branch up to commit 5d3b3ea7 (날짜 필드 ISO 형식 변환 수정) 2025-12-05 15:18:04 +09:00
kjs 1c329b5e0c 화면 분할 패널 자동으로 데이터 넘기는 기능 설정 가능하게 변경 2025-12-05 14:08:07 +09:00
hyeonsu a866647506 Merge pull request 'common/feat/dashboard-map' (#250) from common/feat/dashboard-map into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/250
2025-12-05 14:02:19 +09:00
dohyeons 1a77a5b28a Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map 2025-12-05 14:01:17 +09:00
dohyeons 8e3452a04f 배포 DB 연결 정보 변경 2025-12-05 14:00:49 +09:00
hjlee 6da1590430 Merge pull request '3d 야드 수정' (#249) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/249
2025-12-05 13:46:02 +09:00
leeheejin a1daa63dcc 3d 야드 수정 2025-12-05 13:45:44 +09:00
hjlee 8781e9c6c3 Merge pull request 'lhj' (#248) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/248
2025-12-05 12:47:19 +09:00
leeheejin d2bd623d9a 공차/운행 ~ 운행알림까지 걸린 거리, 시간 기록하기 2025-12-05 11:59:11 +09:00
kjs 662956edd4 엔티티 카테고리 타입 컬럼 배지 표시기능 2025-12-05 11:04:42 +09:00
leeheejin ccf8bd3284 버튼활성화비활성화 2025-12-05 11:03:15 +09:00
kjs 9e956999c5 모달 크기 고정 2025-12-05 10:46:10 +09:00
dohyeons 7c06b98f86 배치 수정 페이지 버그 수정 및 멀티테넌시 보안 강화 2025-12-05 10:36:52 +09:00
dohyeons b6a7b4a93b 배치 수정 페이지 저장 및 API 미리보기 버그 수정 2025-12-05 10:16:23 +09:00
SeongHyun Kim 5d3b3ea76e fix(modal-repeater-table): 날짜 필드 ISO 형식 변환으로 표시 오류 수정
- RepeaterTable에서 DB 조회된 ISO 형식 날짜를 yyyy-mm-dd로 변환
- formatDateValue 함수 추가: ISO 문자열, Date 객체, 기존 형식 모두 처리
- 수주일(order_date), 납기일(item_due_date) 등 날짜 필드 정상 표시
2025-12-05 10:13:59 +09:00
dohyeons 58ca340699 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map 2025-12-04 19:48:34 +09:00
dohyeons 16d30632a0 배치 수정 페이지 레이아웃 및 에러 개선 2025-12-04 19:48:10 +09:00
SeongHyun Kim 5c12b9fa83 Merge origin/main into ksh - resolve split-panel-layout2 conflicts 2025-12-04 19:19:58 +09:00
SeongHyun Kim c1400081c6 fix(modal-repeater-table): 품목 추가 시 UI 즉시 반영되지 않는 버그 수정
- value 상수를 localValue useState로 변경하여 내부 상태 관리
- useEffect로 외부 값(formData, propValue) 변경 시 동기화
- handleChange에서 setLocalValue 호출하여 즉각적인 UI 업데이트
- RepeaterTable, ItemSelectionModal 등 모든 참조를 localValue로 변경
2025-12-04 19:17:32 +09:00
SeongHyun Kim 0e4ecef336 feat(universal-form-modal): 필수 필드 검증 및 섹션 레이아웃 열 수 설정 기능 추가
- validateRequiredFields 함수 추가로 필수 필드 미입력 시 저장 차단
- 섹션별 열 수 설정 (1열/2열/3열/4열) 및 gridSpan 자동 계산
- 버튼 이벤트 버블링 방지 (type=button, preventDefault, stopPropagation)
- onChange 콜백 렌더링 사이클 분리 (setTimeout)
- 다중 행 저장 시 빈 객체 건너뛰기 로직 추가
2025-12-04 19:17:28 +09:00
SeongHyun Kim 6c751eb489 feat(universal-form-modal): 범용 폼 모달 컴포넌트 신규 개발
- 섹션 기반 폼 레이아웃 지원 (접힘/펼침, 그리드 컬럼)
- 반복 섹션 지원 (겸직 등 동일 필드 그룹 여러 개 추가)
- 채번규칙 연동 (모달 열릴 때 또는 저장 시점 자동 생성)
- 다중 행 저장 지원 (공통 필드 + 개별 필드 조합)
- Select 옵션 동적 로드 (정적/테이블/공통코드)
- 스크린 디자이너 설정 패널 구현
2025-12-04 19:13:58 +09:00
kjs a38650692c Merge pull request 'feature/screen-management' (#247) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/247
2025-12-04 18:38:23 +09:00
dohyeons cd39b2fc4d 배치 신규 생성 페이지 UI/UX 개선 2025-12-04 18:34:29 +09:00
kjs bc66f3bba1 거래처 에러수정 2025-12-04 18:26:35 +09:00
dohyeons ef3b85f343 배치 UPSERT 기능 및 고정값 매핑 버그 수정 2025-12-04 17:26:29 +09:00
kjs 93d9937343 자동완성 검색 입력 컴포넌트 다중 컬럼 표시 기능추가 2025-12-04 16:02:00 +09:00
SeongHyun Kim dfc83f6114 feat(split-panel-layout2): 테이블 모드, 수정/삭제, 복수 버튼 기능 추가
- 표시 모드 추가 (card/table)
- 카드 모드 라벨 표시 옵션 (이름 행/정보 행 가로 배치)
- 체크박스 선택 기능 (전체/개별 선택)
- 개별 수정/삭제 핸들러 구현 (openEditModal, DELETE API)
- 복수 액션 버튼 배열 지원 (add, edit, bulk-delete, custom)
- 설정 패널에 표시 라벨 입력 필드 추가
- 기본키 컬럼 설정 옵션 추가
2025-12-04 14:32:04 +09:00
kjs 2cddb42255 엔티티 표시기능 개선 2025-12-04 14:30:52 +09:00
kjs a90ddac512 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-12-04 13:55:28 +09:00
kjs 127f4dc783 숫자컬럼 천단위 구분자 설정 추가 2025-12-04 13:37:17 +09:00
SeongHyun Kim 40c43bab16 feat(numbering-rule): 채번규칙 구분자 설정 기능 추가
- SeparatorType 타입 및 SEPARATOR_OPTIONS 상수 추가
- 구분자 선택 UI 추가 (없음, -, _, ., /, 직접입력)
- 직접 입력 시 최대 2자 제한
- 새 규칙 생성 시 기본값 하이픈(-)
- Select 빈 문자열 에러 해결 (value: "" -> "none")
2025-12-04 13:28:13 +09:00
hjlee 7a2f80b646 Merge pull request '차량 아이콘 안뒤집히게' (#246) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/246
2025-12-04 10:46:57 +09:00
leeheejin 532c56f997 차량 아이콘 안뒤집히게 2025-12-04 10:46:37 +09:00
kjs 3ab32820e9 next.js 버전 15.4.8 2025-12-04 10:39:07 +09:00
hjlee 687a1d57b2 Merge pull request '지도 수정 및 경로확인 가능하게' (#245) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/245
2025-12-04 10:36:07 +09:00
leeheejin dbf6cfc995 지도 수정 및 경로확인 가능하게 2025-12-04 10:30:15 +09:00
kjs 4d9f010ac5 Merge pull request 'feature/screen-management' (#244) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/244
2025-12-03 19:11:37 +09:00
kjs 0d1be47914 Merge branch 'main' into feature/screen-management 2025-12-03 19:11:30 +09:00
kjs c39409823c Merge branch 'feature/screen-management' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-12-03 19:11:18 +09:00
kjs 714919ad64 모바일환경 2025-12-03 19:11:17 +09:00
kjs 0320d30f2d Merge pull request '헤더생성' (#243) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/243
2025-12-03 19:05:33 +09:00
kjs 0cb8d2cbe1 Merge branch 'main' into feature/screen-management 2025-12-03 19:05:25 +09:00
kjs 4569defecf 헤더생성 2025-12-03 19:05:10 +09:00
kjs 3ebc5ea557 Merge pull request '타입에러 수정' (#242) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/242
2025-12-03 18:56:26 +09:00
kjs e9738ce67f 타입에러 수정 2025-12-03 18:56:14 +09:00
SeongHyun Kim 669717f656 feat(split-panel-layout2): 복수 검색 컬럼 지원 기능 추가
- SearchColumnConfig 타입 추가 (types.ts)
- 좌측/우측 패널 모두 여러 검색 컬럼 설정 가능
- ConfigPanel에 검색 컬럼 추가/삭제 UI 구현
- 검색 시 OR 조건으로 여러 컬럼 동시 검색
- 기존 searchColumn 단일 설정과 하위 호환성 유지
2025-12-03 18:53:03 +09:00
SeongHyun Kim 52ad67d44a feat: SplitPanelLayout2 마스터-디테일 컴포넌트 구현
좌측 패널(마스터)-우측 패널(디테일) 분할 레이아웃 컴포넌트 추가
EditModal에 isCreateMode 플래그 추가하여 INSERT/UPDATE 분기 처리
dataFilter 기반 정확한 조인 필터링 구현
좌측 패널 선택 데이터를 모달로 자동 전달하는 dataTransferFields 설정 지원
ConfigPanel에서 테이블, 컬럼, 조인 설정 가능
2025-12-03 18:53:03 +09:00
SeongHyun Kim ca3d6bf8fb fix(split-panel-layout): 좌측 패널 표시 컬럼 설정이 반영되지 않던 문제 수정
- leftPanel.columns 설정을 우선 적용하도록 로직 변경
- 조인 키(leftColumn) 대신 사용자 설정 컬럼이 표시되도록 수정
- 컬럼 라벨 변환 로직 개선
2025-12-03 18:53:03 +09:00
kjs 8b3017224f Merge pull request 'feature/screen-management' (#241) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/241
2025-12-03 18:48:48 +09:00
kjs e8be871d69 Merge branch 'main' into feature/screen-management 2025-12-03 18:48:41 +09:00
SeongHyun Kim de8b643277 Merge remote-tracking branch 'origin/main' into ksh 2025-12-03 18:48:37 +09:00
kjs cb0bbd1ff3 카드디스플레이 검색필터 구현 2025-12-03 18:48:23 +09:00
SeongHyun Kim 294c61e0e3 feat(split-panel-layout2): 복수 검색 컬럼 지원 기능 추가
- SearchColumnConfig 타입 추가 (types.ts)
- 좌측/우측 패널 모두 여러 검색 컬럼 설정 가능
- ConfigPanel에 검색 컬럼 추가/삭제 UI 구현
- 검색 시 OR 조건으로 여러 컬럼 동시 검색
- 기존 searchColumn 단일 설정과 하위 호환성 유지
2025-12-03 18:43:01 +09:00
kjs 676ec16879 화면 분할패널 오류 수정 2025-12-03 18:28:43 +09:00
hyeonsu cb9c90fcdb Merge pull request 'common/feat/dashboard-map' (#240) from common/feat/dashboard-map into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/240
2025-12-03 18:09:22 +09:00
dohyeons 6a0ff5582f Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map 2025-12-03 17:46:01 +09:00
SeongHyun Kim 700623aa78 feat: SplitPanelLayout2 마스터-디테일 컴포넌트 구현
좌측 패널(마스터)-우측 패널(디테일) 분할 레이아웃 컴포넌트 추가
EditModal에 isCreateMode 플래그 추가하여 INSERT/UPDATE 분기 처리
dataFilter 기반 정확한 조인 필터링 구현
좌측 패널 선택 데이터를 모달로 자동 전달하는 dataTransferFields 설정 지원
ConfigPanel에서 테이블, 컬럼, 조인 설정 가능
2025-12-03 17:45:22 +09:00
kjs 2a72f89c8a Merge pull request 'feature/screen-management' (#239) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/239
2025-12-03 17:36:52 +09:00
kjs 4e29f92268 테이블 타입관리 ui개선 2025-12-03 16:39:47 +09:00
kjs eb5ea411c9 화면 일괄삭제기능 2025-12-03 16:02:09 +09:00
SeongHyun Kim 760f9b2d67 fix(split-panel-layout): 좌측 패널 표시 컬럼 설정이 반영되지 않던 문제 수정
- leftPanel.columns 설정을 우선 적용하도록 로직 변경
- 조인 키(leftColumn) 대신 사용자 설정 컬럼이 표시되도록 수정
- 컬럼 라벨 변환 로직 개선
2025-12-03 15:17:43 +09:00
kjs 8317af92cd 입력 타입 변경시 바로 적용 가능하게 수정 2025-12-03 10:24:07 +09:00
kjs 37705e4a24 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-12-03 10:11:57 +09:00
kjs e83fbed71c 셀렉트 박스 카테고리 다른값 들어가는 오류 수정 2025-12-03 10:09:31 +09:00
kjs e33664015a 상단 헤더 제거 2025-12-03 10:03:24 +09:00
kjs 6982635acd Merge pull request 'feature/screen-management' (#238) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/238
2025-12-02 18:08:48 +09:00
kjs 7713d4073c Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-12-02 18:07:24 +09:00
kjs 3b875f20b1 화면간 데이터 전달기능 구현 2025-12-02 18:03:52 +09:00
SeongHyun Kim 3a3ecde358 Merge branch 'ksh' 2025-12-02 17:56:58 +09:00
SeongHyun Kim ae7b21147b feat(repeat-screen-modal): 집계 저장 및 채번 규칙 값 저장 기능 추가
- RepeatScreenModal 집계 결과를 연관 테이블에 저장하는 기능 추가
- ButtonPrimary 저장 시 채번 규칙 값(shipment_plan_no) 함께 저장
- _repeatScreenModal_* 데이터 감지 시 메인 테이블 중복 저장 방지
- 기존 행 수정 모드(_isEditing) 지원
- AggregationSaveConfig 타입 및 ConfigPanel UI 추가
2025-12-02 17:44:24 +09:00
dohyeons faacd5402c 외부 연결 목록에 회사명 표시 기능 추가 2025-12-02 17:36:28 +09:00
hjlee 70c6da0527 Merge pull request 'lhj' (#237) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/237
2025-12-02 15:34:41 +09:00
leeheejin 4c4906f6b3 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj 2025-12-02 15:33:55 +09:00
leeheejin a4f0681f76 지도 작동되게 했음 2025-12-02 15:33:45 +09:00
SeongHyun Kim 10d81cb9bc feat(repeat-screen-modal): 테이블 행 편집 모드 제어 기능 구현
- DB 로드 데이터에 _isEditing: false 명시적 설정
- handleEditExternalRow: 수정 모드 전환 함수 추가
- handleCancelEditExternalRow: 수정 취소 및 원본 복원 함수 추가
- renderTableCell: isRowEditable 파라미터 추가로 행 수준 편집 제어
- UPDATE API 요청 형식 { originalData, updatedData }로 수정
- 테이블 작업 컬럼에 수정/수정취소/삭제/복원 버튼 그룹화
2025-12-02 15:23:25 +09:00
SeongHyun Kim b286bc3c63 feat(repeat-screen-modal): 테이블 삭제 기능 DB 연동 (소프트 삭제)
- 삭제 버튼 클릭 시 _isDeleted 플래그 설정 (소프트 삭제)
- 삭제된 행 시각적 표시 (취소선, 투명도)
- 삭제 취소(복원) 기능 추가
- 저장 버튼 클릭 시 DELETE API 호출하여 DB 반영
- 삭제된 행 집계 계산에서 제외
- axios DELETE 요청 시 body 전달 방식 수정
2025-12-02 14:50:00 +09:00
SeongHyun Kim 8e257f36b2 fix: ScreenModal selectedData 로직 복원 (RepeatScreenModal 지원) 2025-12-02 14:30:29 +09:00
hjlee 7417f0e398 Merge pull request 'lhj' (#236) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/236
2025-12-02 14:27:50 +09:00
leeheejin a75b615c3a Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj 2025-12-02 14:26:03 +09:00
leeheejin 9078873240 워크플로우 restapi도 연결가능하고여러개 가능하게 구현시켜놓음 2025-12-02 14:24:43 +09:00
SeongHyun Kim bc34cded95 merge: origin/main을 ksh로 머지 (UnifiedPropertiesPanel 충돌 해결) 2025-12-02 14:10:33 +09:00
SeongHyun Kim 4787a8b177 feat(repeat-screen-modal): 테이블 영역 독립 저장 기능 구현
- TableCrudConfig에 allowSave, saveButtonLabel 속성 추가
- CRUD 설정 패널에 저장 스위치 추가
- saveTableAreaData 함수: editable 컬럼 + 조인키만 필터링하여 저장
- 날짜 필드 ISO 8601 -> YYYY-MM-DD 형식 변환
- 백엔드: company_code 자동 주입 로직 추가
- tableManagementService에 hasColumn 메서드 추가
2025-12-02 14:02:47 +09:00
hjlee a42db5f15a Merge pull request 'lhj' (#235) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/235
2025-12-02 13:21:26 +09:00
leeheejin 30e6595bf3 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj 2025-12-02 13:21:03 +09:00
leeheejin 2c447fd325 restapi도 가능하게 구현 2025-12-02 13:20:49 +09:00
dohyeons 436d604bb3 REST API 연결 생성 시 회사별 연결명 중복 허용 2025-12-02 11:12:09 +09:00
hjlee 650c5ef722 Merge pull request '공차관련수정사항들' (#234) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/234
2025-12-02 09:58:05 +09:00
leeheejin 0789eb2e20 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj 2025-12-02 09:53:18 +09:00
leeheejin 8c83db596d 공차관련수정사항들 2025-12-02 09:53:08 +09:00
dohyeons cd47f569e2 feat: 공차중계 운전자 차량/프로필 관리 기능 구현 2025-12-01 19:03:43 +09:00
SeongHyun Kim 2f78c83ef6 feat(repeat-screen-modal): 외부 테이블 조인, 필터링, CRUD 및 실시간 집계 기능 추가
- 외부 테이블 데이터 소스 설정 (TableDataSourceConfig) 추가
- 다중 테이블 조인 지원 (AdditionalJoinConfig)
- 테이블 필터링 (equals/notEquals) 지원
- 테이블 CRUD (행 추가/수정/삭제) 기능 추가
- 데이터 변경 시 집계 실시간 재계산 (recalculateAggregationsWithExternalData)
- 시각적 수식 빌더 (FormulaBuilder) 컴포넌트 추가
- 테이블 컬럼 순서 변경 기능 추가
- 백엔드: 배열 파라미터 IN 절 변환 로직 추가
2025-12-01 18:50:26 +09:00
dohyeons 9c3f1d26ad 차량관리(기초데이터) 구현 2025-12-01 18:41:02 +09:00
kjs 44c76d80b7 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-12-01 18:39:20 +09:00
kjs a12f2273b3 Merge branch 'feature/screen-management' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-12-01 18:39:02 +09:00
kjs fb16e224f0 카드 컴포넌트 중간커밋 2025-12-01 18:39:01 +09:00
SeongHyun Kim fb068284db Merge branch 'ksh' 2025-12-01 18:36:06 +09:00
SeongHyun Kim 0281d3722e revert: SelectBasicComponent.tsx 이전 상태로 복원 2025-12-01 18:35:55 +09:00
dohyeons cea2421899 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map 2025-12-01 17:21:02 +09:00
hjlee 18521339bb Merge pull request 'lhj' (#233) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/233
2025-12-01 17:05:34 +09:00
leeheejin 7242f08224 공차 등록, 연속추적 기능 2025-12-01 17:04:59 +09:00
leeheejin fbeb3ec2c9 버튼 과정이 조금 복잡하지만 위도경도 연속추적기능도 넣음 2025-12-01 16:49:02 +09:00
dohyeons 02273b2d79 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map 2025-12-01 15:52:28 +09:00
dohyeons 4b06c6f83a 대시보드 뷰어 다운로드 버튼 비활성화(주석처리) 2025-12-01 15:04:52 +09:00
630 changed files with 184936 additions and 21902 deletions

View File

@ -0,0 +1,559 @@
# 다국어 지원 컴포넌트 개발 가이드
새로운 화면 컴포넌트를 개발할 때 반드시 다국어 시스템을 고려해야 합니다.
이 가이드는 컴포넌트가 다국어 자동 생성 및 매핑 시스템과 호환되도록 하는 방법을 설명합니다.
---
## 1. 타입 정의 시 다국어 필드 추가
### 기본 원칙
텍스트가 표시되는 **모든 속성**에 `langKeyId`와 `langKey` 필드를 함께 정의해야 합니다.
### 단일 텍스트 속성
```typescript
interface MyComponentConfig {
// 기본 텍스트
title?: string;
// 다국어 키 (필수 추가)
titleLangKeyId?: number;
titleLangKey?: string;
// 라벨
label?: string;
labelLangKeyId?: number;
labelLangKey?: string;
// 플레이스홀더
placeholder?: string;
placeholderLangKeyId?: number;
placeholderLangKey?: string;
}
```
### 배열/목록 속성 (컬럼, 탭 등)
```typescript
interface ColumnConfig {
name: string;
label: string;
// 다국어 키 (필수 추가)
langKeyId?: number;
langKey?: string;
// 기타 속성
width?: number;
align?: "left" | "center" | "right";
}
interface TabConfig {
id: string;
label: string;
// 다국어 키 (필수 추가)
langKeyId?: number;
langKey?: string;
// 탭 제목도 별도로
title?: string;
titleLangKeyId?: number;
titleLangKey?: string;
}
interface MyComponentConfig {
columns?: ColumnConfig[];
tabs?: TabConfig[];
}
```
### 버튼 컴포넌트
```typescript
interface ButtonComponentConfig {
text?: string;
// 다국어 키 (필수 추가)
langKeyId?: number;
langKey?: string;
}
```
### 실제 예시: 분할 패널
```typescript
interface SplitPanelLayoutConfig {
leftPanel?: {
title?: string;
langKeyId?: number; // 좌측 패널 제목 다국어
langKey?: string;
columns?: Array<{
name: string;
label: string;
langKeyId?: number; // 각 컬럼 다국어
langKey?: string;
}>;
};
rightPanel?: {
title?: string;
langKeyId?: number; // 우측 패널 제목 다국어
langKey?: string;
columns?: Array<{
name: string;
label: string;
langKeyId?: number;
langKey?: string;
}>;
additionalTabs?: Array<{
label: string;
langKeyId?: number; // 탭 라벨 다국어
langKey?: string;
title?: string;
titleLangKeyId?: number; // 탭 제목 다국어
titleLangKey?: string;
columns?: Array<{
name: string;
label: string;
langKeyId?: number;
langKey?: string;
}>;
}>;
};
}
```
---
## 2. 라벨 추출 로직 등록
### 파일 위치
`frontend/lib/utils/multilangLabelExtractor.ts`
### `extractMultilangLabels` 함수에 추가
새 컴포넌트의 라벨을 추출하는 로직을 추가해야 합니다.
```typescript
// 새 컴포넌트 타입 체크
if (comp.componentType === "my-new-component") {
const config = comp.componentConfig as MyComponentConfig;
// 1. 제목 추출
if (config?.title) {
addLabel({
id: `${comp.id}_title`,
componentId: `${comp.id}_title`,-
label: config.title,
type: "title",
parentType: "my-new-component",
parentLabel: config.title,
langKeyId: config.titleLangKeyId,
langKey: config.titleLangKey,
});
}
// 2. 컬럼 추출
if (config?.columns && Array.isArray(config.columns)) {
config.columns.forEach((col, index) => {
const colLabel = col.label || col.name;
addLabel({
id: `${comp.id}_col_${index}`,
componentId: `${comp.id}_col_${index}`,
label: colLabel,
type: "column",
parentType: "my-new-component",
parentLabel: config.title || "새 컴포넌트",
langKeyId: col.langKeyId,
langKey: col.langKey,
});
});
}
// 3. 버튼 텍스트 추출 (버튼 컴포넌트인 경우)
if (config?.text) {
addLabel({
id: `${comp.id}_button`,
componentId: `${comp.id}_button`,
label: config.text,
type: "button",
parentType: "my-new-component",
parentLabel: config.text,
langKeyId: config.langKeyId,
langKey: config.langKey,
});
}
}
```
### 추출해야 할 라벨 타입
| 타입 | 설명 | 예시 |
| ------------- | ------------------ | ------------------------ |
| `title` | 컴포넌트/패널 제목 | 분할패널 제목, 카드 제목 |
| `label` | 입력 필드 라벨 | 텍스트 입력 라벨 |
| `button` | 버튼 텍스트 | 저장, 취소, 삭제 |
| `column` | 테이블 컬럼 헤더 | 품목명, 수량, 금액 |
| `tab` | 탭 라벨 | 기본정보, 상세정보 |
| `filter` | 검색 필터 라벨 | 검색어, 기간 |
| `placeholder` | 플레이스홀더 | "검색어를 입력하세요" |
| `action` | 액션 버튼/링크 | 수정, 삭제, 상세보기 |
---
## 3. 매핑 적용 로직 등록
### 파일 위치
`frontend/lib/utils/multilangLabelExtractor.ts`
### `applyMultilangMappings` 함수에 추가
다국어 키가 선택되면 컴포넌트에 `langKeyId`와 `langKey`를 저장하는 로직을 추가합니다.
```typescript
// 새 컴포넌트 매핑 적용
if (comp.componentType === "my-new-component") {
const config = comp.componentConfig as MyComponentConfig;
// 1. 제목 매핑
const titleMapping = mappingMap.get(`${comp.id}_title`);
if (titleMapping) {
updated.componentConfig = {
...updated.componentConfig,
titleLangKeyId: titleMapping.keyId,
titleLangKey: titleMapping.langKey,
};
}
// 2. 컬럼 매핑
if (config?.columns && Array.isArray(config.columns)) {
const updatedColumns = config.columns.map((col, index) => {
const colMapping = mappingMap.get(`${comp.id}_col_${index}`);
if (colMapping) {
return {
...col,
langKeyId: colMapping.keyId,
langKey: colMapping.langKey,
};
}
return col;
});
updated.componentConfig = {
...updated.componentConfig,
columns: updatedColumns,
};
}
// 3. 버튼 매핑 (버튼 컴포넌트인 경우)
const buttonMapping = mappingMap.get(`${comp.id}_button`);
if (buttonMapping) {
updated.componentConfig = {
...updated.componentConfig,
langKeyId: buttonMapping.keyId,
langKey: buttonMapping.langKey,
};
}
}
```
### 주의사항
- **객체 참조 유지**: 매핑 시 기존 `updated.componentConfig`를 기반으로 업데이트해야 합니다.
- **중첩 구조**: 중첩된 객체(예: `leftPanel.columns`)는 상위 객체부터 순서대로 업데이트합니다.
```typescript
// 잘못된 방법 - 이전 업데이트 덮어쓰기
updated.componentConfig = { ...config, langKeyId: mapping.keyId }; // ❌
updated.componentConfig = { ...config, columns: updatedColumns }; // langKeyId 사라짐!
// 올바른 방법 - 이전 업데이트 유지
updated.componentConfig = {
...updated.componentConfig,
langKeyId: mapping.keyId,
}; // ✅
updated.componentConfig = {
...updated.componentConfig,
columns: updatedColumns,
}; // ✅
```
---
## 4. 번역 표시 로직 구현
### 파일 위치
새 컴포넌트 파일 (예: `frontend/lib/registry/components/my-component/MyComponent.tsx`)
### Context 사용
```typescript
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
const MyComponent = ({ component }: Props) => {
const { getTranslatedText } = useScreenMultiLang();
const config = component.componentConfig;
// 제목 번역
const displayTitle = config?.titleLangKey
? getTranslatedText(config.titleLangKey, config.title || "")
: config?.title || "";
// 컬럼 헤더 번역
const translatedColumns = config?.columns?.map((col) => ({
...col,
displayLabel: col.langKey
? getTranslatedText(col.langKey, col.label)
: col.label,
}));
// 버튼 텍스트 번역
const buttonText = config?.langKey
? getTranslatedText(config.langKey, config.text || "")
: config?.text || "";
return (
<div>
<h2>{displayTitle}</h2>
<table>
<thead>
<tr>
{translatedColumns?.map((col, idx) => (
<th key={idx}>{col.displayLabel}</th>
))}
</tr>
</thead>
</table>
<button>{buttonText}</button>
</div>
);
};
```
### getTranslatedText 함수
```typescript
// 첫 번째 인자: langKey (다국어 키)
// 두 번째 인자: fallback (키가 없거나 번역이 없을 때 기본값)
const text = getTranslatedText(
"screen.company_1.Sales.OrderList.품목명",
"품목명"
);
```
### 주의사항
- `langKey`가 없으면 원본 텍스트를 표시합니다.
- `useScreenMultiLang`은 반드시 `ScreenMultiLangProvider` 내부에서 사용해야 합니다.
- 화면 페이지(`/screens/[screenId]/page.tsx`)에서 이미 Provider로 감싸져 있습니다.
---
## 5. ScreenMultiLangContext에 키 수집 로직 추가
### 파일 위치
`frontend/contexts/ScreenMultiLangContext.tsx`
### `collectLangKeys` 함수에 추가
번역을 미리 로드하기 위해 컴포넌트에서 사용하는 모든 `langKey`를 수집해야 합니다.
```typescript
const collectLangKeys = (comps: ComponentData[]): Set<string> => {
const keys = new Set<string>();
const processComponent = (comp: ComponentData) => {
const config = comp.componentConfig;
// 새 컴포넌트의 langKey 수집
if (comp.componentType === "my-new-component") {
// 제목
if (config?.titleLangKey) {
keys.add(config.titleLangKey);
}
// 컬럼
if (config?.columns && Array.isArray(config.columns)) {
config.columns.forEach((col: any) => {
if (col.langKey) {
keys.add(col.langKey);
}
});
}
// 버튼
if (config?.langKey) {
keys.add(config.langKey);
}
}
// 자식 컴포넌트 재귀 처리
if (comp.children && Array.isArray(comp.children)) {
comp.children.forEach(processComponent);
}
};
comps.forEach(processComponent);
return keys;
};
```
---
## 6. MultilangSettingsModal에 표시 로직 추가
### 파일 위치
`frontend/components/screen/modals/MultilangSettingsModal.tsx`
### `extractLabelsFromComponents` 함수에 추가
다국어 설정 모달에서 새 컴포넌트의 라벨이 표시되도록 합니다.
```typescript
// 새 컴포넌트 라벨 추출
if (comp.componentType === "my-new-component") {
const config = comp.componentConfig as MyComponentConfig;
// 제목
if (config?.title) {
addLabel({
id: `${comp.id}_title`,
componentId: `${comp.id}_title`,
label: config.title,
type: "title",
parentType: "my-new-component",
parentLabel: config.title,
langKeyId: config.titleLangKeyId,
langKey: config.titleLangKey,
});
}
// 컬럼
if (config?.columns) {
config.columns.forEach((col, index) => {
// columnLabelMap에서 라벨 가져오기 (테이블 컬럼인 경우)
const tableName = config.tableName;
const displayLabel =
tableName && columnLabelMap[tableName]?.[col.name]
? columnLabelMap[tableName][col.name]
: col.label || col.name;
addLabel({
id: `${comp.id}_col_${index}`,
componentId: `${comp.id}_col_${index}`,
label: displayLabel,
type: "column",
parentType: "my-new-component",
parentLabel: config.title || "새 컴포넌트",
langKeyId: col.langKeyId,
langKey: col.langKey,
});
});
}
}
```
---
## 7. 테이블명 추출 (테이블 사용 컴포넌트인 경우)
### 파일 위치
`frontend/lib/utils/multilangLabelExtractor.ts`
### `extractTableNames` 함수에 추가
컴포넌트가 테이블을 사용하는 경우, 테이블명을 추출해야 컬럼 라벨을 가져올 수 있습니다.
```typescript
const extractTableNames = (comps: ComponentData[]): Set<string> => {
const tableNames = new Set<string>();
const processComponent = (comp: ComponentData) => {
const config = comp.componentConfig;
// 새 컴포넌트의 테이블명 추출
if (comp.componentType === "my-new-component") {
if (config?.tableName) {
tableNames.add(config.tableName);
}
if (config?.selectedTable) {
tableNames.add(config.selectedTable);
}
}
// 자식 컴포넌트 재귀 처리
if (comp.children && Array.isArray(comp.children)) {
comp.children.forEach(processComponent);
}
};
comps.forEach(processComponent);
return tableNames;
};
```
---
## 8. 체크리스트
새 컴포넌트 개발 시 다음 항목을 확인하세요:
### 타입 정의
- [ ] 모든 텍스트 속성에 `langKeyId`, `langKey` 필드 추가
- [ ] 배열 속성(columns, tabs 등)의 각 항목에도 다국어 필드 추가
### 라벨 추출 (multilangLabelExtractor.ts)
- [ ] `extractMultilangLabels` 함수에 라벨 추출 로직 추가
- [ ] `extractTableNames` 함수에 테이블명 추출 로직 추가 (해당되는 경우)
### 매핑 적용 (multilangLabelExtractor.ts)
- [ ] `applyMultilangMappings` 함수에 매핑 적용 로직 추가
### 번역 표시 (컴포넌트 파일)
- [ ] `useScreenMultiLang` 훅 사용
- [ ] `getTranslatedText`로 텍스트 번역 적용
### 키 수집 (ScreenMultiLangContext.tsx)
- [ ] `collectLangKeys` 함수에 langKey 수집 로직 추가
### 설정 모달 (MultilangSettingsModal.tsx)
- [ ] `extractLabelsFromComponents`에 라벨 표시 로직 추가
---
## 9. 관련 파일 목록
| 파일 | 역할 |
| -------------------------------------------------------------- | ----------------------- |
| `frontend/lib/utils/multilangLabelExtractor.ts` | 라벨 추출 및 매핑 적용 |
| `frontend/contexts/ScreenMultiLangContext.tsx` | 번역 Context 및 키 수집 |
| `frontend/components/screen/modals/MultilangSettingsModal.tsx` | 다국어 설정 UI |
| `frontend/components/screen/ScreenDesigner.tsx` | 다국어 생성 버튼 처리 |
| `backend-node/src/services/multilangService.ts` | 다국어 키 생성 서비스 |
---
## 10. 주의사항
1. **componentId 형식 일관성**: 라벨 추출과 매핑 적용에서 동일한 ID 형식 사용
- 제목: `${comp.id}_title`
- 컬럼: `${comp.id}_col_${index}`
- 버튼: `${comp.id}_button`
2. **중첩 구조 주의**: 분할패널처럼 중첩된 구조는 경로를 명확히 지정
- `${comp.id}_left_title`, `${comp.id}_right_col_${index}`
3. **기존 값 보존**: 매핑 적용 시 `updated.componentConfig`를 기반으로 업데이트
4. **라벨 타입 구분**: 입력 폼의 `label`과 다른 컴포넌트의 `label`을 구분하여 처리
5. **테스트**: 다국어 생성 → 다국어 설정 → 언어 변경 순서로 테스트

View File

@ -0,0 +1,310 @@
# TableListComponent 개발 가이드
## 개요
`TableListComponent`는 ERP 시스템의 핵심 데이터 그리드 컴포넌트입니다. DevExpress DataGrid 스타일의 고급 기능들을 구현하고 있습니다.
**파일 위치**: `frontend/lib/registry/components/table-list/TableListComponent.tsx`
---
## 핵심 기능 목록
### 1. 인라인 편집 (Inline Editing)
- 셀 더블클릭 또는 F2 키로 편집 모드 진입
- 직접 타이핑으로도 편집 모드 진입 가능
- Enter로 저장, Escape로 취소
- **컬럼별 편집 가능 여부 설정** (`editable` 속성)
```typescript
// ColumnConfig에서 editable 속성 사용
interface ColumnConfig {
editable?: boolean; // false면 해당 컬럼 인라인 편집 불가
}
```
**편집 불가 컬럼 체크 필수 위치**:
1. `handleCellDoubleClick` - 더블클릭 편집
2. `onKeyDown` F2 케이스 - 키보드 편집
3. `onKeyDown` default 케이스 - 직접 타이핑 편집
4. 컨텍스트 메뉴 "셀 편집" 옵션
### 2. 배치 편집 (Batch Editing)
- 여러 셀 수정 후 일괄 저장/취소
- `pendingChanges` Map으로 변경사항 추적
- 저장 전 유효성 검증
### 3. 데이터 유효성 검증 (Validation)
```typescript
type ValidationRule = {
required?: boolean;
min?: number;
max?: number;
minLength?: number;
maxLength?: number;
pattern?: RegExp;
customMessage?: string;
validate?: (value: any, row: any) => string | null;
};
```
### 4. 컬럼 헤더 필터 (Header Filter)
- 각 컬럼 헤더에 필터 아이콘
- 고유값 목록에서 다중 선택 필터링
- `headerFilters` Map으로 필터 상태 관리
### 5. 필터 빌더 (Filter Builder)
```typescript
interface FilterCondition {
id: string;
column: string;
operator: "equals" | "notEquals" | "contains" | "notContains" |
"startsWith" | "endsWith" | "greaterThan" | "lessThan" |
"greaterOrEqual" | "lessOrEqual" | "isEmpty" | "isNotEmpty";
value: string;
}
interface FilterGroup {
id: string;
logic: "AND" | "OR";
conditions: FilterCondition[];
}
```
### 6. 검색 패널 (Search Panel)
- 전체 데이터 검색
- 검색어 하이라이팅
- `searchHighlights` Map으로 하이라이트 위치 관리
### 7. 엑셀 내보내기 (Excel Export)
- `xlsx` 라이브러리 사용
- 현재 표시 데이터 또는 전체 데이터 내보내기
```typescript
import * as XLSX from "xlsx";
// 사용 예시
const worksheet = XLSX.utils.json_to_sheet(exportData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "Sheet1");
XLSX.writeFile(workbook, `${tableName}_${timestamp}.xlsx`);
```
### 8. 클립보드 복사 (Copy to Clipboard)
- 선택된 행 또는 전체 데이터 복사
- 탭 구분자로 엑셀 붙여넣기 호환
### 9. 컨텍스트 메뉴 (Context Menu)
- 우클릭으로 메뉴 표시
- 셀 편집, 행 복사, 행 삭제 등 옵션
- 편집 불가 컬럼은 "(잠김)" 표시
### 10. 키보드 네비게이션
| 키 | 동작 |
|---|---|
| Arrow Keys | 셀 이동 |
| Tab | 다음 셀 |
| Shift+Tab | 이전 셀 |
| F2 | 편집 모드 |
| Enter | 저장 후 아래로 이동 |
| Escape | 편집 취소 |
| Ctrl+C | 복사 |
| Delete | 셀 값 삭제 |
### 11. 컬럼 리사이징
- 컬럼 헤더 경계 드래그로 너비 조절
- `columnWidths` 상태로 관리
- localStorage에 저장
### 12. 컬럼 순서 변경
- 드래그 앤 드롭으로 컬럼 순서 변경
- `columnOrder` 상태로 관리
- localStorage에 저장
### 13. 상태 영속성 (State Persistence)
```typescript
// localStorage 키 패턴
const stateKey = `tableState_${tableName}_${userId}`;
// 저장되는 상태
interface TableState {
columnWidths: Record<string, number>;
columnOrder: string[];
sortBy: string;
sortOrder: "asc" | "desc";
frozenColumns: string[];
columnVisibility: Record<string, boolean>;
}
```
### 14. 그룹화 및 그룹 소계
```typescript
interface GroupedData {
groupKey: string;
groupValues: Record<string, any>;
items: any[];
count: number;
summary?: Record<string, { sum: number; avg: number; count: number }>;
}
```
### 15. 총계 요약 (Total Summary)
- 숫자 컬럼의 합계, 평균, 개수 표시
- 테이블 하단에 요약 행 렌더링
---
## 캐싱 전략
```typescript
// 테이블 컬럼 캐시
const tableColumnCache = new Map<string, { columns: any[]; timestamp: number }>();
const TABLE_CACHE_TTL = 5 * 60 * 1000; // 5분
// API 호출 디바운싱
const debouncedApiCall = <T extends any[], R>(
key: string,
fn: (...args: T) => Promise<R>,
delay: number = 300
) => { ... };
```
---
## 필수 Import
```typescript
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { TableListConfig, ColumnConfig } from "./types";
import { tableTypeApi } from "@/lib/api/screen";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { codeCache } from "@/lib/caching/codeCache";
import * as XLSX from "xlsx";
import { toast } from "sonner";
```
---
## 주요 상태 (State)
```typescript
// 데이터 관련
const [tableData, setTableData] = useState<any[]>([]);
const [filteredData, setFilteredData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
// 편집 관련
const [editingCell, setEditingCell] = useState<{
rowIndex: number;
colIndex: number;
columnName: string;
originalValue: any;
} | null>(null);
const [editingValue, setEditingValue] = useState<string>("");
const [pendingChanges, setPendingChanges] = useState<Map<string, Map<string, any>>>(new Map());
const [validationErrors, setValidationErrors] = useState<Map<string, Map<string, string>>>(new Map());
// 필터 관련
const [headerFilters, setHeaderFilters] = useState<Map<string, Set<string>>>(new Map());
const [filterGroups, setFilterGroups] = useState<FilterGroup[]>([]);
const [globalSearchText, setGlobalSearchText] = useState("");
const [searchHighlights, setSearchHighlights] = useState<Map<string, number[]>>(new Map());
// 컬럼 관련
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
const [columnOrder, setColumnOrder] = useState<string[]>([]);
const [columnVisibility, setColumnVisibility] = useState<Record<string, boolean>>({});
const [frozenColumns, setFrozenColumns] = useState<string[]>([]);
// 선택 관련
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
const [focusedCell, setFocusedCell] = useState<{ rowIndex: number; colIndex: number } | null>(null);
// 정렬 관련
const [sortBy, setSortBy] = useState<string>("");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
// 페이지네이션
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [totalCount, setTotalCount] = useState(0);
```
---
## 편집 불가 컬럼 구현 체크리스트
새로운 편집 진입점을 추가할 때 반드시 다음을 확인하세요:
- [ ] `column.editable === false` 체크 추가
- [ ] 편집 불가 시 `toast.warning()` 메시지 표시
- [ ] `return` 또는 `break`로 편집 모드 진입 방지
```typescript
// 표준 편집 불가 체크 패턴
const column = visibleColumns.find((col) => col.columnName === columnName);
if (column?.editable === false) {
toast.warning(`'${column.displayName || columnName}' 컬럼은 편집할 수 없습니다.`);
return;
}
```
---
## 시각적 표시
### 편집 불가 컬럼 표시
```tsx
// 헤더에 잠금 아이콘
{column.editable === false && (
<Lock className="ml-1 h-3 w-3 text-muted-foreground" />
)}
// 셀 배경색
className={cn(
column.editable === false && "bg-gray-50 dark:bg-gray-900/30"
)}
```
---
## 성능 최적화
1. **useMemo 사용**: `visibleColumns`, `filteredData`, `paginatedData` 등 계산 비용이 큰 값
2. **useCallback 사용**: 이벤트 핸들러 함수들
3. **디바운싱**: API 호출, 검색, 필터링
4. **캐싱**: 테이블 컬럼 정보, 코드 데이터
---
## 주의사항
1. **visibleColumns 정의 순서**: `columnOrder`, `columnVisibility` 상태 이후에 정의해야 함
2. **editInputRef 타입 체크**: `select()` 호출 전 `instanceof HTMLInputElement` 확인
3. **localStorage 키**: `tableName`과 `userId`를 조합하여 고유하게 생성
4. **멀티테넌시**: 모든 API 호출에 `company_code` 필터링 적용 (백엔드에서 자동 처리)
---
## 관련 파일
- `frontend/lib/registry/components/table-list/types.ts` - 타입 정의
- `frontend/lib/registry/components/table-list/TableListConfigPanel.tsx` - 설정 패널
- `frontend/components/common/TableOptionsModal.tsx` - 옵션 모달
- `frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx` - 스티키 헤더 테이블

View File

@ -0,0 +1,592 @@
# 테이블 타입 관리 SQL 작성 가이드
테이블 타입 관리에서 테이블 생성 시 적용되는 컬럼, 타입, 메타데이터 등록 로직을 기반으로 한 SQL 작성 가이드입니다.
## 핵심 원칙
1. **모든 비즈니스 컬럼은 `VARCHAR(500)`로 통일**: 날짜 타입 외 모든 컬럼은 `VARCHAR(500)`
2. **날짜/시간 컬럼만 `TIMESTAMP` 사용**: `created_date`, `updated_date` 등
3. **기본 컬럼 5개 자동 포함**: 모든 테이블에 id, created_date, updated_date, writer, company_code 필수
4. **3개 메타데이터 테이블 등록 필수**: `table_labels`, `column_labels`, `table_type_columns`
---
## 1. 테이블 생성 DDL 템플릿
### 기본 구조
```sql
CREATE TABLE "테이블명" (
-- 시스템 기본 컬럼 (자동 포함)
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
"created_date" timestamp DEFAULT now(),
"updated_date" timestamp DEFAULT now(),
"writer" varchar(500) DEFAULT NULL,
"company_code" varchar(500),
-- 사용자 정의 컬럼 (모두 VARCHAR(500))
"컬럼1" varchar(500),
"컬럼2" varchar(500),
"컬럼3" varchar(500)
);
```
### 예시: 고객 테이블 생성
```sql
CREATE TABLE "customer_info" (
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
"created_date" timestamp DEFAULT now(),
"updated_date" timestamp DEFAULT now(),
"writer" varchar(500) DEFAULT NULL,
"company_code" varchar(500),
"customer_name" varchar(500),
"customer_code" varchar(500),
"phone" varchar(500),
"email" varchar(500),
"address" varchar(500),
"status" varchar(500),
"registration_date" varchar(500)
);
```
---
## 2. 메타데이터 테이블 등록
테이블 생성 시 반드시 아래 3개 테이블에 메타데이터를 등록해야 합니다.
### 2.1 table_labels (테이블 메타데이터)
```sql
INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
VALUES ('테이블명', '테이블 라벨', '테이블 설명', now(), now())
ON CONFLICT (table_name)
DO UPDATE SET
table_label = EXCLUDED.table_label,
description = EXCLUDED.description,
updated_date = now();
```
### 2.2 table_type_columns (컬럼 타입 정보)
**필수 컬럼**: `table_name`, `column_name`, `company_code`, `input_type`, `display_order`
```sql
-- 기본 컬럼 등록 (display_order: -5 ~ -1)
INSERT INTO table_type_columns (
table_name, column_name, company_code, input_type, detail_settings,
is_nullable, display_order, created_date, updated_date
) VALUES
('테이블명', 'id', '*', 'text', '{}', 'Y', -5, now(), now()),
('테이블명', 'created_date', '*', 'date', '{}', 'Y', -4, now(), now()),
('테이블명', 'updated_date', '*', 'date', '{}', 'Y', -3, now(), now()),
('테이블명', 'writer', '*', 'text', '{}', 'Y', -2, now(), now()),
('테이블명', 'company_code', '*', 'text', '{}', 'Y', -1, now(), now())
ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET
input_type = EXCLUDED.input_type,
display_order = EXCLUDED.display_order,
updated_date = now();
-- 사용자 정의 컬럼 등록 (display_order: 0부터 시작)
INSERT INTO table_type_columns (
table_name, column_name, company_code, input_type, detail_settings,
is_nullable, display_order, created_date, updated_date
) VALUES
('테이블명', '컬럼1', '*', 'text', '{}', 'Y', 0, now(), now()),
('테이블명', '컬럼2', '*', 'number', '{}', 'Y', 1, now(), now()),
('테이블명', '컬럼3', '*', 'code', '{"codeCategory":"카테고리코드"}', 'Y', 2, now(), now())
ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET
input_type = EXCLUDED.input_type,
detail_settings = EXCLUDED.detail_settings,
display_order = EXCLUDED.display_order,
updated_date = now();
```
### 2.3 column_labels (레거시 호환용 - 필수)
```sql
-- 기본 컬럼 등록
INSERT INTO column_labels (
table_name, column_name, column_label, input_type, detail_settings,
description, display_order, is_visible, created_date, updated_date
) VALUES
('테이블명', 'id', 'ID', 'text', '{}', '기본키 (자동생성)', -5, true, now(), now()),
('테이블명', 'created_date', '생성일시', 'date', '{}', '레코드 생성일시', -4, true, now(), now()),
('테이블명', 'updated_date', '수정일시', 'date', '{}', '레코드 수정일시', -3, true, now(), now()),
('테이블명', 'writer', '작성자', 'text', '{}', '레코드 작성자', -2, true, now(), now()),
('테이블명', 'company_code', '회사코드', 'text', '{}', '회사 구분 코드', -1, true, now(), now())
ON CONFLICT (table_name, column_name)
DO UPDATE SET
column_label = EXCLUDED.column_label,
input_type = EXCLUDED.input_type,
detail_settings = EXCLUDED.detail_settings,
description = EXCLUDED.description,
display_order = EXCLUDED.display_order,
is_visible = EXCLUDED.is_visible,
updated_date = now();
-- 사용자 정의 컬럼 등록
INSERT INTO column_labels (
table_name, column_name, column_label, input_type, detail_settings,
description, display_order, is_visible, created_date, updated_date
) VALUES
('테이블명', '컬럼1', '컬럼1 라벨', 'text', '{}', '컬럼1 설명', 0, true, now(), now()),
('테이블명', '컬럼2', '컬럼2 라벨', 'number', '{}', '컬럼2 설명', 1, true, now(), now())
ON CONFLICT (table_name, column_name)
DO UPDATE SET
column_label = EXCLUDED.column_label,
input_type = EXCLUDED.input_type,
detail_settings = EXCLUDED.detail_settings,
description = EXCLUDED.description,
display_order = EXCLUDED.display_order,
is_visible = EXCLUDED.is_visible,
updated_date = now();
```
---
## 3. Input Type 정의
### 지원되는 Input Type 목록
| input_type | 설명 | DB 저장 타입 | UI 컴포넌트 |
| ---------- | ------------- | ------------ | -------------------- |
| `text` | 텍스트 입력 | VARCHAR(500) | Input |
| `number` | 숫자 입력 | VARCHAR(500) | Input (type=number) |
| `date` | 날짜/시간 | VARCHAR(500) | DatePicker |
| `code` | 공통코드 선택 | VARCHAR(500) | Select (코드 목록) |
| `entity` | 엔티티 참조 | VARCHAR(500) | Select (테이블 참조) |
| `select` | 선택 목록 | VARCHAR(500) | Select |
| `checkbox` | 체크박스 | VARCHAR(500) | Checkbox |
| `radio` | 라디오 버튼 | VARCHAR(500) | RadioGroup |
| `textarea` | 긴 텍스트 | VARCHAR(500) | Textarea |
| `file` | 파일 업로드 | VARCHAR(500) | FileUpload |
### WebType → InputType 변환 규칙
```
text, textarea, email, tel, url, password → text
number, decimal → number
date, datetime, time → date
select, dropdown → select
checkbox, boolean → checkbox
radio → radio
code → code
entity → entity
file → text
button → text
```
---
## 4. Detail Settings 설정
### 4.1 Code 타입 (공통코드 참조)
```json
{
"codeCategory": "코드_카테고리_ID"
}
```
```sql
INSERT INTO table_type_columns (..., input_type, detail_settings, ...)
VALUES (..., 'code', '{"codeCategory":"STATUS_CODE"}', ...);
```
### 4.2 Entity 타입 (테이블 참조)
```json
{
"referenceTable": "참조_테이블명",
"referenceColumn": "참조_컬럼명(보통 id)",
"displayColumn": "표시할_컬럼명"
}
```
```sql
INSERT INTO table_type_columns (..., input_type, detail_settings, ...)
VALUES (..., 'entity', '{"referenceTable":"user_info","referenceColumn":"id","displayColumn":"user_name"}', ...);
```
### 4.3 Select 타입 (정적 옵션)
```json
{
"options": [
{ "label": "옵션1", "value": "value1" },
{ "label": "옵션2", "value": "value2" }
]
}
```
---
## 5. 전체 예시: 주문 테이블 생성
### Step 1: DDL 실행
```sql
CREATE TABLE "order_info" (
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
"created_date" timestamp DEFAULT now(),
"updated_date" timestamp DEFAULT now(),
"writer" varchar(500) DEFAULT NULL,
"company_code" varchar(500),
"order_no" varchar(500),
"order_date" varchar(500),
"customer_id" varchar(500),
"total_amount" varchar(500),
"status" varchar(500),
"notes" varchar(500)
);
```
### Step 2: table_labels 등록
```sql
INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
VALUES ('order_info', '주문 정보', '주문 관리 테이블', now(), now())
ON CONFLICT (table_name)
DO UPDATE SET
table_label = EXCLUDED.table_label,
description = EXCLUDED.description,
updated_date = now();
```
### Step 3: table_type_columns 등록
```sql
-- 기본 컬럼
INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date)
VALUES
('order_info', 'id', '*', 'text', '{}', 'Y', -5, now(), now()),
('order_info', 'created_date', '*', 'date', '{}', 'Y', -4, now(), now()),
('order_info', 'updated_date', '*', 'date', '{}', 'Y', -3, now(), now()),
('order_info', 'writer', '*', 'text', '{}', 'Y', -2, now(), now()),
('order_info', 'company_code', '*', 'text', '{}', 'Y', -1, now(), now())
ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = EXCLUDED.input_type, display_order = EXCLUDED.display_order, updated_date = now();
-- 사용자 정의 컬럼
INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date)
VALUES
('order_info', 'order_no', '*', 'text', '{}', 'Y', 0, now(), now()),
('order_info', 'order_date', '*', 'date', '{}', 'Y', 1, now(), now()),
('order_info', 'customer_id', '*', 'entity', '{"referenceTable":"customer_info","referenceColumn":"id","displayColumn":"customer_name"}', 'Y', 2, now(), now()),
('order_info', 'total_amount', '*', 'number', '{}', 'Y', 3, now(), now()),
('order_info', 'status', '*', 'code', '{"codeCategory":"ORDER_STATUS"}', 'Y', 4, now(), now()),
('order_info', 'notes', '*', 'textarea', '{}', 'Y', 5, now(), now())
ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = EXCLUDED.input_type, detail_settings = EXCLUDED.detail_settings, display_order = EXCLUDED.display_order, updated_date = now();
```
### Step 4: column_labels 등록 (레거시 호환)
```sql
-- 기본 컬럼
INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, created_date, updated_date)
VALUES
('order_info', 'id', 'ID', 'text', '{}', '기본키', -5, true, now(), now()),
('order_info', 'created_date', '생성일시', 'date', '{}', '레코드 생성일시', -4, true, now(), now()),
('order_info', 'updated_date', '수정일시', 'date', '{}', '레코드 수정일시', -3, true, now(), now()),
('order_info', 'writer', '작성자', 'text', '{}', '레코드 작성자', -2, true, now(), now()),
('order_info', 'company_code', '회사코드', 'text', '{}', '회사 구분 코드', -1, true, now(), now())
ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, updated_date = now();
-- 사용자 정의 컬럼
INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, created_date, updated_date)
VALUES
('order_info', 'order_no', '주문번호', 'text', '{}', '주문 식별 번호', 0, true, now(), now()),
('order_info', 'order_date', '주문일자', 'date', '{}', '주문 발생 일자', 1, true, now(), now()),
('order_info', 'customer_id', '고객', 'entity', '{"referenceTable":"customer_info","referenceColumn":"id","displayColumn":"customer_name"}', '주문 고객', 2, true, now(), now()),
('order_info', 'total_amount', '총금액', 'number', '{}', '주문 총 금액', 3, true, now(), now()),
('order_info', 'status', '상태', 'code', '{"codeCategory":"ORDER_STATUS"}', '주문 상태', 4, true, now(), now()),
('order_info', 'notes', '비고', 'textarea', '{}', '추가 메모', 5, true, now(), now())
ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, detail_settings = EXCLUDED.detail_settings, description = EXCLUDED.description, display_order = EXCLUDED.display_order, updated_date = now();
```
---
## 6. 컬럼 추가 시
### DDL
```sql
ALTER TABLE "테이블명" ADD COLUMN "새컬럼명" varchar(500);
```
### 메타데이터 등록
```sql
-- table_type_columns
INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date)
VALUES ('테이블명', '새컬럼명', '*', 'text', '{}', 'Y', (SELECT COALESCE(MAX(display_order), 0) + 1 FROM table_type_columns WHERE table_name = '테이블명'), now(), now())
ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = EXCLUDED.input_type, display_order = EXCLUDED.display_order, updated_date = now();
-- column_labels
INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, created_date, updated_date)
VALUES ('테이블명', '새컬럼명', '새컬럼 라벨', 'text', '{}', '새컬럼 설명', (SELECT COALESCE(MAX(display_order), 0) + 1 FROM column_labels WHERE table_name = '테이블명'), true, now(), now())
ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, updated_date = now();
```
---
## 7. 로그 테이블 생성 (선택사항)
변경 이력 추적이 필요한 테이블에는 로그 테이블을 생성할 수 있습니다.
### 7.1 로그 테이블 DDL 템플릿
```sql
-- 로그 테이블 생성
CREATE TABLE 테이블명_log (
log_id SERIAL PRIMARY KEY,
operation_type VARCHAR(10) NOT NULL, -- INSERT/UPDATE/DELETE
original_id VARCHAR(100), -- 원본 테이블 PK 값
changed_column VARCHAR(100), -- 변경된 컬럼명
old_value TEXT, -- 변경 전 값
new_value TEXT, -- 변경 후 값
changed_by VARCHAR(50), -- 변경자 ID
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 변경 시각
ip_address VARCHAR(50), -- 변경 요청 IP
user_agent TEXT, -- User Agent
full_row_before JSONB, -- 변경 전 전체 행
full_row_after JSONB -- 변경 후 전체 행
);
-- 인덱스 생성
CREATE INDEX idx_테이블명_log_original_id ON 테이블명_log(original_id);
CREATE INDEX idx_테이블명_log_changed_at ON 테이블명_log(changed_at);
CREATE INDEX idx_테이블명_log_operation ON 테이블명_log(operation_type);
-- 코멘트 추가
COMMENT ON TABLE 테이블명_log IS '테이블명 테이블 변경 이력';
```
### 7.2 트리거 함수 DDL 템플릿
```sql
CREATE OR REPLACE FUNCTION 테이블명_log_trigger_func()
RETURNS TRIGGER AS $$
DECLARE
v_column_name TEXT;
v_old_value TEXT;
v_new_value TEXT;
v_user_id VARCHAR(50);
v_ip_address VARCHAR(50);
BEGIN
v_user_id := current_setting('app.user_id', TRUE);
v_ip_address := current_setting('app.ip_address', TRUE);
IF (TG_OP = 'INSERT') THEN
INSERT INTO 테이블명_log (operation_type, original_id, changed_by, ip_address, full_row_after)
VALUES ('INSERT', NEW.id, v_user_id, v_ip_address, row_to_json(NEW)::jsonb);
RETURN NEW;
ELSIF (TG_OP = 'UPDATE') THEN
FOR v_column_name IN
SELECT column_name
FROM information_schema.columns
WHERE table_name = '테이블명'
AND table_schema = 'public'
LOOP
EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name)
INTO v_old_value, v_new_value
USING OLD, NEW;
IF v_old_value IS DISTINCT FROM v_new_value THEN
INSERT INTO 테이블명_log (
operation_type, original_id, changed_column, old_value, new_value,
changed_by, ip_address, full_row_before, full_row_after
)
VALUES (
'UPDATE', NEW.id, v_column_name, v_old_value, v_new_value,
v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb
);
END IF;
END LOOP;
RETURN NEW;
ELSIF (TG_OP = 'DELETE') THEN
INSERT INTO 테이블명_log (operation_type, original_id, changed_by, ip_address, full_row_before)
VALUES ('DELETE', OLD.id, v_user_id, v_ip_address, row_to_json(OLD)::jsonb);
RETURN OLD;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
```
### 7.3 트리거 DDL 템플릿
```sql
CREATE TRIGGER 테이블명_audit_trigger
AFTER INSERT OR UPDATE OR DELETE ON 테이블명
FOR EACH ROW EXECUTE FUNCTION 테이블명_log_trigger_func();
```
### 7.4 로그 설정 등록
```sql
INSERT INTO table_log_config (
original_table_name, log_table_name, trigger_name,
trigger_function_name, is_active, created_by, created_at
) VALUES (
'테이블명', '테이블명_log', '테이블명_audit_trigger',
'테이블명_log_trigger_func', 'Y', '생성자ID', now()
);
```
### 7.5 table_labels에 use_log_table 플래그 설정
```sql
UPDATE table_labels
SET use_log_table = 'Y', updated_date = now()
WHERE table_name = '테이블명';
```
### 7.6 전체 예시: order_info 로그 테이블 생성
```sql
-- Step 1: 로그 테이블 생성
CREATE TABLE order_info_log (
log_id SERIAL PRIMARY KEY,
operation_type VARCHAR(10) NOT NULL,
original_id VARCHAR(100),
changed_column VARCHAR(100),
old_value TEXT,
new_value TEXT,
changed_by VARCHAR(50),
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ip_address VARCHAR(50),
user_agent TEXT,
full_row_before JSONB,
full_row_after JSONB
);
CREATE INDEX idx_order_info_log_original_id ON order_info_log(original_id);
CREATE INDEX idx_order_info_log_changed_at ON order_info_log(changed_at);
CREATE INDEX idx_order_info_log_operation ON order_info_log(operation_type);
COMMENT ON TABLE order_info_log IS 'order_info 테이블 변경 이력';
-- Step 2: 트리거 함수 생성
CREATE OR REPLACE FUNCTION order_info_log_trigger_func()
RETURNS TRIGGER AS $$
DECLARE
v_column_name TEXT;
v_old_value TEXT;
v_new_value TEXT;
v_user_id VARCHAR(50);
v_ip_address VARCHAR(50);
BEGIN
v_user_id := current_setting('app.user_id', TRUE);
v_ip_address := current_setting('app.ip_address', TRUE);
IF (TG_OP = 'INSERT') THEN
INSERT INTO order_info_log (operation_type, original_id, changed_by, ip_address, full_row_after)
VALUES ('INSERT', NEW.id, v_user_id, v_ip_address, row_to_json(NEW)::jsonb);
RETURN NEW;
ELSIF (TG_OP = 'UPDATE') THEN
FOR v_column_name IN
SELECT column_name FROM information_schema.columns
WHERE table_name = 'order_info' AND table_schema = 'public'
LOOP
EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name)
INTO v_old_value, v_new_value USING OLD, NEW;
IF v_old_value IS DISTINCT FROM v_new_value THEN
INSERT INTO order_info_log (operation_type, original_id, changed_column, old_value, new_value, changed_by, ip_address, full_row_before, full_row_after)
VALUES ('UPDATE', NEW.id, v_column_name, v_old_value, v_new_value, v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb);
END IF;
END LOOP;
RETURN NEW;
ELSIF (TG_OP = 'DELETE') THEN
INSERT INTO order_info_log (operation_type, original_id, changed_by, ip_address, full_row_before)
VALUES ('DELETE', OLD.id, v_user_id, v_ip_address, row_to_json(OLD)::jsonb);
RETURN OLD;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
-- Step 3: 트리거 생성
CREATE TRIGGER order_info_audit_trigger
AFTER INSERT OR UPDATE OR DELETE ON order_info
FOR EACH ROW EXECUTE FUNCTION order_info_log_trigger_func();
-- Step 4: 로그 설정 등록
INSERT INTO table_log_config (original_table_name, log_table_name, trigger_name, trigger_function_name, is_active, created_by, created_at)
VALUES ('order_info', 'order_info_log', 'order_info_audit_trigger', 'order_info_log_trigger_func', 'Y', 'system', now());
-- Step 5: table_labels 플래그 업데이트
UPDATE table_labels SET use_log_table = 'Y', updated_date = now() WHERE table_name = 'order_info';
```
### 7.7 로그 테이블 삭제
```sql
-- 트리거 삭제
DROP TRIGGER IF EXISTS 테이블명_audit_trigger ON 테이블명;
-- 트리거 함수 삭제
DROP FUNCTION IF EXISTS 테이블명_log_trigger_func();
-- 로그 테이블 삭제
DROP TABLE IF EXISTS 테이블명_log;
-- 로그 설정 삭제
DELETE FROM table_log_config WHERE original_table_name = '테이블명';
-- table_labels 플래그 업데이트
UPDATE table_labels SET use_log_table = 'N', updated_date = now() WHERE table_name = '테이블명';
```
---
## 8. 체크리스트
### 테이블 생성/수정 시 반드시 확인할 사항:
- [ ] DDL에 기본 5개 컬럼 포함 (id, created_date, updated_date, writer, company_code)
- [ ] 모든 비즈니스 컬럼은 `VARCHAR(500)` 타입 사용
- [ ] `table_labels`에 테이블 메타데이터 등록
- [ ] `table_type_columns`에 모든 컬럼 등록 (company_code = '\*')
- [ ] `column_labels`에 모든 컬럼 등록 (레거시 호환)
- [ ] 기본 컬럼 display_order: -5 ~ -1
- [ ] 사용자 정의 컬럼 display_order: 0부터 순차
- [ ] code/entity 타입은 detail_settings에 참조 정보 포함
- [ ] ON CONFLICT 절로 중복 시 UPDATE 처리
### 로그 테이블 생성 시 확인할 사항 (선택):
- [ ] 로그 테이블 생성 (`테이블명_log`)
- [ ] 인덱스 3개 생성 (original_id, changed_at, operation_type)
- [ ] 트리거 함수 생성 (`테이블명_log_trigger_func`)
- [ ] 트리거 생성 (`테이블명_audit_trigger`)
- [ ] `table_log_config`에 로그 설정 등록
- [ ] `table_labels.use_log_table = 'Y'` 업데이트
---
## 9. 금지 사항
1. **DB 타입 직접 지정 금지**: NUMBER, INTEGER, DATE 등 DB 타입 직접 사용 금지
2. **VARCHAR 길이 변경 금지**: 반드시 `VARCHAR(500)` 사용
3. **기본 컬럼 누락 금지**: id, created_date, updated_date, writer, company_code 필수
4. **메타데이터 미등록 금지**: 3개 테이블 모두 등록 필수
5. **web_type 사용 금지**: 레거시 컬럼이므로 `input_type` 사용
---
## 참조 파일
- `backend-node/src/services/ddlExecutionService.ts`: DDL 실행 서비스
- `backend-node/src/services/tableManagementService.ts`: 로그 테이블 생성 서비스
- `backend-node/src/types/ddl.ts`: DDL 타입 정의
- `backend-node/src/controllers/ddlController.ts`: DDL API 컨트롤러
- `backend-node/src/controllers/tableManagementController.ts`: 로그 테이블 API 컨트롤러

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

70
PLAN.MD
View File

@ -1,4 +1,72 @@
# 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원) # 프로젝트: 화면 관리 기능 개선 (복제/삭제/그룹 관리/테이블 설정)
## 개요
화면 관리 시스템의 복제, 삭제, 수정, 테이블 설정 기능을 전면 개선하여 효율적인 화면 관리를 지원합니다.
## 핵심 기능
### 1. 단일 화면 복제
- [x] 우클릭 컨텍스트 메뉴에서 "복제" 선택
- [x] 화면명, 화면 코드 자동 생성 (중복 시 `_COPY` 접미사 추가)
- [x] 연결된 모달 화면 함께 복제
- [x] 대상 그룹 선택 가능
- [x] 복제 후 목록 자동 새로고침
### 2. 그룹(폴더) 전체 복제
- [x] 대분류 폴더 복제 시 모든 하위 폴더 + 화면 재귀적 복제
- [x] 정렬 순서(display_order) 유지
- [x] 대분류(최상위 그룹) 복제 시 경고 문구 표시
- [x] 정렬 순서 입력 필드 추가
- [x] 복제 모드 선택: 전체(폴더+화면), 폴더만, 화면만
- [x] 모달 스크롤 지원 (max-h-[90vh] overflow-y-auto)
### 3. 고급 옵션: 이름 일괄 변경
- [x] 찾을 텍스트 / 대체할 텍스트 (Find & Replace)
- [x] 미리보기 기능
### 4. 삭제 기능
- [x] 단일 화면 삭제 (휴지통으로 이동)
- [x] 그룹 삭제 (화면 함께 삭제 옵션)
- [x] 삭제 시 로딩 프로그레스 바 표시
### 5. 화면 수정 기능
- [x] 우클릭 "수정" 메뉴로 화면 이름/그룹/역할/정렬 순서 변경
- [x] 그룹 추가/수정 시 상위 그룹 기반 자동 회사 코드 설정
### 6. 테이블 설정 기능 (TableSettingModal)
- [x] 화면 설정 모달에 "테이블 설정" 탭 추가
- [x] 입력 타입 변경 시 관련 참조 필드 자동 초기화
- 엔티티→텍스트: referenceTable, referenceColumn, displayColumn 초기화
- 코드→다른 타입: codeCategory, codeValue 초기화
- [x] 데이터 일관성 유지 (inputType ↔ referenceTable 연동)
- [x] 조인 배지 단일화 (FK 배지 제거, 조인 배지만 표시)
### 7. 회사 코드 지원 (최고 관리자)
- [x] 대상 회사 선택 가능
- [x] 상위 그룹 선택 시 자동 회사 코드 설정
## 관련 파일
- `frontend/components/screen/CopyScreenModal.tsx` - 복제 모달
- `frontend/components/screen/ScreenGroupTreeView.tsx` - 트리 뷰 + 컨텍스트 메뉴
- `frontend/components/screen/TableSettingModal.tsx` - 테이블 설정 모달
- `frontend/components/screen/ScreenSettingModal.tsx` - 화면 설정 모달 (테이블 설정 탭 포함)
- `frontend/lib/api/screen.ts` - 화면 API
- `frontend/lib/api/screenGroup.ts` - 그룹 API
- `frontend/lib/api/tableManagement.ts` - 테이블 관리 API
## 진행 상태
- [완료] 단일 화면 복제 + 새로고침
- [완료] 그룹 전체 복제 (재귀적)
- [완료] 고급 옵션: 이름 일괄 변경 (Find & Replace)
- [완료] 단일 화면/그룹 삭제 + 로딩 프로그레스
- [완료] 화면 수정 (이름/그룹/역할/순서)
- [완료] 테이블 설정 탭 추가
- [완료] 입력 타입 변경 시 관련 필드 초기화
- [완료] 그룹 복제 모달 스크롤 문제 수정
---
# 이전 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원)
## 개요 ## 개요
현재 GET 방식 위주로 구현된 외부 REST API 커넥션 관리 기능을 확장하여, POST, PUT, DELETE 등 다양한 HTTP 메서드와 JSON Request Body를 설정하고 테스트할 수 있도록 개선합니다. 이를 통해 토큰 발급 API나 데이터 전송 API 등 다양한 외부 시스템과의 연동을 지원합니다. 현재 GET 방식 위주로 구현된 외부 REST API 커넥션 관리 기능을 확장하여, POST, PUT, DELETE 등 다양한 HTTP 메서드와 JSON Request Body를 설정하고 테스트할 수 있도록 개선합니다. 이를 통해 토큰 발급 API나 데이터 전송 API 등 다양한 외부 시스템과의 연동을 지원합니다.

680
PLAN_RENEWAL.md Normal file
View File

@ -0,0 +1,680 @@
# Screen Designer 2.0 리뉴얼 계획: 컴포넌트 통합 및 속성 기반 고도화
## 1. 개요
현재 **68개 이상**으로 파편화된 화면 관리 컴포넌트들을 **9개의 핵심 통합 컴포넌트(Unified Components)**로 재편합니다.
각 컴포넌트는 **속성(Config)** 설정을 통해 다양한 형태(View Mode)와 기능(Behavior)을 수행하도록 설계되어, 유지보수성과 확장성을 극대화합니다.
### 현재 컴포넌트 현황 (AS-IS)
| 카테고리 | 파일 수 | 주요 파일들 |
| :------------- | :-----: | :------------------------------------------------------------------ |
| Widget 타입별 | 14개 | TextWidget, NumberWidget, SelectWidget, DateWidget, EntityWidget 등 |
| Config Panel | 28개 | TextConfigPanel, SelectConfigPanel, DateConfigPanel 등 |
| WebType Config | 11개 | TextTypeConfigPanel, SelectTypeConfigPanel 등 |
| 기타 패널 | 15개+ | PropertiesPanel, DetailSettingsPanel 등 |
---
## 2. 통합 전략: 9 Core Widgets
### A. 입력 위젯 (Input Widgets) - 5종
단순 데이터 입력 필드를 통합합니다.
| 통합 컴포넌트 (TO-BE) | 포함되는 기존 컴포넌트 (AS-IS) | 핵심 속성 (Configuration) |
| :-------------------- | :------------------------------------------------------------------ | :----------------------------------------------------------------------------------------------------------------------------------------------- |
| **1. Unified Select** | Select, Radio, Checkbox, Boolean, Code, Entity, Combobox, Toggle | **`mode`**: "dropdown" / "radio" / "check" / "tag"<br>**`source`**: "static" / "code" / "db" / "api"<br>**`dependency`**: { parentField: "..." } |
| **2. Unified Input** | Text, Number, Email, Tel, Password, Color, Search, Integer, Decimal | **`type`**: "text" / "number" / "password"<br>**`format`**: "email", "currency", "biz_no"<br>**`mask`**: "000-0000-0000" |
| **3. Unified Date** | Date, Time, DateTime, DateRange, Month, Year | **`type`**: "date" / "time" / "datetime"<br>**`range`**: true/false |
| **4. Unified Text** | Textarea, RichEditor, Markdown, HTML | **`mode`**: "simple" / "rich" / "code"<br>**`rows`**: number |
| **5. Unified Media** | File, Image, Video, Audio, Attachment | **`type`**: "file" / "image"<br>**`multiple`**: true/false<br>**`preview`**: true/false |
### B. 구조/데이터 위젯 (Structure & Data Widgets) - 4종
레이아웃 배치와 데이터 시각화를 담당합니다.
| 통합 컴포넌트 (TO-BE) | 포함되는 기존 컴포넌트 (AS-IS) | 핵심 속성 (Configuration) | 활용 예시 |
| :-------------------- | :-------------------------------------------------- | :------------------------------------------------------------------------ | :----------------------------------------------------------------------------------------------------------------------- |
| **6. Unified List** | **Table, Card List, Repeater, DataGrid, List View** | **`viewMode`**: "table" / "card" / "kanban"<br>**`editable`**: true/false | - `viewMode='table'`: 엑셀형 리스트<br>- `viewMode='card'`: **카드 디스플레이**<br>- `editable=true`: **반복 필드 그룹** |
| **7. Unified Layout** | **Row, Col, Split Panel, Grid, Spacer** | **`type`**: "grid" / "split" / "flex"<br>**`columns`**: number | - `type='split'`: **화면 분할 패널**<br>- `type='grid'`: 격자 레이아웃 |
| **8. Unified Group** | Tab, Accordion, FieldSet, Modal, Section | **`type`**: "tab" / "accordion" / "modal" | - 탭이나 아코디언으로 내용 그룹화 |
| **9. Unified Biz** | **Rack Structure**, Calendar, Gantt | **`type`**: "rack" / "calendar" / "gantt" | - `type='rack'`: **랙 구조 설정**<br>- 특수 비즈니스 로직 플러그인 탑재 |
### C. Config Panel 통합 전략 (핵심)
현재 28개의 ConfigPanel을 **1개의 DynamicConfigPanel**로 통합합니다.
| AS-IS | TO-BE | 방식 |
| :-------------------- | :--------------------- | :------------------------------- |
| TextConfigPanel.tsx | | |
| SelectConfigPanel.tsx | **DynamicConfigPanel** | DB의 `sys_input_type` 테이블에서 |
| DateConfigPanel.tsx | (단일 컴포넌트) | JSON Schema를 읽어 |
| NumberConfigPanel.tsx | | 속성 UI를 동적 생성 |
| ... 24개 더 | | |
---
## 3. 구현 시나리오 (속성 기반 변신)
### Case 1: "테이블을 카드 리스트로 변경"
- **AS-IS**: `DataTable` 컴포넌트를 삭제하고 `CardList` 컴포넌트를 새로 추가해야 함.
- **TO-BE**: `UnifiedList`의 속성창에서 **[View Mode]**를 `Table``Card`로 변경하면 즉시 반영.
### Case 2: "단일 선택을 라디오 버튼으로 변경"
- **AS-IS**: `SelectWidget`을 삭제하고 `RadioWidget` 추가.
- **TO-BE**: `UnifiedSelect` 속성창에서 **[Display Mode]**를 `Dropdown``Radio`로 변경.
### Case 3: "입력 폼에 반복 필드(Repeater) 추가"
- **TO-BE**: `UnifiedList` 컴포넌트 배치 후 `editable: true`, `viewMode: "table"` 설정.
---
## 4. 실행 로드맵 (Action Plan)
### Phase 0: 준비 단계 (1주)
통합 작업 전 필수 분석 및 설계를 진행합니다.
- [ ] 기존 컴포넌트 사용 현황 분석 (화면별 위젯 사용 빈도 조사)
- [ ] 데이터 마이그레이션 전략 설계 (`widgetType` → `UnifiedWidget.type` 매핑 정의)
- [ ] `sys_input_type` 테이블 JSON Schema 설계
- [ ] DynamicConfigPanel 프로토타입 설계
### Phase 1: 입력 위젯 통합 (2주)
가장 중복이 많고 효과가 즉각적인 입력 필드부터 통합합니다.
- [ ] **UnifiedInput 구현**: Text, Number, Email, Tel, Password 통합
- [ ] **UnifiedSelect 구현**: Select, Radio, Checkbox, Boolean 통합
- [ ] **UnifiedDate 구현**: Date, DateTime, Time 통합
- [ ] 기존 위젯과 **병행 운영** (deprecated 마킹, 삭제하지 않음)
### Phase 2: Config Panel 통합 (2주)
28개의 ConfigPanel을 단일 동적 패널로 통합합니다.
- [ ] **DynamicConfigPanel 구현**: DB 스키마 기반 속성 UI 자동 생성
- [ ] `sys_input_type` 테이블에 위젯별 JSON Schema 정의 저장
- [ ] 기존 ConfigPanel과 **병행 운영** (삭제하지 않음)
### Phase 3: 데이터/레이아웃 위젯 통합 (2주)
프로젝트의 데이터를 보여주는 핵심 뷰를 통합합니다.
- [ ] **UnifiedList 구현**: Table, Card, Repeater 통합 렌더러 개발
- [ ] **UnifiedLayout 구현**: Split Panel, Grid, Flex 통합
- [ ] **UnifiedGroup 구현**: Tab, Accordion, Modal 통합
### Phase 4: 안정화 및 마이그레이션 (2주)
신규 컴포넌트 안정화 후 점진적 전환을 진행합니다.
- [ ] 신규 화면은 Unified 컴포넌트만 사용하도록 가이드
- [ ] 기존 화면 데이터 마이그레이션 스크립트 개발
- [ ] 마이그레이션 테스트 (스테이징 환경)
- [ ] 문서화 및 개발 가이드 작성
### Phase 5: 레거시 정리 (추후 결정)
충분한 안정화 기간 후 레거시 컴포넌트 정리를 검토합니다.
- [ ] 사용 현황 재분석 (Unified 전환율 확인)
- [ ] 미전환 화면 목록 정리
- [ ] 레거시 컴포넌트 삭제 여부 결정 (별도 회의)
---
## 5. 데이터 마이그레이션 전략
### 5.1 위젯 타입 매핑 테이블
기존 `widgetType`을 신규 Unified 컴포넌트로 매핑합니다.
| 기존 widgetType | 신규 컴포넌트 | 속성 설정 |
| :-------------- | :------------ | :------------------------------ |
| `text` | UnifiedInput | `type: "text"` |
| `number` | UnifiedInput | `type: "number"` |
| `email` | UnifiedInput | `type: "text", format: "email"` |
| `tel` | UnifiedInput | `type: "text", format: "tel"` |
| `select` | UnifiedSelect | `mode: "dropdown"` |
| `radio` | UnifiedSelect | `mode: "radio"` |
| `checkbox` | UnifiedSelect | `mode: "check"` |
| `date` | UnifiedDate | `type: "date"` |
| `datetime` | UnifiedDate | `type: "datetime"` |
| `textarea` | UnifiedText | `mode: "simple"` |
| `file` | UnifiedMedia | `type: "file"` |
| `image` | UnifiedMedia | `type: "image"` |
### 5.2 마이그레이션 원칙
1. **비파괴적 전환**: 기존 데이터 구조 유지, 신규 필드 추가 방식
2. **하위 호환성**: 기존 `widgetType` 필드는 유지, `unifiedType` 필드 추가
3. **점진적 전환**: 화면 수정 시점에 자동 또는 수동 전환
---
## 6. 기대 효과
1. **컴포넌트 수 감소**: 68개 → **9개** (관리 포인트 87% 감소)
2. **Config Panel 통합**: 28개 → **1개** (DynamicConfigPanel)
3. **유연한 UI 변경**: 컴포넌트 교체 없이 속성 변경만으로 UI 모드 전환 가능
4. **Low-Code 확장성**: 새로운 유형의 입력 방식이 필요할 때 코딩 없이 DB 설정만으로 추가 가능
---
## 7. 리스크 및 대응 방안
| 리스크 | 영향도 | 대응 방안 |
| :----------------------- | :----: | :-------------------------------- |
| 기존 화면 호환성 깨짐 | 높음 | 병행 운영 + 하위 호환성 유지 |
| 마이그레이션 데이터 손실 | 높음 | 백업 필수 + 롤백 스크립트 준비 |
| 개발자 학습 곡선 | 중간 | 상세 가이드 문서 + 예제 코드 제공 |
| 성능 저하 (동적 렌더링) | 중간 | 메모이제이션 + 지연 로딩 적용 |
---
## 8. 현재 컴포넌트 매핑 분석
### 8.1 Registry 등록 컴포넌트 전수 조사 (44개)
현재 `frontend/lib/registry/components/`에 등록된 모든 컴포넌트의 통합 가능 여부를 분석했습니다.
#### UnifiedInput으로 통합 (4개)
| 현재 컴포넌트 | 매핑 속성 | 비고 |
| :------------- | :--------------- | :------------- |
| text-input | `type: "text"` | |
| number-input | `type: "number"` | |
| slider-basic | `type: "slider"` | 속성 추가 필요 |
| button-primary | `type: "button"` | 별도 검토 |
#### UnifiedSelect로 통합 (8개)
| 현재 컴포넌트 | 매핑 속성 | 비고 |
| :------------------------ | :----------------------------------- | :------------- |
| select-basic | `mode: "dropdown"` | |
| checkbox-basic | `mode: "check"` | |
| radio-basic | `mode: "radio"` | |
| toggle-switch | `mode: "toggle"` | 속성 추가 필요 |
| autocomplete-search-input | `mode: "dropdown", searchable: true` | |
| entity-search-input | `source: "entity"` | |
| mail-recipient-selector | `mode: "multi", type: "email"` | 복합 컴포넌트 |
| location-swap-selector | `mode: "swap"` | 특수 UI |
#### UnifiedDate로 통합 (1개)
| 현재 컴포넌트 | 매핑 속성 | 비고 |
| :------------ | :------------- | :--- |
| date-input | `type: "date"` | |
#### UnifiedText로 통합 (1개)
| 현재 컴포넌트 | 매핑 속성 | 비고 |
| :------------- | :--------------- | :--- |
| textarea-basic | `mode: "simple"` | |
#### UnifiedMedia로 통합 (3개)
| 현재 컴포넌트 | 매핑 속성 | 비고 |
| :------------ | :------------------------------ | :--- |
| file-upload | `type: "file"` | |
| image-widget | `type: "image"` | |
| image-display | `type: "image", readonly: true` | |
#### UnifiedList로 통합 (8개)
| 현재 컴포넌트 | 매핑 속성 | 비고 |
| :-------------------- | :------------------------------------ | :------------ |
| table-list | `viewMode: "table"` | |
| card-display | `viewMode: "card"` | |
| repeater-field-group | `editable: true` | |
| modal-repeater-table | `viewMode: "table", modal: true` | |
| simple-repeater-table | `viewMode: "table", simple: true` | |
| repeat-screen-modal | `viewMode: "card", modal: true` | |
| table-search-widget | `viewMode: "table", searchable: true` | |
| tax-invoice-list | `viewMode: "table", bizType: "tax"` | 특수 비즈니스 |
#### UnifiedLayout으로 통합 (4개)
| 현재 컴포넌트 | 매핑 속성 | 비고 |
| :------------------ | :-------------------------- | :------------- |
| split-panel-layout | `type: "split"` | |
| split-panel-layout2 | `type: "split", version: 2` | |
| divider-line | `type: "divider"` | 속성 추가 필요 |
| screen-split-panel | `type: "screen-embed"` | 화면 임베딩 |
#### UnifiedGroup으로 통합 (5개)
| 현재 컴포넌트 | 매핑 속성 | 비고 |
| :------------------- | :--------------------- | :------------ |
| accordion-basic | `type: "accordion"` | |
| tabs | `type: "tabs"` | |
| section-paper | `type: "section"` | |
| section-card | `type: "card-section"` | |
| universal-form-modal | `type: "form-modal"` | 복합 컴포넌트 |
#### UnifiedBiz로 통합 (7개)
| 현재 컴포넌트 | 매핑 속성 | 비고 |
| :-------------------- | :------------------------ | :--------------- |
| flow-widget | `type: "flow"` | 플로우 관리 |
| rack-structure | `type: "rack"` | 창고 렉 구조 |
| map | `type: "map"` | 지도 |
| numbering-rule | `type: "numbering"` | 채번 규칙 |
| category-manager | `type: "category"` | 카테고리 관리 |
| customer-item-mapping | `type: "mapping"` | 거래처-품목 매핑 |
| related-data-buttons | `type: "related-buttons"` | 연관 데이터 |
#### 별도 검토 필요 (3개)
| 현재 컴포넌트 | 문제점 | 제안 |
| :-------------------------- | :------------------- | :------------------------------ |
| conditional-container | 조건부 렌더링 로직 | 공통 속성으로 분리 |
| selected-items-detail-input | 복합 (선택+상세입력) | UnifiedList + UnifiedGroup 조합 |
| text-display | 읽기 전용 텍스트 | UnifiedInput (readonly: true) |
### 8.2 매핑 분석 결과
```
┌─────────────────────────────────────────────────────────┐
│ 전체 44개 컴포넌트 분석 결과 │
├─────────────────────────────────────────────────────────┤
│ ✅ 즉시 통합 가능 : 36개 (82%) │
│ ⚠️ 속성 추가 후 통합 : 5개 (11%) │
│ 🔄 별도 검토 필요 : 3개 (7%) │
└─────────────────────────────────────────────────────────┘
```
### 8.3 속성 확장 필요 사항
#### UnifiedInput 속성 확장
```typescript
// 기존
type: "text" | "number" | "password";
// 확장
type: "text" | "number" | "password" | "slider" | "color" | "button";
```
#### UnifiedSelect 속성 확장
```typescript
// 기존
mode: "dropdown" | "radio" | "check" | "tag";
// 확장
mode: "dropdown" | "radio" | "check" | "tag" | "toggle" | "swap";
```
#### UnifiedLayout 속성 확장
```typescript
// 기존
type: "grid" | "split" | "flex";
// 확장
type: "grid" | "split" | "flex" | "divider" | "screen-embed";
```
### 8.4 조건부 렌더링 공통화
`conditional-container`의 기능을 모든 컴포넌트에서 사용 가능한 공통 속성으로 분리합니다.
```typescript
// 모든 Unified 컴포넌트에 적용 가능한 공통 속성
interface BaseUnifiedProps {
// ... 기존 속성
/** 조건부 렌더링 설정 */
conditional?: {
enabled: boolean;
field: string; // 참조할 필드명
operator: "=" | "!=" | ">" | "<" | "in" | "notIn";
value: any; // 비교 값
hideOnFalse?: boolean; // false일 때 숨김 (기본: true)
};
}
```
---
## 9. 계층 구조(Hierarchy) 컴포넌트 전략
### 9.1 현재 계층 구조 지원 현황
DB 테이블 `cascading_hierarchy_group`에서 4가지 계층 타입을 지원합니다:
| 타입 | 설명 | 예시 |
| :----------------- | :---------------------- | :--------------- |
| **MULTI_TABLE** | 다중 테이블 계층 | 국가 > 도시 > 구 |
| **SELF_REFERENCE** | 자기 참조 (단일 테이블) | 조직도, 메뉴 |
| **BOM** | 자재명세서 구조 | 부품 > 하위부품 |
| **TREE** | 일반 트리 | 카테고리 |
### 9.2 통합 방안: UnifiedHierarchy 신설 (10번째 컴포넌트)
계층 구조는 일반 입력/표시 위젯과 성격이 다르므로 **별도 컴포넌트로 분리**합니다.
```typescript
interface UnifiedHierarchyProps {
/** 계층 유형 */
type: "tree" | "org" | "bom" | "cascading";
/** 표시 방식 */
viewMode: "tree" | "table" | "indent" | "dropdown";
/** 계층 그룹 코드 (cascading_hierarchy_group 연동) */
source: string;
/** 편집 가능 여부 */
editable?: boolean;
/** 드래그 정렬 가능 */
draggable?: boolean;
/** BOM 수량 표시 (BOM 타입 전용) */
showQty?: boolean;
/** 최대 레벨 제한 */
maxLevel?: number;
}
```
### 9.3 활용 예시
| 설정 | 결과 |
| :---------------------------------------- | :------------------------- |
| `type: "tree", viewMode: "tree"` | 카테고리 트리뷰 |
| `type: "org", viewMode: "tree"` | 조직도 |
| `type: "bom", viewMode: "indent"` | BOM 들여쓰기 테이블 |
| `type: "cascading", viewMode: "dropdown"` | 연쇄 셀렉트 (국가>도시>구) |
---
## 10. 최종 통합 컴포넌트 목록 (10개)
| # | 컴포넌트 | 역할 | 커버 범위 |
| :-: | :------------------- | :------------- | :----------------------------------- |
| 1 | **UnifiedInput** | 단일 값 입력 | text, number, slider, button 등 |
| 2 | **UnifiedSelect** | 선택 입력 | dropdown, radio, checkbox, toggle 등 |
| 3 | **UnifiedDate** | 날짜/시간 입력 | date, datetime, time, range |
| 4 | **UnifiedText** | 다중 행 텍스트 | textarea, rich editor, markdown |
| 5 | **UnifiedMedia** | 파일/미디어 | file, image, video, audio |
| 6 | **UnifiedList** | 데이터 목록 | table, card, repeater, kanban |
| 7 | **UnifiedLayout** | 레이아웃 배치 | grid, split, flex, divider |
| 8 | **UnifiedGroup** | 콘텐츠 그룹화 | tabs, accordion, section, modal |
| 9 | **UnifiedBiz** | 비즈니스 특화 | flow, rack, map, numbering 등 |
| 10 | **UnifiedHierarchy** | 계층 구조 | tree, org, bom, cascading |
---
## 11. 연쇄관계 관리 메뉴 통합 전략
### 11.1 현재 연쇄관계 관리 현황
**관리 메뉴**: `연쇄 드롭다운 통합 관리` (6개 탭)
| 탭 | DB 테이블 | 실제 데이터 | 복잡도 |
| :--------------- | :--------------------------------------- | :---------: | :----: |
| 2단계 연쇄관계 | `cascading_relation` | 2건 | 낮음 |
| 다단계 계층 | `cascading_hierarchy_group/level` | 1건 | 높음 |
| 조건부 필터 | `cascading_condition` | 0건 | 중간 |
| 자동 입력 | `cascading_auto_fill_group/mapping` | 0건 | 낮음 |
| 상호 배제 | `cascading_mutual_exclusion` | 0건 | 낮음 |
| 카테고리 값 연쇄 | `category_value_cascading_group/mapping` | 2건 | 중간 |
### 11.2 통합 방향: 속성 기반 vs 공통 정의
#### 판단 기준
| 기능 | 재사용 빈도 | 설정 복잡도 | 권장 방식 |
| :--------------- | :---------: | :---------: | :----------------------- |
| 2단계 연쇄 | 낮음 | 간단 | **속성에 inline 정의** |
| 다단계 계층 | 높음 | 복잡 | **공통 정의 유지** |
| 조건부 필터 | 낮음 | 간단 | **속성에 inline 정의** |
| 자동 입력 | 낮음 | 간단 | **속성에 inline 정의** |
| 상호 배제 | 낮음 | 간단 | **속성에 inline 정의** |
| 카테고리 값 연쇄 | 중간 | 중간 | **카테고리 관리와 통합** |
### 11.3 속성 통합 설계
#### 2단계 연쇄 → UnifiedSelect 속성
```typescript
// AS-IS: 별도 관리 메뉴에서 정의 후 참조
<SelectWidget cascadingRelation="WAREHOUSE_LOCATION" />
// TO-BE: 컴포넌트 속성에서 직접 정의
<UnifiedSelect
source="db"
table="warehouse_location"
valueColumn="location_code"
labelColumn="location_name"
cascading={{
parentField: "warehouse_code", // 같은 화면 내 부모 필드
filterColumn: "warehouse_code", // 필터링할 컬럼
clearOnChange: true // 부모 변경 시 초기화
}}
/>
```
#### 조건부 필터 → 공통 conditional 속성
```typescript
// AS-IS: 별도 관리 메뉴에서 조건 정의
// cascading_condition 테이블에 저장
// TO-BE: 모든 컴포넌트에 공통 속성으로 적용
<UnifiedInput
conditional={{
enabled: true,
field: "order_type", // 참조할 필드
operator: "=", // 비교 연산자
value: "EXPORT", // 비교 값
action: "show", // show | hide | disable | enable
}}
/>
```
#### 자동 입력 → autoFill 속성
```typescript
// AS-IS: cascading_auto_fill_group 테이블에 정의
// TO-BE: 컴포넌트 속성에서 직접 정의
<UnifiedInput
autoFill={{
enabled: true,
sourceTable: "company_mng", // 조회할 테이블
filterColumn: "company_code", // 필터링 컬럼
userField: "companyCode", // 사용자 정보 필드
displayColumn: "company_name", // 표시할 컬럼
}}
/>
```
#### 상호 배제 → mutualExclusion 속성
```typescript
// AS-IS: cascading_mutual_exclusion 테이블에 정의
// TO-BE: 컴포넌트 속성에서 직접 정의
<UnifiedSelect
mutualExclusion={{
enabled: true,
targetField: "sub_category", // 상호 배제 대상 필드
type: "exclusive", // exclusive | inclusive
}}
/>
```
### 11.4 관리 메뉴 정리 계획
| 현재 메뉴 | TO-BE | 비고 |
| :-------------------------- | :----------------------- | :-------------------- |
| **연쇄 드롭다운 통합 관리** | **삭제** | 6개 탭 전체 제거 |
| ├─ 2단계 연쇄관계 | UnifiedSelect 속성 | inline 정의 |
| ├─ 다단계 계층 | **테이블관리로 이동** | 복잡한 구조 유지 필요 |
| ├─ 조건부 필터 | 공통 conditional 속성 | 모든 컴포넌트에 적용 |
| ├─ 자동 입력 | autoFill 속성 | 컴포넌트별 정의 |
| ├─ 상호 배제 | mutualExclusion 속성 | 컴포넌트별 정의 |
| └─ 카테고리 값 연쇄 | **카테고리 관리로 이동** | 기존 메뉴 통합 |
### 11.5 DB 테이블 정리 (Phase 5)
| 테이블 | 조치 | 시점 |
| :--------------------------- | :----------------------- | :------ |
| `cascading_relation` | 마이그레이션 후 삭제 | Phase 5 |
| `cascading_condition` | 삭제 (데이터 없음) | Phase 5 |
| `cascading_auto_fill_*` | 삭제 (데이터 없음) | Phase 5 |
| `cascading_mutual_exclusion` | 삭제 (데이터 없음) | Phase 5 |
| `cascading_hierarchy_*` | **유지** | - |
| `category_value_cascading_*` | **유지** (카테고리 관리) | - |
### 11.6 마이그레이션 스크립트 필요 항목
```sql
-- cascading_relation → 화면 레이아웃 데이터로 마이그레이션
-- 기존 2건의 연쇄관계를 사용하는 화면을 찾아서
-- 해당 컴포넌트의 cascading 속성으로 변환
-- 예시: WAREHOUSE_LOCATION 연쇄관계
-- 이 관계를 사용하는 화면의 컴포넌트에
-- cascading: { parentField: "warehouse_code", filterColumn: "warehouse_code" }
-- 속성 추가
```
---
## 12. 최종 아키텍처 요약
### 12.1 통합 컴포넌트 (10개)
| # | 컴포넌트 | 역할 |
| :-: | :------------------- | :--------------------------------------- |
| 1 | **UnifiedInput** | 단일 값 입력 (text, number, slider 등) |
| 2 | **UnifiedSelect** | 선택 입력 (dropdown, radio, checkbox 등) |
| 3 | **UnifiedDate** | 날짜/시간 입력 |
| 4 | **UnifiedText** | 다중 행 텍스트 (textarea, rich editor) |
| 5 | **UnifiedMedia** | 파일/미디어 (file, image) |
| 6 | **UnifiedList** | 데이터 목록 (table, card, repeater) |
| 7 | **UnifiedLayout** | 레이아웃 배치 (grid, split, flex) |
| 8 | **UnifiedGroup** | 콘텐츠 그룹화 (tabs, accordion, section) |
| 9 | **UnifiedBiz** | 비즈니스 특화 (flow, rack, map 등) |
| 10 | **UnifiedHierarchy** | 계층 구조 (tree, org, bom, cascading) |
### 12.2 공통 속성 (모든 컴포넌트에 적용)
```typescript
interface BaseUnifiedProps {
// 기본 속성
id: string;
label?: string;
required?: boolean;
readonly?: boolean;
disabled?: boolean;
// 스타일
style?: ComponentStyle;
className?: string;
// 조건부 렌더링 (conditional-container 대체)
conditional?: {
enabled: boolean;
field: string;
operator:
| "="
| "!="
| ">"
| "<"
| "in"
| "notIn"
| "isEmpty"
| "isNotEmpty";
value: any;
action: "show" | "hide" | "disable" | "enable";
};
// 자동 입력 (autoFill 대체)
autoFill?: {
enabled: boolean;
sourceTable: string;
filterColumn: string;
userField: "companyCode" | "userId" | "deptCode";
displayColumn: string;
};
// 유효성 검사
validation?: ValidationRule[];
}
```
### 12.3 UnifiedSelect 전용 속성
```typescript
interface UnifiedSelectProps extends BaseUnifiedProps {
// 표시 모드
mode: "dropdown" | "radio" | "check" | "tag" | "toggle" | "swap";
// 데이터 소스
source: "static" | "code" | "db" | "api" | "entity";
// static 소스
options?: Array<{ value: string; label: string }>;
// db 소스
table?: string;
valueColumn?: string;
labelColumn?: string;
// code 소스
codeGroup?: string;
// 연쇄 관계 (cascading_relation 대체)
cascading?: {
parentField: string; // 부모 필드명
filterColumn: string; // 필터링할 컬럼
clearOnChange?: boolean; // 부모 변경 시 초기화
};
// 상호 배제 (mutual_exclusion 대체)
mutualExclusion?: {
enabled: boolean;
targetField: string; // 상호 배제 대상
type: "exclusive" | "inclusive";
};
// 다중 선택
multiple?: boolean;
maxSelect?: number;
}
```
### 12.4 관리 메뉴 정리 결과
| AS-IS | TO-BE |
| :---------------------------- | :----------------------------------- |
| 연쇄 드롭다운 통합 관리 (6탭) | **삭제** |
| - 2단계 연쇄관계 | → UnifiedSelect.cascading 속성 |
| - 다단계 계층 | → 테이블관리 > 계층 구조 설정 |
| - 조건부 필터 | → 공통 conditional 속성 |
| - 자동 입력 | → 공통 autoFill 속성 |
| - 상호 배제 | → UnifiedSelect.mutualExclusion 속성 |
| - 카테고리 값 연쇄 | → 카테고리 관리와 통합 |
---
## 13. 주의사항
> **기존 컴포넌트 삭제 금지**
> 모든 Phase에서 기존 컴포넌트는 삭제하지 않고 **병행 운영**합니다.
> 레거시 정리는 Phase 5에서 충분한 안정화 후 별도 검토합니다.
> **연쇄관계 마이그레이션 필수**
> 관리 메뉴 삭제 전 기존 `cascading_relation` 데이터(2건)를
> 해당 화면의 컴포넌트 속성으로 마이그레이션해야 합니다.

View File

@ -12,12 +12,15 @@
"@types/mssql": "^9.1.8", "@types/mssql": "^9.1.8",
"axios": "^1.11.0", "axios": "^1.11.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"bwip-js": "^4.8.0",
"compression": "^1.7.4", "compression": "^1.7.4",
"cors": "^2.8.5", "cors": "^2.8.5",
"docx": "^9.5.1",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^7.1.5", "express-rate-limit": "^7.1.5",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"html-to-docx": "^1.8.0",
"iconv-lite": "^0.7.0", "iconv-lite": "^0.7.0",
"imap": "^0.8.19", "imap": "^0.8.19",
"joi": "^17.11.0", "joi": "^17.11.0",
@ -39,6 +42,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/bwip-js": "^3.2.3",
"@types/compression": "^1.7.5", "@types/compression": "^1.7.5",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
@ -1040,6 +1044,7 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3", "@babel/generator": "^7.28.3",
@ -2256,6 +2261,93 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/@oozcitak/dom": {
"version": "1.15.6",
"resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-1.15.6.tgz",
"integrity": "sha512-k4uEIa6DI3FCrFJMGq/05U/59WnS9DjME0kaPqBRCJAqBTkmopbYV1Xs4qFKbDJ/9wOg8W97p+1E0heng/LH7g==",
"license": "MIT",
"dependencies": {
"@oozcitak/infra": "1.0.5",
"@oozcitak/url": "1.0.0",
"@oozcitak/util": "8.3.4"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/@oozcitak/infra": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-1.0.5.tgz",
"integrity": "sha512-o+zZH7M6l5e3FaAWy3ojaPIVN5eusaYPrKm6MZQt0DKNdgXa2wDYExjpP0t/zx+GoQgQKzLu7cfD8rHCLt8JrQ==",
"license": "MIT",
"dependencies": {
"@oozcitak/util": "8.0.0"
},
"engines": {
"node": ">=6.0"
}
},
"node_modules/@oozcitak/infra/node_modules/@oozcitak/util": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.0.0.tgz",
"integrity": "sha512-+9Hq6yuoq/3TRV/n/xcpydGBq2qN2/DEDMqNTG7rm95K6ZE2/YY/sPyx62+1n8QsE9O26e5M1URlXsk+AnN9Jw==",
"license": "MIT",
"engines": {
"node": ">=6.0"
}
},
"node_modules/@oozcitak/url": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@oozcitak/url/-/url-1.0.0.tgz",
"integrity": "sha512-LGrMeSxeLzsdaitxq3ZmBRVOrlRRQIgNNci6L0VRnOKlJFuRIkNm4B+BObXPCJA6JT5bEJtrrwjn30jueHJYZQ==",
"license": "MIT",
"dependencies": {
"@oozcitak/infra": "1.0.3",
"@oozcitak/util": "1.0.2"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/@oozcitak/url/node_modules/@oozcitak/infra": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-1.0.3.tgz",
"integrity": "sha512-9O2wxXGnRzy76O1XUxESxDGsXT5kzETJPvYbreO4mv6bqe1+YSuux2cZTagjJ/T4UfEwFJz5ixanOqB0QgYAag==",
"license": "MIT",
"dependencies": {
"@oozcitak/util": "1.0.1"
},
"engines": {
"node": ">=6.0"
}
},
"node_modules/@oozcitak/url/node_modules/@oozcitak/infra/node_modules/@oozcitak/util": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-1.0.1.tgz",
"integrity": "sha512-dFwFqcKrQnJ2SapOmRD1nQWEZUtbtIy9Y6TyJquzsalWNJsKIPxmTI0KG6Ypyl8j7v89L2wixH9fQDNrF78hKg==",
"license": "MIT",
"engines": {
"node": ">=6.0"
}
},
"node_modules/@oozcitak/url/node_modules/@oozcitak/util": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-1.0.2.tgz",
"integrity": "sha512-4n8B1cWlJleSOSba5gxsMcN4tO8KkkcvXhNWW+ADqvq9Xj+Lrl9uCa90GRpjekqQJyt84aUX015DG81LFpZYXA==",
"license": "MIT",
"engines": {
"node": ">=6.0"
}
},
"node_modules/@oozcitak/util": {
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.3.4.tgz",
"integrity": "sha512-6gH/bLQJSJEg7OEpkH4wGQdA8KXHRbzL1YkGyUO12YNAgV3jxKy4K9kvfXj4+9T0OLug5k58cnPCKSSIKzp7pg==",
"license": "MIT",
"engines": {
"node": ">=8.0"
}
},
"node_modules/@paralleldrive/cuid2": { "node_modules/@paralleldrive/cuid2": {
"version": "2.2.2", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz",
@ -2280,6 +2372,7 @@
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"cluster-key-slot": "1.1.2", "cluster-key-slot": "1.1.2",
"generic-pool": "3.9.0", "generic-pool": "3.9.0",
@ -3124,6 +3217,16 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/bwip-js": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/@types/bwip-js/-/bwip-js-3.2.3.tgz",
"integrity": "sha512-kgL1GOW7n5FhlC5aXnckaEim0rz1cFM4t9/xUwuNXCIDnWLx8ruQ4JQkG6znq4GQFovNLhQy5JdgbDwJw4D/zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/compression": { "node_modules/@types/compression": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz",
@ -3373,6 +3476,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==", "integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@ -3609,6 +3713,7 @@
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0", "@typescript-eslint/types": "6.21.0",
@ -3826,6 +3931,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -4326,6 +4432,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/browser-split": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/browser-split/-/browser-split-0.0.1.tgz",
"integrity": "sha512-JhvgRb2ihQhsljNda3BI8/UcRHVzrVwo3Q+P8vDtSiyobXuFpuZ9mq+MbRGMnC22CjW3RrfXdg6j6ITX8M+7Ow==",
"license": "MIT"
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.26.2", "version": "4.26.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz",
@ -4346,6 +4458,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.3", "baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001741", "caniuse-lite": "^1.0.30001741",
@ -4445,6 +4558,15 @@
"node": ">=10.16.0" "node": ">=10.16.0"
} }
}, },
"node_modules/bwip-js": {
"version": "4.8.0",
"resolved": "https://registry.npmjs.org/bwip-js/-/bwip-js-4.8.0.tgz",
"integrity": "sha512-gUDkDHSTv8/DJhomSIbO0fX/Dx0MO/sgllLxJyJfu4WixCQe9nfGJzmHm64ZCbxo+gUYQEsQcRmqcwcwPRwUkg==",
"license": "MIT",
"bin": {
"bwip-js": "bin/bwip-js.js"
}
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@ -4521,6 +4643,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/camelize": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
"integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001745", "version": "1.0.30001745",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001745.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001745.tgz",
@ -5202,6 +5333,56 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/docx": {
"version": "9.5.1",
"resolved": "https://registry.npmjs.org/docx/-/docx-9.5.1.tgz",
"integrity": "sha512-ABDI7JEirFD2+bHhOBlsGZxaG1UgZb2M/QMKhLSDGgVNhxDesTCDcP+qoDnDGjZ4EOXTRfUjUgwHVuZ6VSTfWQ==",
"license": "MIT",
"dependencies": {
"@types/node": "^24.0.1",
"hash.js": "^1.1.7",
"jszip": "^3.10.1",
"nanoid": "^5.1.3",
"xml": "^1.0.1",
"xml-js": "^1.6.8"
},
"engines": {
"node": ">=10"
}
},
"node_modules/docx/node_modules/@types/node": {
"version": "24.10.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz",
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/docx/node_modules/nanoid": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz",
"integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/docx/node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"license": "MIT"
},
"node_modules/dom-serializer": { "node_modules/dom-serializer": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@ -5216,6 +5397,11 @@
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
} }
}, },
"node_modules/dom-walk": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
},
"node_modules/domelementtype": { "node_modules/domelementtype": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
@ -5349,6 +5535,27 @@
"node": ">=8.10.0" "node": ">=8.10.0"
} }
}, },
"node_modules/ent": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/ent/-/ent-2.2.2.tgz",
"integrity": "sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.3",
"es-errors": "^1.3.0",
"punycode": "^1.4.1",
"safe-regex-test": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ent/node_modules/punycode": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
"integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==",
"license": "MIT"
},
"node_modules/entities": { "node_modules/entities": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
@ -5361,6 +5568,16 @@
"url": "https://github.com/fb55/entities?sponsor=1" "url": "https://github.com/fb55/entities?sponsor=1"
} }
}, },
"node_modules/error": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/error/-/error-4.4.0.tgz",
"integrity": "sha512-SNDKualLUtT4StGFP7xNfuFybL2f6iJujFtrWuvJqGbVQGaN+adE23veqzPz1hjUjTunLi2EnJ+0SJxtbJreKw==",
"dependencies": {
"camelize": "^1.0.0",
"string-template": "~0.2.0",
"xtend": "~4.0.0"
}
},
"node_modules/error-ex": { "node_modules/error-ex": {
"version": "1.3.4", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
@ -5452,6 +5669,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1", "@eslint-community/regexpp": "^4.6.1",
@ -5643,6 +5861,14 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/ev-store": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/ev-store/-/ev-store-7.0.0.tgz",
"integrity": "sha512-otazchNRnGzp2YarBJ+GXKVGvhxVATB1zmaStxJBYet0Dyq7A9VhH8IUEB/gRcL6Ch52lfpgPTRJ2m49epyMsQ==",
"dependencies": {
"individual": "^3.0.0"
}
},
"node_modules/event-target-shim": { "node_modules/event-target-shim": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
@ -6279,6 +6505,16 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/global": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz",
"integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==",
"license": "MIT",
"dependencies": {
"min-document": "^2.19.0",
"process": "^0.11.10"
}
},
"node_modules/globals": { "node_modules/globals": {
"version": "13.24.0", "version": "13.24.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
@ -6413,6 +6649,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/hash.js": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"minimalistic-assert": "^1.0.1"
}
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@ -6443,6 +6689,22 @@
"node": ">=16.0.0" "node": ">=16.0.0"
} }
}, },
"node_modules/html-entities": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
"integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/mdevils"
},
{
"type": "patreon",
"url": "https://patreon.com/mdevils"
}
],
"license": "MIT"
},
"node_modules/html-escaper": { "node_modules/html-escaper": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@ -6450,6 +6712,27 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/html-to-docx": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/html-to-docx/-/html-to-docx-1.8.0.tgz",
"integrity": "sha512-IiMBWIqXM4+cEsW//RKoonWV7DlXAJBmmKI73XJSVWTIXjGUaxSr2ck1jqzVRZknpvO8xsFnVicldKVAWrBYBA==",
"license": "MIT",
"dependencies": {
"@oozcitak/dom": "1.15.6",
"@oozcitak/util": "8.3.4",
"color-name": "^1.1.4",
"html-entities": "^2.3.3",
"html-to-vdom": "^0.7.0",
"image-size": "^1.0.0",
"image-to-base64": "^2.2.0",
"jszip": "^3.7.1",
"lodash": "^4.17.21",
"mime-types": "^2.1.35",
"nanoid": "^3.1.25",
"virtual-dom": "^2.1.1",
"xmlbuilder2": "2.1.2"
}
},
"node_modules/html-to-text": { "node_modules/html-to-text": {
"version": "9.0.5", "version": "9.0.5",
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
@ -6466,6 +6749,106 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/html-to-vdom": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/html-to-vdom/-/html-to-vdom-0.7.0.tgz",
"integrity": "sha512-k+d2qNkbx0JO00KezQsNcn6k2I/xSBP4yXYFLvXbcasTTDh+RDLUJS3puxqyNnpdyXWRHFGoKU7cRmby8/APcQ==",
"license": "ISC",
"dependencies": {
"ent": "^2.0.0",
"htmlparser2": "^3.8.2"
}
},
"node_modules/html-to-vdom/node_modules/dom-serializer": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz",
"integrity": "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.0.1",
"entities": "^2.0.0"
}
},
"node_modules/html-to-vdom/node_modules/dom-serializer/node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/html-to-vdom/node_modules/dom-serializer/node_modules/entities": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
"integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
"license": "BSD-2-Clause",
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/html-to-vdom/node_modules/domelementtype": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz",
"integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==",
"license": "BSD-2-Clause"
},
"node_modules/html-to-vdom/node_modules/domhandler": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz",
"integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "1"
}
},
"node_modules/html-to-vdom/node_modules/domutils": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz",
"integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "0",
"domelementtype": "1"
}
},
"node_modules/html-to-vdom/node_modules/entities": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz",
"integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==",
"license": "BSD-2-Clause"
},
"node_modules/html-to-vdom/node_modules/htmlparser2": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz",
"integrity": "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==",
"license": "MIT",
"dependencies": {
"domelementtype": "^1.3.1",
"domhandler": "^2.3.0",
"domutils": "^1.5.1",
"entities": "^1.1.1",
"inherits": "^2.0.1",
"readable-stream": "^3.1.1"
}
},
"node_modules/html-to-vdom/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/htmlparser2": { "node_modules/htmlparser2": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
@ -6590,6 +6973,30 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/image-size": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz",
"integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==",
"license": "MIT",
"dependencies": {
"queue": "6.0.2"
},
"bin": {
"image-size": "bin/image-size.js"
},
"engines": {
"node": ">=16.x"
}
},
"node_modules/image-to-base64": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/image-to-base64/-/image-to-base64-2.2.0.tgz",
"integrity": "sha512-Z+aMwm/91UOQqHhrz7Upre2ytKhWejZlWV/JxUTD1sT7GWWKFDJUEV5scVQKnkzSgPHFuQBUEWcanO+ma0PSVw==",
"license": "MIT",
"dependencies": {
"node-fetch": "^2.6.0"
}
},
"node_modules/imap": { "node_modules/imap": {
"version": "0.8.19", "version": "0.8.19",
"resolved": "https://registry.npmjs.org/imap/-/imap-0.8.19.tgz", "resolved": "https://registry.npmjs.org/imap/-/imap-0.8.19.tgz",
@ -6626,6 +7033,12 @@
"integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -6673,6 +7086,11 @@
"node": ">=0.8.19" "node": ">=0.8.19"
} }
}, },
"node_modules/individual": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/individual/-/individual-3.0.0.tgz",
"integrity": "sha512-rUY5vtT748NMRbEMrTNiFfy29BgGZwGXUi2NFUVMWQrogSLzlJvQV9eeMWi+g1aVaQ53tpyLAQtd5x/JH0Nh1g=="
},
"node_modules/inflight": { "node_modules/inflight": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@ -6854,6 +7272,15 @@
"node": ">=0.12.0" "node": ">=0.12.0"
} }
}, },
"node_modules/is-object": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz",
"integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-path-inside": { "node_modules/is-path-inside": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
@ -7005,6 +7432,7 @@
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jest/core": "^29.7.0", "@jest/core": "^29.7.0",
"@jest/types": "^29.6.3", "@jest/types": "^29.6.3",
@ -7696,6 +8124,18 @@
"npm": ">=6" "npm": ">=6"
} }
}, },
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jwa": { "node_modules/jwa": {
"version": "1.4.2", "version": "1.4.2",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
@ -7812,6 +8252,15 @@
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lines-and-columns": { "node_modules/lines-and-columns": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@ -7953,7 +8402,6 @@
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0" "js-tokens": "^3.0.0 || ^4.0.0"
}, },
@ -8177,6 +8625,21 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/min-document": {
"version": "2.19.2",
"resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.2.tgz",
"integrity": "sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==",
"license": "MIT",
"dependencies": {
"dom-walk": "^0.1.0"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "9.0.3", "version": "9.0.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
@ -8300,6 +8763,24 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/native-duplexpair": { "node_modules/native-duplexpair": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz", "resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz",
@ -8329,6 +8810,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/next-tick": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-0.2.2.tgz",
"integrity": "sha512-f7h4svPtl+QidoBv4taKXUjJ70G2asaZ8G28nS0OkqaalX8dwwrtWtyxEDPK62AC00ur/+/E0pUwBwY5EPn15Q==",
"license": "MIT"
},
"node_modules/node-cron": { "node_modules/node-cron": {
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
@ -8670,6 +9157,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parchment": { "node_modules/parchment": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz", "resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
@ -8797,6 +9290,7 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"pg-connection-string": "^2.9.1", "pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1", "pg-pool": "^3.10.1",
@ -9179,6 +9673,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
"license": "MIT",
"dependencies": {
"inherits": "~2.0.3"
}
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@ -9595,6 +10098,23 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/safe-regex-test": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
"integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"is-regex": "^1.2.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safe-stable-stringify": { "node_modules/safe-stable-stringify": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
@ -9610,12 +10130,17 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/sax": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz",
"integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==",
"license": "BlueOak-1.0.0"
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.23.2", "version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
} }
@ -9744,6 +10269,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/setprototypeof": { "node_modules/setprototypeof": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@ -10020,6 +10551,11 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/string-template": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/string-template/-/string-template-0.2.1.tgz",
"integrity": "sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw=="
},
"node_modules/string-width": { "node_modules/string-width": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@ -10413,6 +10949,7 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@cspotcode/source-map-support": "^0.8.0", "@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7", "@tsconfig/node10": "^1.0.7",
@ -10518,6 +11055,7 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -10685,6 +11223,22 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/virtual-dom": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/virtual-dom/-/virtual-dom-2.1.1.tgz",
"integrity": "sha512-wb6Qc9Lbqug0kRqo/iuApfBpJJAq14Sk1faAnSmtqXiwahg7PVTvWMs9L02Z8nNIMqbwsxzBAA90bbtRLbw0zg==",
"license": "MIT",
"dependencies": {
"browser-split": "0.0.1",
"error": "^4.3.0",
"ev-store": "^7.0.0",
"global": "^4.3.0",
"is-object": "^1.0.1",
"next-tick": "^0.2.2",
"x-is-array": "0.1.0",
"x-is-string": "0.1.0"
}
},
"node_modules/walker": { "node_modules/walker": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
@ -10862,6 +11416,80 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/x-is-array": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/x-is-array/-/x-is-array-0.1.0.tgz",
"integrity": "sha512-goHPif61oNrr0jJgsXRfc8oqtYzvfiMJpTqwE7Z4y9uH+T3UozkGqQ4d2nX9mB9khvA8U2o/UbPOFjgC7hLWIA=="
},
"node_modules/x-is-string": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/x-is-string/-/x-is-string-0.1.0.tgz",
"integrity": "sha512-GojqklwG8gpzOVEVki5KudKNoq7MbbjYZCbyWzEz7tyPA7eleiE0+ePwOWQQRb5fm86rD3S8Tc0tSFf3AOv50w=="
},
"node_modules/xml": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
"integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
"license": "MIT"
},
"node_modules/xml-js": {
"version": "1.6.11",
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
"license": "MIT",
"dependencies": {
"sax": "^1.2.4"
},
"bin": {
"xml-js": "bin/cli.js"
}
},
"node_modules/xmlbuilder2": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-2.1.2.tgz",
"integrity": "sha512-PI710tmtVlQ5VmwzbRTuhmVhKnj9pM8Si+iOZCV2g2SNo3gCrpzR2Ka9wNzZtqfD+mnP+xkrqoNy0sjKZqP4Dg==",
"license": "MIT",
"dependencies": {
"@oozcitak/dom": "1.15.5",
"@oozcitak/infra": "1.0.5",
"@oozcitak/util": "8.3.3"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/xmlbuilder2/node_modules/@oozcitak/dom": {
"version": "1.15.5",
"resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-1.15.5.tgz",
"integrity": "sha512-L6v3Mwb0TaYBYgeYlIeBaHnc+2ZEaDSbFiRm5KmqZQSoBlbPlf+l6aIH/sD5GUf2MYwULw00LT7+dOnEuAEC0A==",
"license": "MIT",
"dependencies": {
"@oozcitak/infra": "1.0.5",
"@oozcitak/url": "1.0.0",
"@oozcitak/util": "8.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/xmlbuilder2/node_modules/@oozcitak/dom/node_modules/@oozcitak/util": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.0.0.tgz",
"integrity": "sha512-+9Hq6yuoq/3TRV/n/xcpydGBq2qN2/DEDMqNTG7rm95K6ZE2/YY/sPyx62+1n8QsE9O26e5M1URlXsk+AnN9Jw==",
"license": "MIT",
"engines": {
"node": ">=6.0"
}
},
"node_modules/xmlbuilder2/node_modules/@oozcitak/util": {
"version": "8.3.3",
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.3.3.tgz",
"integrity": "sha512-Ufpab7G5PfnEhQyy5kDg9C8ltWJjsVT1P/IYqacjstaqydG4Q21HAT2HUZQYBrC/a1ZLKCz87pfydlDvv8y97w==",
"license": "MIT",
"engines": {
"node": ">=6.0"
}
},
"node_modules/xtend": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@ -26,12 +26,15 @@
"@types/mssql": "^9.1.8", "@types/mssql": "^9.1.8",
"axios": "^1.11.0", "axios": "^1.11.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"bwip-js": "^4.8.0",
"compression": "^1.7.4", "compression": "^1.7.4",
"cors": "^2.8.5", "cors": "^2.8.5",
"docx": "^9.5.1",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^7.1.5", "express-rate-limit": "^7.1.5",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"html-to-docx": "^1.8.0",
"iconv-lite": "^0.7.0", "iconv-lite": "^0.7.0",
"imap": "^0.8.19", "imap": "^0.8.19",
"joi": "^17.11.0", "joi": "^17.11.0",
@ -53,6 +56,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/bwip-js": "^3.2.3",
"@types/compression": "^1.7.5", "@types/compression": "^1.7.5",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",

View File

@ -8,6 +8,7 @@ import path from "path";
import config from "./config/environment"; import config from "./config/environment";
import { logger } from "./utils/logger"; import { logger } from "./utils/logger";
import { errorHandler } from "./middleware/errorHandler"; import { errorHandler } from "./middleware/errorHandler";
import { refreshTokenIfNeeded } from "./middleware/authMiddleware";
// 라우터 임포트 // 라우터 임포트
import authRoutes from "./routes/authRoutes"; import authRoutes from "./routes/authRoutes";
@ -57,6 +58,7 @@ import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관
import todoRoutes from "./routes/todoRoutes"; // To-Do 관리 import todoRoutes from "./routes/todoRoutes"; // To-Do 관리
import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리 import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리
import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리 import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리
import excelMappingRoutes from "./routes/excelMappingRoutes"; // 엑셀 매핑 템플릿
import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 3D 필드 import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 3D 필드
//import materialRoutes from "./routes/materialRoutes"; // 자재 관리 //import materialRoutes from "./routes/materialRoutes"; // 자재 관리
import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제) import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제)
@ -70,8 +72,17 @@ import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카
import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합 import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리 import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색 import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
import orderRoutes from "./routes/orderRoutes"; // 수주 관리
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달 import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리
import cascadingAutoFillRoutes from "./routes/cascadingAutoFillRoutes"; // 자동 입력 관리
import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조건부 연쇄 관리
import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리
import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
import { BatchSchedulerService } from "./services/batchSchedulerService"; import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@ -166,6 +177,10 @@ const limiter = rateLimit({
}); });
app.use("/api/", limiter); app.use("/api/", limiter);
// 토큰 자동 갱신 미들웨어 (모든 API 요청에 적용)
// 토큰이 1시간 이내에 만료되는 경우 자동으로 갱신하여 응답 헤더에 포함
app.use("/api/", refreshTokenIfNeeded);
// 헬스 체크 엔드포인트 // 헬스 체크 엔드포인트
app.get("/health", (req, res) => { app.get("/health", (req, res) => {
res.status(200).json({ res.status(200).json({
@ -183,6 +198,7 @@ app.use("/api/multilang", multilangRoutes);
app.use("/api/table-management", tableManagementRoutes); app.use("/api/table-management", tableManagementRoutes);
app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능 app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능
app.use("/api/screen-management", screenManagementRoutes); app.use("/api/screen-management", screenManagementRoutes);
app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리
app.use("/api/common-codes", commonCodeRoutes); app.use("/api/common-codes", commonCodeRoutes);
app.use("/api/dynamic-form", dynamicFormRoutes); app.use("/api/dynamic-form", dynamicFormRoutes);
app.use("/api/files", fileRoutes); app.use("/api/files", fileRoutes);
@ -207,6 +223,7 @@ app.use("/api/external-rest-api-connections", externalRestApiConnectionRoutes);
app.use("/api/multi-connection", multiConnectionRoutes); app.use("/api/multi-connection", multiConnectionRoutes);
app.use("/api/screen-files", screenFileRoutes); app.use("/api/screen-files", screenFileRoutes);
app.use("/api/batch-configs", batchRoutes); app.use("/api/batch-configs", batchRoutes);
app.use("/api/excel-mapping", excelMappingRoutes); // 엑셀 매핑 템플릿
app.use("/api/batch-management", batchManagementRoutes); app.use("/api/batch-management", batchManagementRoutes);
app.use("/api/batch-execution-logs", batchExecutionLogRoutes); app.use("/api/batch-execution-logs", batchExecutionLogRoutes);
// app.use("/api/db-type-categories", dbTypeCategoryRoutes); // 파일이 존재하지 않음 // app.use("/api/db-type-categories", dbTypeCategoryRoutes); // 파일이 존재하지 않음
@ -236,8 +253,16 @@ app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값
app.use("/api/code-merge", codeMergeRoutes); // 코드 병합 app.use("/api/code-merge", codeMergeRoutes); // 코드 병합
app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리 app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색 app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
app.use("/api/orders", orderRoutes); // 수주 관리 app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리
app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리
app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리
app.use("/api/cascading-auto-fill", cascadingAutoFillRoutes); // 자동 입력 관리
app.use("/api/cascading-conditions", cascadingConditionRoutes); // 조건부 연쇄 관리
app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리
app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리
app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
// app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/collections", collectionRoutes); // 임시 주석
// app.use("/api/batch", batchRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석
// app.use('/api/users', userRoutes); // app.use('/api/users', userRoutes);

View File

@ -632,6 +632,9 @@ export class DashboardController {
validateStatus: () => true, // 모든 상태 코드 허용 (에러도 응답으로 처리) validateStatus: () => true, // 모든 상태 코드 허용 (에러도 응답으로 처리)
}; };
// 연결 정보 (응답에 포함용)
let connectionInfo: { saveToHistory?: boolean } | null = null;
// 외부 커넥션 ID가 있는 경우, 해당 커넥션의 인증 정보(DB 토큰 등)를 적용 // 외부 커넥션 ID가 있는 경우, 해당 커넥션의 인증 정보(DB 토큰 등)를 적용
if (externalConnectionId) { if (externalConnectionId) {
try { try {
@ -652,6 +655,11 @@ export class DashboardController {
if (connectionResult.success && connectionResult.data) { if (connectionResult.success && connectionResult.data) {
const connection = connectionResult.data; const connection = connectionResult.data;
// 연결 정보 저장 (응답에 포함)
connectionInfo = {
saveToHistory: connection.save_to_history === "Y",
};
// 인증 헤더 생성 (DB 토큰 등) // 인증 헤더 생성 (DB 토큰 등)
const authHeaders = const authHeaders =
await ExternalRestApiConnectionService.getAuthHeaders( await ExternalRestApiConnectionService.getAuthHeaders(
@ -694,6 +702,15 @@ export class DashboardController {
requestConfig.data = body; requestConfig.data = body;
} }
// 디버깅 로그: 실제 요청 정보 출력
logger.info(`[fetchExternalApi] 요청 정보:`, {
url: requestConfig.url,
method: requestConfig.method,
headers: requestConfig.headers,
body: requestConfig.data,
externalConnectionId,
});
// TLS 인증서 검증 예외 처리 (thiratis.com 등 내부망/레거시 API 대응) // TLS 인증서 검증 예외 처리 (thiratis.com 등 내부망/레거시 API 대응)
// ExternalRestApiConnectionService와 동일한 로직 적용 // ExternalRestApiConnectionService와 동일한 로직 적용
const bypassDomains = ["thiratis.com"]; const bypassDomains = ["thiratis.com"];
@ -708,6 +725,12 @@ export class DashboardController {
}); });
} }
// 기상청 API 등 EUC-KR 인코딩을 사용하는 경우 arraybuffer로 받아서 디코딩
const isKmaApi = urlObj.hostname.includes("kma.go.kr");
if (isKmaApi) {
requestConfig.responseType = "arraybuffer";
}
const response = await axios(requestConfig); const response = await axios(requestConfig);
if (response.status >= 400) { if (response.status >= 400) {
@ -719,14 +742,35 @@ export class DashboardController {
let data = response.data; let data = response.data;
const contentType = response.headers["content-type"]; const contentType = response.headers["content-type"];
// 기상청 API 인코딩 처리 (UTF-8 우선, 실패 시 EUC-KR)
if (isKmaApi && Buffer.isBuffer(data)) {
const iconv = require("iconv-lite");
const buffer = Buffer.from(data);
const utf8Text = buffer.toString("utf-8");
// UTF-8로 정상 디코딩되었는지 확인
if (
utf8Text.includes("특보") ||
utf8Text.includes("경보") ||
utf8Text.includes("주의보") ||
(utf8Text.includes("#START7777") && !utf8Text.includes("<22>"))
) {
data = { text: utf8Text, contentType, encoding: "utf-8" };
} else {
// EUC-KR로 디코딩
const eucKrText = iconv.decode(buffer, "EUC-KR");
data = { text: eucKrText, contentType, encoding: "euc-kr" };
}
}
// 텍스트 응답인 경우 포맷팅 // 텍스트 응답인 경우 포맷팅
if (typeof data === "string") { else if (typeof data === "string") {
data = { text: data, contentType }; data = { text: data, contentType };
} }
res.status(200).json({ res.status(200).json({
success: true, success: true,
data, data,
connectionInfo, // 외부 연결 정보 (saveToHistory 등)
}); });
} catch (error: any) { } catch (error: any) {
const status = error.response?.status || 500; const status = error.response?.status || 500;

View File

@ -3,7 +3,7 @@ import { logger } from "../utils/logger";
import { AuthenticatedRequest } from "../types/auth"; import { AuthenticatedRequest } from "../types/auth";
import { ApiResponse } from "../types/common"; import { ApiResponse } from "../types/common";
import { Client } from "pg"; import { Client } from "pg";
import { query, queryOne } from "../database/db"; import { query, queryOne, getPool } from "../database/db";
import config from "../config/environment"; import config from "../config/environment";
import { AdminService } from "../services/adminService"; import { AdminService } from "../services/adminService";
import { EncryptUtil } from "../utils/encryptUtil"; import { EncryptUtil } from "../utils/encryptUtil";
@ -553,10 +553,24 @@ export const setUserLocale = async (
const { locale } = req.body; const { locale } = req.body;
if (!locale || !["ko", "en", "ja", "zh"].includes(locale)) { if (!locale) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
message: "유효하지 않은 로케일입니다. (ko, en, ja, zh 중 선택)", message: "로케일이 필요합니다.",
});
return;
}
// language_master 테이블에서 유효한 언어 코드인지 확인
const validLang = await queryOne<{ lang_code: string }>(
"SELECT lang_code FROM language_master WHERE lang_code = $1 AND is_active = 'Y'",
[locale]
);
if (!validLang) {
res.status(400).json({
success: false,
message: `유효하지 않은 로케일입니다: ${locale}`,
}); });
return; return;
} }
@ -1165,6 +1179,33 @@ export async function saveMenu(
logger.info("메뉴 저장 성공", { savedMenu }); logger.info("메뉴 저장 성공", { savedMenu });
// 다국어 메뉴 카테고리 자동 생성
try {
const { MultiLangService } = await import("../services/multilangService");
const multilangService = new MultiLangService();
// 회사명 조회
const companyInfo = await queryOne<{ company_name: string }>(
`SELECT company_name FROM company_mng WHERE company_code = $1`,
[companyCode]
);
const companyName = companyCode === "*" ? "공통" : (companyInfo?.company_name || companyCode);
// 메뉴 경로 조회 및 카테고리 생성
const menuPath = await multilangService.getMenuPath(savedMenu.objid.toString());
await multilangService.ensureMenuCategory(companyCode, companyName, menuPath);
logger.info("메뉴 다국어 카테고리 생성 완료", {
menuObjId: savedMenu.objid.toString(),
menuPath,
});
} catch (categoryError) {
logger.warn("메뉴 다국어 카테고리 생성 실패 (메뉴 저장은 성공)", {
menuObjId: savedMenu.objid.toString(),
error: categoryError,
});
}
const response: ApiResponse<any> = { const response: ApiResponse<any> = {
success: true, success: true,
message: "메뉴가 성공적으로 저장되었습니다.", message: "메뉴가 성공적으로 저장되었습니다.",
@ -1256,8 +1297,17 @@ export async function updateMenu(
} }
} }
const requestCompanyCode = let requestCompanyCode =
menuData.companyCode || menuData.company_code || currentMenu.company_code; menuData.companyCode || menuData.company_code;
// "none"이나 빈 값은 기존 메뉴의 회사 코드 유지
if (
requestCompanyCode === "none" ||
requestCompanyCode === "" ||
!requestCompanyCode
) {
requestCompanyCode = currentMenu.company_code;
}
// company_code 변경 시도하는 경우 권한 체크 // company_code 변경 시도하는 경우 권한 체크
if (requestCompanyCode !== currentMenu.company_code) { if (requestCompanyCode !== currentMenu.company_code) {
@ -1367,6 +1417,75 @@ export async function updateMenu(
} }
} }
/**
* ID를
*/
async function collectAllChildMenuIds(parentObjid: number): Promise<number[]> {
const allIds: number[] = [];
// 직접 자식 메뉴들 조회
const children = await query<any>(
`SELECT objid FROM menu_info WHERE parent_obj_id = $1`,
[parentObjid]
);
for (const child of children) {
allIds.push(child.objid);
// 자식의 자식들도 재귀적으로 수집
const grandChildren = await collectAllChildMenuIds(child.objid);
allIds.push(...grandChildren);
}
return allIds;
}
/**
*
*/
async function cleanupMenuRelatedData(menuObjid: number): Promise<void> {
// 1. category_column_mapping에서 menu_objid를 NULL로 설정
await query(
`UPDATE category_column_mapping SET menu_objid = NULL WHERE menu_objid = $1`,
[menuObjid]
);
// 2. code_category에서 menu_objid를 NULL로 설정
await query(
`UPDATE code_category SET menu_objid = NULL WHERE menu_objid = $1`,
[menuObjid]
);
// 3. code_info에서 menu_objid를 NULL로 설정
await query(
`UPDATE code_info SET menu_objid = NULL WHERE menu_objid = $1`,
[menuObjid]
);
// 4. numbering_rules에서 menu_objid를 NULL로 설정
await query(
`UPDATE numbering_rules SET menu_objid = NULL WHERE menu_objid = $1`,
[menuObjid]
);
// 5. rel_menu_auth에서 관련 권한 삭제
await query(
`DELETE FROM rel_menu_auth WHERE menu_objid = $1`,
[menuObjid]
);
// 6. screen_menu_assignments에서 관련 할당 삭제
await query(
`DELETE FROM screen_menu_assignments WHERE menu_objid = $1`,
[menuObjid]
);
// 7. screen_groups에서 menu_objid를 NULL로 설정
await query(
`UPDATE screen_groups SET menu_objid = NULL WHERE menu_objid = $1`,
[menuObjid]
);
}
/** /**
* *
*/ */
@ -1393,7 +1512,7 @@ export async function deleteMenu(
// 삭제하려는 메뉴 조회 // 삭제하려는 메뉴 조회
const currentMenu = await queryOne<any>( const currentMenu = await queryOne<any>(
`SELECT objid, company_code FROM menu_info WHERE objid = $1`, `SELECT objid, company_code, menu_name_kor FROM menu_info WHERE objid = $1`,
[Number(menuId)] [Number(menuId)]
); );
@ -1428,26 +1547,50 @@ export async function deleteMenu(
} }
} }
// Raw Query를 사용한 메뉴 삭제 const menuObjid = Number(menuId);
const [deletedMenu] = await query<any>(
`DELETE FROM menu_info WHERE objid = $1 RETURNING *`, // 하위 메뉴들 재귀적으로 수집
[Number(menuId)] const childMenuIds = await collectAllChildMenuIds(menuObjid);
); const allMenuIdsToDelete = [menuObjid, ...childMenuIds];
logger.info(`메뉴 삭제 대상: 본인(${menuObjid}) + 하위 메뉴 ${childMenuIds.length}`, {
menuName: currentMenu.menu_name_kor,
totalCount: allMenuIdsToDelete.length,
childMenuIds,
});
logger.info("메뉴 삭제 성공", { deletedMenu }); // 모든 삭제 대상 메뉴에 대해 관련 데이터 정리
for (const objid of allMenuIdsToDelete) {
await cleanupMenuRelatedData(objid);
}
logger.info("메뉴 관련 데이터 정리 완료", {
menuObjid,
totalCleaned: allMenuIdsToDelete.length
});
// 하위 메뉴부터 역순으로 삭제 (외래키 제약 회피)
// 가장 깊은 하위부터 삭제해야 하므로 역순으로
const reversedIds = [...allMenuIdsToDelete].reverse();
for (const objid of reversedIds) {
await query(`DELETE FROM menu_info WHERE objid = $1`, [objid]);
}
logger.info("메뉴 삭제 성공", {
deletedMenuObjid: menuObjid,
deletedMenuName: currentMenu.menu_name_kor,
totalDeleted: allMenuIdsToDelete.length,
});
const response: ApiResponse<any> = { const response: ApiResponse<any> = {
success: true, success: true,
message: "메뉴가 성공적으로 삭제되었습니다.", message: `메뉴가 성공적으로 삭제되었습니다. (하위 메뉴 ${childMenuIds.length}개 포함)`,
data: { data: {
objid: deletedMenu.objid.toString(), objid: menuObjid.toString(),
menuNameKor: deletedMenu.menu_name_kor, menuNameKor: currentMenu.menu_name_kor,
menuNameEng: deletedMenu.menu_name_eng, deletedCount: allMenuIdsToDelete.length,
menuUrl: deletedMenu.menu_url, deletedChildCount: childMenuIds.length,
menuDesc: deletedMenu.menu_desc,
status: deletedMenu.status,
writer: deletedMenu.writer,
regdate: new Date(deletedMenu.regdate).toISOString(),
}, },
}; };
@ -1532,18 +1675,49 @@ export async function deleteMenusBatch(
} }
} }
// 모든 삭제 대상 메뉴 ID 수집 (하위 메뉴 포함)
const allMenuIdsToDelete = new Set<number>();
for (const menuId of menuIds) {
const objid = Number(menuId);
allMenuIdsToDelete.add(objid);
// 하위 메뉴들 재귀적으로 수집
const childMenuIds = await collectAllChildMenuIds(objid);
childMenuIds.forEach(id => allMenuIdsToDelete.add(Number(id)));
}
const allIdsArray = Array.from(allMenuIdsToDelete);
logger.info(`메뉴 일괄 삭제 대상: 선택 ${menuIds.length}개 + 하위 메뉴 포함 총 ${allIdsArray.length}`, {
selectedMenuIds: menuIds,
totalWithChildren: allIdsArray.length,
});
// 모든 삭제 대상 메뉴에 대해 관련 데이터 정리
for (const objid of allIdsArray) {
await cleanupMenuRelatedData(objid);
}
logger.info("메뉴 관련 데이터 정리 완료", {
totalCleaned: allIdsArray.length
});
// Raw Query를 사용한 메뉴 일괄 삭제 // Raw Query를 사용한 메뉴 일괄 삭제
let deletedCount = 0; let deletedCount = 0;
let failedCount = 0; let failedCount = 0;
const deletedMenus: any[] = []; const deletedMenus: any[] = [];
const failedMenuIds: string[] = []; const failedMenuIds: string[] = [];
// 하위 메뉴부터 삭제하기 위해 역순으로 정렬
const reversedIds = [...allIdsArray].reverse();
// 각 메뉴 ID에 대해 삭제 시도 // 각 메뉴 ID에 대해 삭제 시도
for (const menuId of menuIds) { for (const menuObjid of reversedIds) {
try { try {
const result = await query<any>( const result = await query<any>(
`DELETE FROM menu_info WHERE objid = $1 RETURNING *`, `DELETE FROM menu_info WHERE objid = $1 RETURNING *`,
[Number(menuId)] [menuObjid]
); );
if (result.length > 0) { if (result.length > 0) {
@ -1554,20 +1728,20 @@ export async function deleteMenusBatch(
}); });
} else { } else {
failedCount++; failedCount++;
failedMenuIds.push(menuId); failedMenuIds.push(String(menuObjid));
} }
} catch (error) { } catch (error) {
logger.error(`메뉴 삭제 실패 (ID: ${menuId}):`, error); logger.error(`메뉴 삭제 실패 (ID: ${menuObjid}):`, error);
failedCount++; failedCount++;
failedMenuIds.push(menuId); failedMenuIds.push(String(menuObjid));
} }
} }
logger.info("메뉴 일괄 삭제 완료", { logger.info("메뉴 일괄 삭제 완료", {
total: menuIds.length, requested: menuIds.length,
totalWithChildren: allIdsArray.length,
deletedCount, deletedCount,
failedCount, failedCount,
deletedMenus,
failedMenuIds, failedMenuIds,
}); });
@ -2599,6 +2773,24 @@ export const createCompany = async (
}); });
} }
// 다국어 카테고리 자동 생성
try {
const { MultiLangService } = await import("../services/multilangService");
const multilangService = new MultiLangService();
await multilangService.ensureCompanyCategory(
createdCompany.company_code,
createdCompany.company_name
);
logger.info("회사 다국어 카테고리 생성 완료", {
companyCode: createdCompany.company_code,
});
} catch (categoryError) {
logger.warn("회사 다국어 카테고리 생성 실패 (회사 등록은 성공)", {
companyCode: createdCompany.company_code,
error: categoryError,
});
}
logger.info("회사 등록 성공", { logger.info("회사 등록 성공", {
companyCode: createdCompany.company_code, companyCode: createdCompany.company_code,
companyName: createdCompany.company_name, companyName: createdCompany.company_name,
@ -3008,6 +3200,23 @@ export const updateProfile = async (
} }
if (locale !== undefined) { if (locale !== undefined) {
// language_master 테이블에서 유효한 언어 코드인지 확인
const validLang = await queryOne<{ lang_code: string }>(
"SELECT lang_code FROM language_master WHERE lang_code = $1 AND is_active = 'Y'",
[locale]
);
if (!validLang) {
res.status(400).json({
result: false,
error: {
code: "INVALID_LOCALE",
details: `유효하지 않은 로케일입니다: ${locale}`,
},
});
return;
}
updateFields.push(`locale = $${paramIndex}`); updateFields.push(`locale = $${paramIndex}`);
updateValues.push(locale); updateValues.push(locale);
paramIndex++; paramIndex++;
@ -3195,6 +3404,7 @@ export const resetUserPassword = async (
/** /**
* ( ) * ( )
* column_labels
*/ */
export async function getTableSchema( export async function getTableSchema(
req: AuthenticatedRequest, req: AuthenticatedRequest,
@ -3214,20 +3424,25 @@ export async function getTableSchema(
logger.info("테이블 스키마 조회", { tableName, companyCode }); logger.info("테이블 스키마 조회", { tableName, companyCode });
// information_schema에서 컬럼 정보 가져오기 // information_schema와 column_labels를 JOIN하여 컬럼 정보와 라벨 정보 함께 가져오기
const schemaQuery = ` const schemaQuery = `
SELECT SELECT
column_name, ic.column_name,
data_type, ic.data_type,
is_nullable, ic.is_nullable,
column_default, ic.column_default,
character_maximum_length, ic.character_maximum_length,
numeric_precision, ic.numeric_precision,
numeric_scale ic.numeric_scale,
FROM information_schema.columns cl.column_label,
WHERE table_schema = 'public' cl.display_order
AND table_name = $1 FROM information_schema.columns ic
ORDER BY ordinal_position LEFT JOIN column_labels cl
ON cl.table_name = ic.table_name
AND cl.column_name = ic.column_name
WHERE ic.table_schema = 'public'
AND ic.table_name = $1
ORDER BY COALESCE(cl.display_order, ic.ordinal_position), ic.ordinal_position
`; `;
const columns = await query<any>(schemaQuery, [tableName]); const columns = await query<any>(schemaQuery, [tableName]);
@ -3240,9 +3455,10 @@ export async function getTableSchema(
return; return;
} }
// 컬럼 정보를 간단한 형태로 변환 // 컬럼 정보를 간단한 형태로 변환 (라벨 정보 포함)
const columnList = columns.map((col: any) => ({ const columnList = columns.map((col: any) => ({
name: col.column_name, name: col.column_name,
label: col.column_label || col.column_name, // 라벨이 없으면 컬럼명 사용
type: col.data_type, type: col.data_type,
nullable: col.is_nullable === "YES", nullable: col.is_nullable === "YES",
default: col.column_default, default: col.column_default,
@ -3337,13 +3553,23 @@ export async function copyMenu(
} }
: undefined; : undefined;
// 추가 복사 옵션 (카테고리, 코드, 채번규칙 등)
const additionalCopyOptions = req.body.additionalCopyOptions
? {
copyCodeCategory: req.body.additionalCopyOptions.copyCodeCategory === true,
copyNumberingRules: req.body.additionalCopyOptions.copyNumberingRules === true,
copyCategoryMapping: req.body.additionalCopyOptions.copyCategoryMapping === true,
}
: undefined;
// 메뉴 복사 실행 // 메뉴 복사 실행
const menuCopyService = new MenuCopyService(); const menuCopyService = new MenuCopyService();
const result = await menuCopyService.copyMenu( const result = await menuCopyService.copyMenu(
parseInt(menuObjid, 10), parseInt(menuObjid, 10),
targetCompanyCode, targetCompanyCode,
userId, userId,
screenNameConfig screenNameConfig,
additionalCopyOptions
); );
logger.info("✅ 메뉴 복사 API 성공"); logger.info("✅ 메뉴 복사 API 성공");
@ -3365,3 +3591,395 @@ export async function copyMenu(
}); });
} }
} }
/**
* ============================================================
* + API
* ============================================================
*
* (user_info) (user_dept) .
*
* ##
* 1. user_info
* 2. user_dept +
* 3.
* 4.
*
* ##
* ```json
* {
* "userInfo": {
* "user_id": "string (필수)",
* "user_name": "string (필수)",
* "email": "string",
* "cell_phone": "string",
* "sabun": "string",
* ...
* },
* "mainDept": {
* "dept_code": "string (필수)",
* "dept_name": "string",
* "position_name": "string"
* },
* "subDepts": [
* {
* "dept_code": "string (필수)",
* "dept_name": "string",
* "position_name": "string"
* }
* ]
* }
* ```
*/
// 사원 + 부서 저장 요청 타입
interface UserWithDeptRequest {
userInfo: {
user_id: string;
user_name: string;
user_name_eng?: string;
user_password?: string;
email?: string;
tel?: string;
cell_phone?: string;
sabun?: string;
user_type?: string;
user_type_name?: string;
status?: string;
locale?: string;
// 메인 부서 정보 (user_info에도 저장)
dept_code?: string;
dept_name?: string;
position_code?: string;
position_name?: string;
};
mainDept?: {
dept_code: string;
dept_name?: string;
position_name?: string;
};
subDepts?: Array<{
dept_code: string;
dept_name?: string;
position_name?: string;
}>;
isUpdate?: boolean; // 수정 모드 여부
}
/**
* POST /api/admin/users/with-dept
* + API
*/
export const saveUserWithDept = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
const client = await getPool().connect();
try {
const { userInfo, mainDept, subDepts = [], isUpdate = false } = req.body as UserWithDeptRequest;
const companyCode = req.user?.companyCode || "*";
const currentUserId = req.user?.userId;
logger.info("사원+부서 통합 저장 요청", {
userId: userInfo?.user_id,
mainDept: mainDept?.dept_code,
subDeptsCount: subDepts.length,
isUpdate,
companyCode,
});
// 필수값 검증
if (!userInfo?.user_id || !userInfo?.user_name) {
res.status(400).json({
success: false,
message: "사용자 ID와 이름은 필수입니다.",
error: { code: "REQUIRED_FIELD_MISSING" },
});
return;
}
// 트랜잭션 시작
await client.query("BEGIN");
// 1. 기존 사용자 확인
const existingUser = await client.query(
"SELECT user_id FROM user_info WHERE user_id = $1",
[userInfo.user_id]
);
const isExistingUser = existingUser.rows.length > 0;
// 2. 비밀번호 암호화 (새 사용자이거나 비밀번호가 제공된 경우)
let encryptedPassword = null;
if (userInfo.user_password) {
encryptedPassword = await EncryptUtil.encrypt(userInfo.user_password);
}
// 3. user_info 저장 (UPSERT)
// mainDept가 있으면 user_info에도 메인 부서 정보 저장
const deptCode = mainDept?.dept_code || userInfo.dept_code || null;
const deptName = mainDept?.dept_name || userInfo.dept_name || null;
const positionName = mainDept?.position_name || userInfo.position_name || null;
if (isExistingUser) {
// 기존 사용자 수정
const updateFields: string[] = [];
const updateValues: any[] = [];
let paramIndex = 1;
// 동적으로 업데이트할 필드 구성
const fieldsToUpdate: Record<string, any> = {
user_name: userInfo.user_name,
user_name_eng: userInfo.user_name_eng,
email: userInfo.email,
tel: userInfo.tel,
cell_phone: userInfo.cell_phone,
sabun: userInfo.sabun,
user_type: userInfo.user_type,
user_type_name: userInfo.user_type_name,
status: userInfo.status || "active",
locale: userInfo.locale,
dept_code: deptCode,
dept_name: deptName,
position_code: userInfo.position_code,
position_name: positionName,
company_code: companyCode !== "*" ? companyCode : undefined,
};
// 비밀번호가 제공된 경우에만 업데이트
if (encryptedPassword) {
fieldsToUpdate.user_password = encryptedPassword;
}
for (const [key, value] of Object.entries(fieldsToUpdate)) {
if (value !== undefined) {
updateFields.push(`${key} = $${paramIndex}`);
updateValues.push(value);
paramIndex++;
}
}
if (updateFields.length > 0) {
updateValues.push(userInfo.user_id);
await client.query(
`UPDATE user_info SET ${updateFields.join(", ")} WHERE user_id = $${paramIndex}`,
updateValues
);
}
} else {
// 새 사용자 등록
await client.query(
`INSERT INTO user_info (
user_id, user_name, user_name_eng, user_password,
email, tel, cell_phone, sabun,
user_type, user_type_name, status, locale,
dept_code, dept_name, position_code, position_name,
company_code, regdate
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW())`,
[
userInfo.user_id,
userInfo.user_name,
userInfo.user_name_eng || null,
encryptedPassword || null,
userInfo.email || null,
userInfo.tel || null,
userInfo.cell_phone || null,
userInfo.sabun || null,
userInfo.user_type || null,
userInfo.user_type_name || null,
userInfo.status || "active",
userInfo.locale || null,
deptCode,
deptName,
userInfo.position_code || null,
positionName,
companyCode !== "*" ? companyCode : null,
]
);
}
// 4. user_dept 처리
if (mainDept?.dept_code || subDepts.length > 0) {
// 4-1. 기존 부서 관계 조회 (메인 부서 변경 감지용)
const existingDepts = await client.query(
"SELECT dept_code, is_primary FROM user_dept WHERE user_id = $1",
[userInfo.user_id]
);
const existingMainDept = existingDepts.rows.find((d: any) => d.is_primary === true);
// 4-2. 메인 부서가 변경된 경우, 기존 메인 부서를 겸직으로 전환
if (mainDept?.dept_code && existingMainDept && existingMainDept.dept_code !== mainDept.dept_code) {
logger.info("메인 부서 변경 감지 - 기존 메인을 겸직으로 전환", {
userId: userInfo.user_id,
oldMain: existingMainDept.dept_code,
newMain: mainDept.dept_code,
});
await client.query(
"UPDATE user_dept SET is_primary = false, updated_at = NOW() WHERE user_id = $1 AND dept_code = $2",
[userInfo.user_id, existingMainDept.dept_code]
);
}
// 4-3. 기존 겸직 부서 삭제 (메인 제외)
// 새로 입력받은 subDepts로 교체하기 위해 기존 겸직 삭제
await client.query(
"DELETE FROM user_dept WHERE user_id = $1 AND is_primary = false",
[userInfo.user_id]
);
// 4-4. 메인 부서 저장 (UPSERT)
if (mainDept?.dept_code) {
await client.query(
`INSERT INTO user_dept (user_id, dept_code, is_primary, dept_name, user_name, position_name, company_code, created_at, updated_at)
VALUES ($1, $2, true, $3, $4, $5, $6, NOW(), NOW())
ON CONFLICT (user_id, dept_code) DO UPDATE SET
is_primary = true,
dept_name = $3,
user_name = $4,
position_name = $5,
company_code = $6,
updated_at = NOW()`,
[
userInfo.user_id,
mainDept.dept_code,
mainDept.dept_name || null,
userInfo.user_name,
mainDept.position_name || null,
companyCode !== "*" ? companyCode : null,
]
);
}
// 4-5. 겸직 부서 저장
for (const subDept of subDepts) {
if (!subDept.dept_code) continue;
// 메인 부서와 같은 부서는 겸직으로 추가하지 않음
if (mainDept?.dept_code === subDept.dept_code) continue;
await client.query(
`INSERT INTO user_dept (user_id, dept_code, is_primary, dept_name, user_name, position_name, company_code, created_at, updated_at)
VALUES ($1, $2, false, $3, $4, $5, $6, NOW(), NOW())
ON CONFLICT (user_id, dept_code) DO UPDATE SET
is_primary = false,
dept_name = $3,
user_name = $4,
position_name = $5,
company_code = $6,
updated_at = NOW()`,
[
userInfo.user_id,
subDept.dept_code,
subDept.dept_name || null,
userInfo.user_name,
subDept.position_name || null,
companyCode !== "*" ? companyCode : null,
]
);
}
}
// 트랜잭션 커밋
await client.query("COMMIT");
logger.info("사원+부서 통합 저장 완료", {
userId: userInfo.user_id,
isUpdate: isExistingUser,
});
res.json({
success: true,
message: isExistingUser ? "사원 정보가 수정되었습니다." : "사원이 등록되었습니다.",
data: {
userId: userInfo.user_id,
isUpdate: isExistingUser,
},
});
} catch (error: any) {
// 트랜잭션 롤백
await client.query("ROLLBACK");
logger.error("사원+부서 통합 저장 실패", { error: error.message, body: req.body });
// 중복 키 에러 처리
if (error.code === "23505") {
res.status(400).json({
success: false,
message: "이미 존재하는 사용자 ID입니다.",
error: { code: "DUPLICATE_USER_ID" },
});
return;
}
res.status(500).json({
success: false,
message: "사원 저장 중 오류가 발생했습니다.",
error: { code: "SAVE_ERROR", details: error.message },
});
} finally {
client.release();
}
}
/**
* GET /api/admin/users/:userId/with-dept
* + API ( )
*/
export const getUserWithDept = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { userId } = req.params;
const companyCode = req.user?.companyCode || "*";
logger.info("사원+부서 조회 요청", { userId, companyCode });
// 1. user_info 조회
let userQuery = "SELECT * FROM user_info WHERE user_id = $1";
const userParams: any[] = [userId];
// 최고 관리자가 아니면 회사 필터링
if (companyCode !== "*") {
userQuery += " AND company_code = $2";
userParams.push(companyCode);
}
const userResult = await query<any>(userQuery, userParams);
if (userResult.length === 0) {
res.status(404).json({
success: false,
message: "사용자를 찾을 수 없습니다.",
error: { code: "USER_NOT_FOUND" },
});
return;
}
const userInfo = userResult[0];
// 2. user_dept 조회 (메인 + 겸직)
let deptQuery = "SELECT * FROM user_dept WHERE user_id = $1 ORDER BY is_primary DESC, created_at ASC";
const deptResult = await query<any>(deptQuery, [userId]);
const mainDept = deptResult.find((d: any) => d.is_primary === true);
const subDepts = deptResult.filter((d: any) => d.is_primary === false);
res.json({
success: true,
data: {
userInfo,
mainDept: mainDept || null,
subDepts,
},
});
} catch (error: any) {
logger.error("사원+부서 조회 실패", { error: error.message, userId: req.params.userId });
res.status(500).json({
success: false,
message: "사원 조회 중 오류가 발생했습니다.",
error: { code: "QUERY_ERROR", details: error.message },
});
}
}

View File

@ -141,6 +141,110 @@ export class AuthController {
} }
} }
/**
* POST /api/auth/switch-company
* WACE 전용: 다른
*/
static async switchCompany(req: Request, res: Response): Promise<void> {
try {
const { companyCode } = req.body;
const authHeader = req.get("Authorization");
const token = authHeader && authHeader.split(" ")[1];
if (!token) {
res.status(401).json({
success: false,
message: "인증 토큰이 필요합니다.",
error: { code: "TOKEN_MISSING" },
});
return;
}
// 현재 사용자 정보 확인
const currentUser = JwtUtils.verifyToken(token);
// WACE 관리자 권한 체크 (userType = "SUPER_ADMIN"만 확인)
// 이미 다른 회사로 전환한 상태(companyCode != "*")에서도 다시 전환 가능해야 함
if (currentUser.userType !== "SUPER_ADMIN") {
logger.warn(`회사 전환 권한 없음: userId=${currentUser.userId}, userType=${currentUser.userType}, companyCode=${currentUser.companyCode}`);
res.status(403).json({
success: false,
message: "회사 전환은 최고 관리자(SUPER_ADMIN)만 가능합니다.",
error: { code: "FORBIDDEN" },
});
return;
}
// 전환할 회사 코드 검증
if (!companyCode || companyCode.trim() === "") {
res.status(400).json({
success: false,
message: "전환할 회사 코드가 필요합니다.",
error: { code: "INVALID_INPUT" },
});
return;
}
logger.info(`=== WACE 관리자 회사 전환 ===`, {
userId: currentUser.userId,
originalCompanyCode: currentUser.companyCode,
targetCompanyCode: companyCode,
});
// 회사 코드 존재 여부 확인 (company_code가 "*"가 아닌 경우만)
if (companyCode !== "*") {
const { query } = await import("../database/db");
const companies = await query<any>(
"SELECT company_code, company_name FROM company_mng WHERE company_code = $1",
[companyCode]
);
if (companies.length === 0) {
res.status(404).json({
success: false,
message: "존재하지 않는 회사 코드입니다.",
error: { code: "COMPANY_NOT_FOUND" },
});
return;
}
}
// 새로운 JWT 토큰 발급 (company_code만 변경)
const newPersonBean: PersonBean = {
...currentUser,
companyCode: companyCode.trim(), // 전환할 회사 코드로 변경
};
const newToken = JwtUtils.generateToken(newPersonBean);
logger.info(`✅ 회사 전환 성공: ${currentUser.userId}${companyCode}`);
res.status(200).json({
success: true,
message: "회사 전환 완료",
data: {
token: newToken,
companyCode: companyCode.trim(),
},
});
} catch (error) {
logger.error(
`회사 전환 API 오류: ${error instanceof Error ? error.message : error}`
);
res.status(500).json({
success: false,
message: "회사 전환 중 오류가 발생했습니다.",
error: {
code: "SERVER_ERROR",
details:
error instanceof Error
? error.message
: "알 수 없는 오류가 발생했습니다.",
},
});
}
}
/** /**
* POST /api/auth/logout * POST /api/auth/logout
* Java ApiLoginController.logout() * Java ApiLoginController.logout()
@ -226,13 +330,14 @@ export class AuthController {
} }
// 프론트엔드 호환성을 위해 더 많은 사용자 정보 반환 // 프론트엔드 호환성을 위해 더 많은 사용자 정보 반환
// ⚠️ JWT 토큰의 companyCode를 우선 사용 (회사 전환 기능 지원)
const userInfoResponse: any = { const userInfoResponse: any = {
userId: dbUserInfo.userId, userId: dbUserInfo.userId,
userName: dbUserInfo.userName || "", userName: dbUserInfo.userName || "",
deptName: dbUserInfo.deptName || "", deptName: dbUserInfo.deptName || "",
companyCode: dbUserInfo.companyCode || "ILSHIN", companyCode: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선
company_code: dbUserInfo.companyCode || "ILSHIN", // 프론트엔드 호환성 company_code: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선
userType: dbUserInfo.userType || "USER", userType: userInfo.userType || dbUserInfo.userType || "USER", // JWT 토큰 우선
userTypeName: dbUserInfo.userTypeName || "일반사용자", userTypeName: dbUserInfo.userTypeName || "일반사용자",
email: dbUserInfo.email || "", email: dbUserInfo.email || "",
photo: dbUserInfo.photo, photo: dbUserInfo.photo,
@ -384,4 +489,69 @@ export class AuthController {
}); });
} }
} }
/**
* POST /api/auth/signup
* API
*/
static async signup(req: Request, res: Response): Promise<void> {
try {
const { userId, password, userName, phoneNumber, licenseNumber, vehicleNumber, vehicleType } = req.body;
logger.info(`=== 공차중계 회원가입 API 호출 ===`);
logger.info(`userId: ${userId}, vehicleNumber: ${vehicleNumber}`);
// 입력값 검증
if (!userId || !password || !userName || !phoneNumber || !licenseNumber || !vehicleNumber) {
res.status(400).json({
success: false,
message: "필수 입력값이 누락되었습니다.",
error: {
code: "INVALID_INPUT",
details: "아이디, 비밀번호, 이름, 연락처, 면허번호, 차량번호는 필수입니다.",
},
});
return;
}
// 회원가입 처리
const signupResult = await AuthService.signupDriver({
userId,
password,
userName,
phoneNumber,
licenseNumber,
vehicleNumber,
vehicleType,
});
if (signupResult.success) {
logger.info(`공차중계 회원가입 성공: ${userId}`);
res.status(201).json({
success: true,
message: "회원가입이 완료되었습니다.",
});
} else {
logger.warn(`공차중계 회원가입 실패: ${userId} - ${signupResult.message}`);
res.status(400).json({
success: false,
message: signupResult.message || "회원가입에 실패했습니다.",
error: {
code: "SIGNUP_FAILED",
details: signupResult.message,
},
});
}
} catch (error) {
logger.error("공차중계 회원가입 API 오류:", error);
res.status(500).json({
success: false,
message: "회원가입 처리 중 오류가 발생했습니다.",
error: {
code: "SIGNUP_ERROR",
details: error instanceof Error ? error.message : "알 수 없는 오류",
},
});
}
}
} }

View File

@ -1,7 +1,7 @@
// 배치관리 전용 컨트롤러 (기존 소스와 완전 분리) // 배치관리 전용 컨트롤러 (기존 소스와 완전 분리)
// 작성일: 2024-12-24 // 작성일: 2024-12-24
import { Response } from "express"; import { Request, Response } from "express";
import { AuthenticatedRequest } from "../types/auth"; import { AuthenticatedRequest } from "../types/auth";
import { import {
BatchManagementService, BatchManagementService,
@ -13,6 +13,7 @@ import { BatchService } from "../services/batchService";
import { BatchSchedulerService } from "../services/batchSchedulerService"; import { BatchSchedulerService } from "../services/batchSchedulerService";
import { BatchExternalDbService } from "../services/batchExternalDbService"; import { BatchExternalDbService } from "../services/batchExternalDbService";
import { CreateBatchConfigRequest, BatchConfig } from "../types/batchTypes"; import { CreateBatchConfigRequest, BatchConfig } from "../types/batchTypes";
import { query } from "../database/db";
export class BatchManagementController { export class BatchManagementController {
/** /**
@ -422,6 +423,8 @@ export class BatchManagementController {
paramValue, paramValue,
paramSource, paramSource,
requestBody, requestBody,
authServiceName, // DB에서 토큰 가져올 서비스명
dataArrayPath, // 데이터 배열 경로 (예: response, data.items)
} = req.body; } = req.body;
// apiUrl, endpoint는 항상 필수 // apiUrl, endpoint는 항상 필수
@ -432,15 +435,47 @@ export class BatchManagementController {
}); });
} }
// GET 요청일 때만 API Key 필수 (POST/PUT/DELETE는 선택) // 토큰 결정: authServiceName이 있으면 DB에서 조회, 없으면 apiKey 사용
if ((!method || method === "GET") && !apiKey) { let finalApiKey = apiKey || "";
return res.status(400).json({ if (authServiceName) {
success: false, const companyCode = req.user?.companyCode;
message: "GET 메서드에서는 API Key가 필요합니다.",
}); // DB에서 토큰 조회 (멀티테넌시: company_code 필터링)
let tokenQuery: string;
let tokenParams: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 회사 토큰 조회 가능
tokenQuery = `SELECT access_token FROM auth_tokens
WHERE service_name = $1
ORDER BY created_date DESC LIMIT 1`;
tokenParams = [authServiceName];
} else {
// 일반 회사: 자신의 회사 토큰만 조회
tokenQuery = `SELECT access_token FROM auth_tokens
WHERE service_name = $1 AND company_code = $2
ORDER BY created_date DESC LIMIT 1`;
tokenParams = [authServiceName, companyCode];
}
const tokenResult = await query<{ access_token: string }>(
tokenQuery,
tokenParams
);
if (tokenResult.length > 0 && tokenResult[0].access_token) {
finalApiKey = tokenResult[0].access_token;
console.log(`auth_tokens에서 토큰 조회 성공: ${authServiceName}`);
} else {
return res.status(400).json({
success: false,
message: `서비스 '${authServiceName}'의 토큰을 찾을 수 없습니다. 먼저 토큰 저장 배치를 실행하세요.`,
});
}
} }
console.log("🔍 REST API 미리보기 요청:", { // 토큰이 없어도 공개 API 호출 가능 (토큰 검증 제거)
console.log("REST API 미리보기 요청:", {
apiUrl, apiUrl,
endpoint, endpoint,
method, method,
@ -449,6 +484,8 @@ export class BatchManagementController {
paramValue, paramValue,
paramSource, paramSource,
requestBody: requestBody ? "Included" : "None", requestBody: requestBody ? "Included" : "None",
authServiceName: authServiceName || "직접 입력",
dataArrayPath: dataArrayPath || "전체 응답",
}); });
// RestApiConnector 사용하여 데이터 조회 // RestApiConnector 사용하여 데이터 조회
@ -456,7 +493,7 @@ export class BatchManagementController {
const connector = new RestApiConnector({ const connector = new RestApiConnector({
baseUrl: apiUrl, baseUrl: apiUrl,
apiKey: apiKey || "", apiKey: finalApiKey,
timeout: 30000, timeout: 30000,
}); });
@ -511,8 +548,50 @@ export class BatchManagementController {
result.rows && result.rows.length > 0 ? result.rows[0] : "no data", result.rows && result.rows.length > 0 ? result.rows[0] : "no data",
}); });
const data = result.rows.slice(0, 5); // 최대 5개 샘플만 // 데이터 배열 추출 헬퍼 함수
console.log(`[previewRestApiData] 슬라이스된 데이터:`, data); const getValueByPath = (obj: any, path: string): any => {
if (!path) return obj;
const keys = path.split(".");
let current = obj;
for (const key of keys) {
if (current === null || current === undefined) return undefined;
current = current[key];
}
return current;
};
// dataArrayPath가 있으면 해당 경로에서 배열 추출
let extractedData: any[] = [];
if (dataArrayPath) {
// result.rows가 단일 객체일 수 있음 (API 응답 전체)
const rawData = result.rows.length === 1 ? result.rows[0] : result.rows;
const arrayData = getValueByPath(rawData, dataArrayPath);
if (Array.isArray(arrayData)) {
extractedData = arrayData;
console.log(
`[previewRestApiData] '${dataArrayPath}' 경로에서 ${arrayData.length}개 항목 추출`
);
} else {
console.warn(
`[previewRestApiData] '${dataArrayPath}' 경로가 배열이 아님:`,
typeof arrayData
);
// 배열이 아니면 단일 객체로 처리
if (arrayData) {
extractedData = [arrayData];
}
}
} else {
// dataArrayPath가 없으면 기존 로직 사용
extractedData = result.rows;
}
const data = extractedData.slice(0, 5); // 최대 5개 샘플만
console.log(
`[previewRestApiData] 슬라이스된 데이터 (${extractedData.length}개 중 ${data.length}개):`,
data
);
if (data.length > 0) { if (data.length > 0) {
// 첫 번째 객체에서 필드명 추출 // 첫 번째 객체에서 필드명 추출
@ -524,9 +603,9 @@ export class BatchManagementController {
data: { data: {
fields: fields, fields: fields,
samples: data, samples: data,
totalCount: result.rowCount || data.length, totalCount: extractedData.length,
}, },
message: `${fields.length}개 필드, ${result.rowCount || data.length}개 레코드를 조회했습니다.`, message: `${fields.length}개 필드, ${extractedData.length}개 레코드를 조회했습니다.`,
}); });
} else { } else {
return res.json({ return res.json({
@ -554,8 +633,17 @@ export class BatchManagementController {
*/ */
static async saveRestApiBatch(req: AuthenticatedRequest, res: Response) { static async saveRestApiBatch(req: AuthenticatedRequest, res: Response) {
try { try {
const { batchName, batchType, cronSchedule, description, apiMappings } = const {
req.body; batchName,
batchType,
cronSchedule,
description,
apiMappings,
authServiceName,
dataArrayPath,
saveMode,
conflictKey,
} = req.body;
if ( if (
!batchName || !batchName ||
@ -576,6 +664,10 @@ export class BatchManagementController {
cronSchedule, cronSchedule,
description, description,
apiMappings, apiMappings,
authServiceName,
dataArrayPath,
saveMode,
conflictKey,
}); });
// 🔐 멀티테넌시: 현재 사용자 회사 코드 사용 (프론트에서 받지 않음) // 🔐 멀티테넌시: 현재 사용자 회사 코드 사용 (프론트에서 받지 않음)
@ -589,6 +681,10 @@ export class BatchManagementController {
cronSchedule: cronSchedule, cronSchedule: cronSchedule,
isActive: "Y", isActive: "Y",
companyCode, companyCode,
authServiceName: authServiceName || undefined,
dataArrayPath: dataArrayPath || undefined,
saveMode: saveMode || "INSERT",
conflictKey: conflictKey || undefined,
mappings: apiMappings, mappings: apiMappings,
}; };
@ -625,4 +721,51 @@ export class BatchManagementController {
}); });
} }
} }
/**
*
*/
static async getAuthServiceNames(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
// 멀티테넌시: company_code 필터링
let queryText: string;
let queryParams: any[] = [];
if (companyCode === "*") {
// 최고 관리자: 모든 서비스 조회
queryText = `SELECT DISTINCT service_name
FROM auth_tokens
WHERE service_name IS NOT NULL
ORDER BY service_name`;
} else {
// 일반 회사: 자신의 회사 서비스만 조회
queryText = `SELECT DISTINCT service_name
FROM auth_tokens
WHERE service_name IS NOT NULL
AND company_code = $1
ORDER BY service_name`;
queryParams = [companyCode];
}
const result = await query<{ service_name: string }>(
queryText,
queryParams
);
const serviceNames = result.map((row) => row.service_name);
return res.json({
success: true,
data: serviceNames,
});
} catch (error) {
console.error("인증 서비스 목록 조회 오류:", error);
return res.status(500).json({
success: false,
message: "인증 서비스 목록 조회 중 오류가 발생했습니다.",
});
}
}
} }

View File

@ -0,0 +1,606 @@
/**
* (Auto-Fill)
*
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { query, queryOne } from "../database/db";
import logger from "../utils/logger";
// =====================================================
// 자동 입력 그룹 CRUD
// =====================================================
/**
*
*/
export const getAutoFillGroups = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const companyCode = req.user?.companyCode || "*";
const { isActive } = req.query;
let sql = `
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
`;
const params: any[] = [];
let paramIndex = 1;
// 회사 필터
if (companyCode !== "*") {
sql += ` AND g.company_code = $${paramIndex++}`;
params.push(companyCode);
}
// 활성 상태 필터
if (isActive) {
sql += ` AND g.is_active = $${paramIndex++}`;
params.push(isActive);
}
sql += ` GROUP BY g.group_id ORDER BY g.group_name`;
const result = await query(sql, params);
logger.info("자동 입력 그룹 목록 조회", {
count: result.length,
companyCode,
});
res.json({
success: true,
data: result,
});
} catch (error: any) {
logger.error("자동 입력 그룹 목록 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "자동 입력 그룹 목록 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
* ( )
*/
export const getAutoFillGroupDetail = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { groupCode } = req.params;
const companyCode = req.user?.companyCode || "*";
// 그룹 정보 조회
let groupSql = `
SELECT * FROM cascading_auto_fill_group
WHERE group_code = $1
`;
const groupParams: any[] = [groupCode];
if (companyCode !== "*") {
groupSql += ` AND company_code = $2`;
groupParams.push(companyCode);
}
const groupResult = await queryOne(groupSql, groupParams);
if (!groupResult) {
return res.status(404).json({
success: false,
message: "자동 입력 그룹을 찾을 수 없습니다.",
});
}
// 매핑 정보 조회
const mappingSql = `
SELECT * FROM cascading_auto_fill_mapping
WHERE group_code = $1 AND company_code = $2
ORDER BY sort_order, mapping_id
`;
const mappingResult = await query(mappingSql, [
groupCode,
groupResult.company_code,
]);
logger.info("자동 입력 그룹 상세 조회", { groupCode, companyCode });
res.json({
success: true,
data: {
...groupResult,
mappings: mappingResult,
},
});
} catch (error: any) {
logger.error("자동 입력 그룹 상세 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "자동 입력 그룹 상세 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
const generateAutoFillGroupCode = async (
companyCode: string
): Promise<string> => {
const prefix = "AF";
const result = await queryOne(
`SELECT COUNT(*) as cnt FROM cascading_auto_fill_group WHERE company_code = $1`,
[companyCode]
);
const count = parseInt(result?.cnt || "0", 10) + 1;
const timestamp = Date.now().toString(36).toUpperCase().slice(-4);
return `${prefix}_${timestamp}_${count.toString().padStart(3, "0")}`;
};
/**
*
*/
export const createAutoFillGroup = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "system";
const {
groupName,
description,
masterTable,
masterValueColumn,
masterLabelColumn,
mappings = [],
} = req.body;
// 필수 필드 검증
if (!groupName || !masterTable || !masterValueColumn) {
return res.status(400).json({
success: false,
message:
"필수 필드가 누락되었습니다. (groupName, masterTable, masterValueColumn)",
});
}
// 그룹 코드 자동 생성
const groupCode = await generateAutoFillGroupCode(companyCode);
// 그룹 생성
const insertGroupSql = `
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 ($1, $2, $3, $4, $5, $6, $7, 'Y', CURRENT_TIMESTAMP)
RETURNING *
`;
const groupResult = await queryOne(insertGroupSql, [
groupCode,
groupName,
description || null,
masterTable,
masterValueColumn,
masterLabelColumn || null,
companyCode,
]);
// 매핑 생성
if (mappings.length > 0) {
for (let i = 0; i < mappings.length; i++) {
const m = mappings[i];
await query(
`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 ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
groupCode,
companyCode,
m.sourceColumn,
m.targetField,
m.targetLabel || null,
m.isEditable || "Y",
m.isRequired || "N",
m.defaultValue || null,
m.sortOrder || i + 1,
]
);
}
}
logger.info("자동 입력 그룹 생성", { groupCode, companyCode, userId });
res.status(201).json({
success: true,
message: "자동 입력 그룹이 생성되었습니다.",
data: groupResult,
});
} catch (error: any) {
logger.error("자동 입력 그룹 생성 실패", { error: error.message });
res.status(500).json({
success: false,
message: "자동 입력 그룹 생성에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const updateAutoFillGroup = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { groupCode } = req.params;
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "system";
const {
groupName,
description,
masterTable,
masterValueColumn,
masterLabelColumn,
isActive,
mappings,
} = req.body;
// 기존 그룹 확인
let checkSql = `SELECT * FROM cascading_auto_fill_group WHERE group_code = $1`;
const checkParams: any[] = [groupCode];
if (companyCode !== "*") {
checkSql += ` AND company_code = $2`;
checkParams.push(companyCode);
}
const existing = await queryOne(checkSql, checkParams);
if (!existing) {
return res.status(404).json({
success: false,
message: "자동 입력 그룹을 찾을 수 없습니다.",
});
}
// 그룹 업데이트
const updateSql = `
UPDATE cascading_auto_fill_group SET
group_name = COALESCE($1, group_name),
description = COALESCE($2, description),
master_table = COALESCE($3, master_table),
master_value_column = COALESCE($4, master_value_column),
master_label_column = COALESCE($5, master_label_column),
is_active = COALESCE($6, is_active),
updated_date = CURRENT_TIMESTAMP
WHERE group_code = $7 AND company_code = $8
RETURNING *
`;
const updateResult = await queryOne(updateSql, [
groupName,
description,
masterTable,
masterValueColumn,
masterLabelColumn,
isActive,
groupCode,
existing.company_code,
]);
// 매핑 업데이트 (전체 교체 방식)
if (mappings !== undefined) {
// 기존 매핑 삭제
await query(
`DELETE FROM cascading_auto_fill_mapping WHERE group_code = $1 AND company_code = $2`,
[groupCode, existing.company_code]
);
// 새 매핑 추가
for (let i = 0; i < mappings.length; i++) {
const m = mappings[i];
await query(
`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 ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
groupCode,
existing.company_code,
m.sourceColumn,
m.targetField,
m.targetLabel || null,
m.isEditable || "Y",
m.isRequired || "N",
m.defaultValue || null,
m.sortOrder || i + 1,
]
);
}
}
logger.info("자동 입력 그룹 수정", { groupCode, companyCode, userId });
res.json({
success: true,
message: "자동 입력 그룹이 수정되었습니다.",
data: updateResult,
});
} catch (error: any) {
logger.error("자동 입력 그룹 수정 실패", { error: error.message });
res.status(500).json({
success: false,
message: "자동 입력 그룹 수정에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const deleteAutoFillGroup = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { groupCode } = req.params;
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "system";
let deleteSql = `DELETE FROM cascading_auto_fill_group WHERE group_code = $1`;
const deleteParams: any[] = [groupCode];
if (companyCode !== "*") {
deleteSql += ` AND company_code = $2`;
deleteParams.push(companyCode);
}
deleteSql += ` RETURNING group_code`;
const result = await queryOne(deleteSql, deleteParams);
if (!result) {
return res.status(404).json({
success: false,
message: "자동 입력 그룹을 찾을 수 없습니다.",
});
}
logger.info("자동 입력 그룹 삭제", { groupCode, companyCode, userId });
res.json({
success: true,
message: "자동 입력 그룹이 삭제되었습니다.",
});
} catch (error: any) {
logger.error("자동 입력 그룹 삭제 실패", { error: error.message });
res.status(500).json({
success: false,
message: "자동 입력 그룹 삭제에 실패했습니다.",
error: error.message,
});
}
};
// =====================================================
// 자동 입력 데이터 조회 (실제 사용)
// =====================================================
/**
*
*
*/
export const getAutoFillMasterOptions = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { groupCode } = req.params;
const companyCode = req.user?.companyCode || "*";
// 그룹 정보 조회
let groupSql = `SELECT * FROM cascading_auto_fill_group WHERE group_code = $1 AND is_active = 'Y'`;
const groupParams: any[] = [groupCode];
if (companyCode !== "*") {
groupSql += ` AND company_code = $2`;
groupParams.push(companyCode);
}
const group = await queryOne(groupSql, groupParams);
if (!group) {
return res.status(404).json({
success: false,
message: "자동 입력 그룹을 찾을 수 없습니다.",
});
}
// 마스터 테이블에서 옵션 조회
const labelColumn = group.master_label_column || group.master_value_column;
let optionsSql = `
SELECT
${group.master_value_column} as value,
${labelColumn} as label
FROM ${group.master_table}
WHERE 1=1
`;
const optionsParams: any[] = [];
let paramIndex = 1;
// 멀티테넌시 필터 (테이블에 company_code가 있는 경우)
if (companyCode !== "*") {
// company_code 컬럼 존재 여부 확인
const columnCheck = await queryOne(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[group.master_table]
);
if (columnCheck) {
optionsSql += ` AND company_code = $${paramIndex++}`;
optionsParams.push(companyCode);
}
}
optionsSql += ` ORDER BY ${labelColumn}`;
const optionsResult = await query(optionsSql, optionsParams);
logger.info("자동 입력 마스터 옵션 조회", {
groupCode,
count: optionsResult.length,
});
res.json({
success: true,
data: optionsResult,
});
} catch (error: any) {
logger.error("자동 입력 마스터 옵션 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "자동 입력 마스터 옵션 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*
*/
export const getAutoFillData = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { groupCode } = req.params;
const { masterValue } = req.query;
const companyCode = req.user?.companyCode || "*";
if (!masterValue) {
return res.status(400).json({
success: false,
message: "masterValue 파라미터가 필요합니다.",
});
}
// 그룹 정보 조회
let groupSql = `SELECT * FROM cascading_auto_fill_group WHERE group_code = $1 AND is_active = 'Y'`;
const groupParams: any[] = [groupCode];
if (companyCode !== "*") {
groupSql += ` AND company_code = $2`;
groupParams.push(companyCode);
}
const group = await queryOne(groupSql, groupParams);
if (!group) {
return res.status(404).json({
success: false,
message: "자동 입력 그룹을 찾을 수 없습니다.",
});
}
// 매핑 정보 조회
const mappingSql = `
SELECT * FROM cascading_auto_fill_mapping
WHERE group_code = $1 AND company_code = $2
ORDER BY sort_order
`;
const mappings = await query(mappingSql, [groupCode, group.company_code]);
if (mappings.length === 0) {
return res.json({
success: true,
data: {},
mappings: [],
});
}
// 마스터 테이블에서 데이터 조회
const sourceColumns = mappings.map((m: any) => m.source_column).join(", ");
let dataSql = `
SELECT ${sourceColumns}
FROM ${group.master_table}
WHERE ${group.master_value_column} = $1
`;
const dataParams: any[] = [masterValue];
let paramIndex = 2;
// 멀티테넌시 필터
if (companyCode !== "*") {
const columnCheck = await queryOne(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[group.master_table]
);
if (columnCheck) {
dataSql += ` AND company_code = $${paramIndex++}`;
dataParams.push(companyCode);
}
}
const dataResult = await queryOne(dataSql, dataParams);
// 결과를 target_field 기준으로 변환
const autoFillData: Record<string, any> = {};
const mappingInfo: any[] = [];
for (const mapping of mappings) {
const sourceValue = dataResult?.[mapping.source_column];
const finalValue =
sourceValue !== null && sourceValue !== undefined
? sourceValue
: mapping.default_value;
autoFillData[mapping.target_field] = finalValue;
mappingInfo.push({
targetField: mapping.target_field,
targetLabel: mapping.target_label,
value: finalValue,
isEditable: mapping.is_editable === "Y",
isRequired: mapping.is_required === "Y",
});
}
logger.info("자동 입력 데이터 조회", {
groupCode,
masterValue,
fieldCount: mappingInfo.length,
});
res.json({
success: true,
data: autoFillData,
mappings: mappingInfo,
});
} catch (error: any) {
logger.error("자동 입력 데이터 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "자동 입력 데이터 조회에 실패했습니다.",
error: error.message,
});
}
};

View File

@ -0,0 +1,562 @@
/**
* (Conditional Cascading)
*
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { query, queryOne } from "../database/db";
import logger from "../utils/logger";
// =====================================================
// 조건부 연쇄 규칙 CRUD
// =====================================================
/**
*
*/
export const getConditions = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const companyCode = req.user?.companyCode || "*";
const { isActive, relationCode, relationType } = req.query;
let sql = `
SELECT * FROM cascading_condition
WHERE 1=1
`;
const params: any[] = [];
let paramIndex = 1;
// 회사 필터
if (companyCode !== "*") {
sql += ` AND company_code = $${paramIndex++}`;
params.push(companyCode);
}
// 활성 상태 필터
if (isActive) {
sql += ` AND is_active = $${paramIndex++}`;
params.push(isActive);
}
// 관계 코드 필터
if (relationCode) {
sql += ` AND relation_code = $${paramIndex++}`;
params.push(relationCode);
}
// 관계 유형 필터 (RELATION / HIERARCHY)
if (relationType) {
sql += ` AND relation_type = $${paramIndex++}`;
params.push(relationType);
}
sql += ` ORDER BY relation_code, priority, condition_name`;
const result = await query(sql, params);
logger.info("조건부 연쇄 규칙 목록 조회", {
count: result.length,
companyCode,
});
res.json({
success: true,
data: result,
});
} catch (error: any) {
console.error("조건부 연쇄 규칙 목록 조회 실패:", error);
logger.error("조건부 연쇄 규칙 목록 조회 실패", {
error: error.message,
stack: error.stack,
});
res.status(500).json({
success: false,
message: "조건부 연쇄 규칙 목록 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const getConditionDetail = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { conditionId } = req.params;
const companyCode = req.user?.companyCode || "*";
let sql = `SELECT * FROM cascading_condition WHERE condition_id = $1`;
const params: any[] = [Number(conditionId)];
if (companyCode !== "*") {
sql += ` AND company_code = $2`;
params.push(companyCode);
}
const result = await queryOne(sql, params);
if (!result) {
return res.status(404).json({
success: false,
message: "조건부 연쇄 규칙을 찾을 수 없습니다.",
});
}
logger.info("조건부 연쇄 규칙 상세 조회", { conditionId, companyCode });
res.json({
success: true,
data: result,
});
} catch (error: any) {
logger.error("조건부 연쇄 규칙 상세 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "조건부 연쇄 규칙 상세 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const createCondition = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const companyCode = req.user?.companyCode || "*";
const {
relationType = "RELATION",
relationCode,
conditionName,
conditionField,
conditionOperator = "EQ",
conditionValue,
filterColumn,
filterValues,
priority = 0,
} = req.body;
// 필수 필드 검증
if (
!relationCode ||
!conditionName ||
!conditionField ||
!conditionValue ||
!filterColumn ||
!filterValues
) {
return res.status(400).json({
success: false,
message:
"필수 필드가 누락되었습니다. (relationCode, conditionName, conditionField, conditionValue, filterColumn, filterValues)",
});
}
const insertSql = `
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 ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'Y', CURRENT_TIMESTAMP)
RETURNING *
`;
const result = await queryOne(insertSql, [
relationType,
relationCode,
conditionName,
conditionField,
conditionOperator,
conditionValue,
filterColumn,
filterValues,
priority,
companyCode,
]);
logger.info("조건부 연쇄 규칙 생성", {
conditionId: result?.condition_id,
relationCode,
companyCode,
});
res.status(201).json({
success: true,
message: "조건부 연쇄 규칙이 생성되었습니다.",
data: result,
});
} catch (error: any) {
logger.error("조건부 연쇄 규칙 생성 실패", { error: error.message });
res.status(500).json({
success: false,
message: "조건부 연쇄 규칙 생성에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const updateCondition = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { conditionId } = req.params;
const companyCode = req.user?.companyCode || "*";
const {
conditionName,
conditionField,
conditionOperator,
conditionValue,
filterColumn,
filterValues,
priority,
isActive,
} = req.body;
// 기존 규칙 확인
let checkSql = `SELECT * FROM cascading_condition WHERE condition_id = $1`;
const checkParams: any[] = [Number(conditionId)];
if (companyCode !== "*") {
checkSql += ` AND company_code = $2`;
checkParams.push(companyCode);
}
const existing = await queryOne(checkSql, checkParams);
if (!existing) {
return res.status(404).json({
success: false,
message: "조건부 연쇄 규칙을 찾을 수 없습니다.",
});
}
const updateSql = `
UPDATE cascading_condition SET
condition_name = COALESCE($1, condition_name),
condition_field = COALESCE($2, condition_field),
condition_operator = COALESCE($3, condition_operator),
condition_value = COALESCE($4, condition_value),
filter_column = COALESCE($5, filter_column),
filter_values = COALESCE($6, filter_values),
priority = COALESCE($7, priority),
is_active = COALESCE($8, is_active),
updated_date = CURRENT_TIMESTAMP
WHERE condition_id = $9
RETURNING *
`;
const result = await queryOne(updateSql, [
conditionName,
conditionField,
conditionOperator,
conditionValue,
filterColumn,
filterValues,
priority,
isActive,
Number(conditionId),
]);
logger.info("조건부 연쇄 규칙 수정", { conditionId, companyCode });
res.json({
success: true,
message: "조건부 연쇄 규칙이 수정되었습니다.",
data: result,
});
} catch (error: any) {
logger.error("조건부 연쇄 규칙 수정 실패", { error: error.message });
res.status(500).json({
success: false,
message: "조건부 연쇄 규칙 수정에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const deleteCondition = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { conditionId } = req.params;
const companyCode = req.user?.companyCode || "*";
let deleteSql = `DELETE FROM cascading_condition WHERE condition_id = $1`;
const deleteParams: any[] = [Number(conditionId)];
if (companyCode !== "*") {
deleteSql += ` AND company_code = $2`;
deleteParams.push(companyCode);
}
deleteSql += ` RETURNING condition_id`;
const result = await queryOne(deleteSql, deleteParams);
if (!result) {
return res.status(404).json({
success: false,
message: "조건부 연쇄 규칙을 찾을 수 없습니다.",
});
}
logger.info("조건부 연쇄 규칙 삭제", { conditionId, companyCode });
res.json({
success: true,
message: "조건부 연쇄 규칙이 삭제되었습니다.",
});
} catch (error: any) {
logger.error("조건부 연쇄 규칙 삭제 실패", { error: error.message });
res.status(500).json({
success: false,
message: "조건부 연쇄 규칙 삭제에 실패했습니다.",
error: error.message,
});
}
};
// =====================================================
// 조건부 필터링 적용 API (실제 사용)
// =====================================================
/**
*
*
*/
export const getFilteredOptions = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { relationCode } = req.params;
const { conditionFieldValue, parentValue } = req.query;
const companyCode = req.user?.companyCode || "*";
// 1. 기본 연쇄 관계 정보 조회
let relationSql = `SELECT * FROM cascading_relation WHERE relation_code = $1 AND is_active = 'Y'`;
const relationParams: any[] = [relationCode];
if (companyCode !== "*") {
relationSql += ` AND company_code = $2`;
relationParams.push(companyCode);
}
const relation = await queryOne(relationSql, relationParams);
if (!relation) {
return res.status(404).json({
success: false,
message: "연쇄 관계를 찾을 수 없습니다.",
});
}
// 2. 해당 관계에 적용되는 조건 규칙 조회
let conditionSql = `
SELECT * FROM cascading_condition
WHERE relation_code = $1 AND is_active = 'Y'
`;
const conditionParams: any[] = [relationCode];
let conditionParamIndex = 2;
if (companyCode !== "*") {
conditionSql += ` AND company_code = $${conditionParamIndex++}`;
conditionParams.push(companyCode);
}
conditionSql += ` ORDER BY priority DESC`;
const conditions = await query(conditionSql, conditionParams);
// 3. 조건에 맞는 규칙 찾기
let matchedCondition: any = null;
if (conditionFieldValue) {
for (const cond of conditions) {
const isMatch = evaluateCondition(
conditionFieldValue as string,
cond.condition_operator,
cond.condition_value
);
if (isMatch) {
matchedCondition = cond;
break; // 우선순위가 높은 첫 번째 매칭 규칙 사용
}
}
}
// 4. 옵션 조회 쿼리 생성
let optionsSql = `
SELECT
${relation.child_value_column} as value,
${relation.child_label_column} as label
FROM ${relation.child_table}
WHERE 1=1
`;
const optionsParams: any[] = [];
let optionsParamIndex = 1;
// 부모 값 필터 (기본 연쇄)
if (parentValue) {
optionsSql += ` AND ${relation.child_filter_column} = $${optionsParamIndex++}`;
optionsParams.push(parentValue);
}
// 조건부 필터 적용
if (matchedCondition) {
const filterValues = matchedCondition.filter_values
.split(",")
.map((v: string) => v.trim());
const placeholders = filterValues
.map((_: any, i: number) => `$${optionsParamIndex + i}`)
.join(",");
optionsSql += ` AND ${matchedCondition.filter_column} IN (${placeholders})`;
optionsParams.push(...filterValues);
optionsParamIndex += filterValues.length;
}
// 멀티테넌시 필터
if (companyCode !== "*") {
const columnCheck = await queryOne(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[relation.child_table]
);
if (columnCheck) {
optionsSql += ` AND company_code = $${optionsParamIndex++}`;
optionsParams.push(companyCode);
}
}
// 정렬
if (relation.child_order_column) {
optionsSql += ` ORDER BY ${relation.child_order_column} ${relation.child_order_direction || "ASC"}`;
} else {
optionsSql += ` ORDER BY ${relation.child_label_column}`;
}
const optionsResult = await query(optionsSql, optionsParams);
logger.info("조건부 필터링 옵션 조회", {
relationCode,
conditionFieldValue,
parentValue,
matchedCondition: matchedCondition?.condition_name,
optionCount: optionsResult.length,
});
res.json({
success: true,
data: optionsResult,
appliedCondition: matchedCondition
? {
conditionId: matchedCondition.condition_id,
conditionName: matchedCondition.condition_name,
}
: null,
});
} catch (error: any) {
logger.error("조건부 필터링 옵션 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "조건부 필터링 옵션 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
function evaluateCondition(
actualValue: string,
operator: string,
expectedValue: string
): boolean {
const actual = actualValue.toLowerCase().trim();
const expected = expectedValue.toLowerCase().trim();
switch (operator.toUpperCase()) {
case "EQ":
case "=":
case "EQUALS":
return actual === expected;
case "NEQ":
case "!=":
case "<>":
case "NOT_EQUALS":
return actual !== expected;
case "CONTAINS":
case "LIKE":
return actual.includes(expected);
case "NOT_CONTAINS":
case "NOT_LIKE":
return !actual.includes(expected);
case "STARTS_WITH":
return actual.startsWith(expected);
case "ENDS_WITH":
return actual.endsWith(expected);
case "IN":
const inValues = expected.split(",").map((v) => v.trim());
return inValues.includes(actual);
case "NOT_IN":
const notInValues = expected.split(",").map((v) => v.trim());
return !notInValues.includes(actual);
case "GT":
case ">":
return parseFloat(actual) > parseFloat(expected);
case "GTE":
case ">=":
return parseFloat(actual) >= parseFloat(expected);
case "LT":
case "<":
return parseFloat(actual) < parseFloat(expected);
case "LTE":
case "<=":
return parseFloat(actual) <= parseFloat(expected);
case "IS_NULL":
case "NULL":
return actual === "" || actual === "null" || actual === "undefined";
case "IS_NOT_NULL":
case "NOT_NULL":
return actual !== "" && actual !== "null" && actual !== "undefined";
default:
logger.warn(`알 수 없는 연산자: ${operator}`);
return false;
}
}

View File

@ -0,0 +1,772 @@
/**
* (Hierarchy)
* > > /
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { query, queryOne } from "../database/db";
import logger from "../utils/logger";
// =====================================================
// 계층 그룹 CRUD
// =====================================================
/**
*
*/
export const getHierarchyGroups = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const companyCode = req.user?.companyCode || "*";
const { isActive, hierarchyType } = req.query;
let sql = `
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
`;
const params: any[] = [];
let paramIndex = 1;
if (companyCode !== "*") {
sql += ` AND g.company_code = $${paramIndex++}`;
params.push(companyCode);
}
if (isActive) {
sql += ` AND g.is_active = $${paramIndex++}`;
params.push(isActive);
}
if (hierarchyType) {
sql += ` AND g.hierarchy_type = $${paramIndex++}`;
params.push(hierarchyType);
}
sql += ` ORDER BY g.group_name`;
const result = await query(sql, params);
logger.info("계층 그룹 목록 조회", { count: result.length, companyCode });
res.json({
success: true,
data: result,
});
} catch (error: any) {
logger.error("계층 그룹 목록 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "계층 그룹 목록 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
* ( )
*/
export const getHierarchyGroupDetail = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { groupCode } = req.params;
const companyCode = req.user?.companyCode || "*";
// 그룹 조회
let groupSql = `SELECT * FROM cascading_hierarchy_group WHERE group_code = $1`;
const groupParams: any[] = [groupCode];
if (companyCode !== "*") {
groupSql += ` AND company_code = $2`;
groupParams.push(companyCode);
}
const group = await queryOne(groupSql, groupParams);
if (!group) {
return res.status(404).json({
success: false,
message: "계층 그룹을 찾을 수 없습니다.",
});
}
// 레벨 조회
let levelSql = `SELECT * FROM cascading_hierarchy_level WHERE group_code = $1`;
const levelParams: any[] = [groupCode];
if (companyCode !== "*") {
levelSql += ` AND company_code = $2`;
levelParams.push(companyCode);
}
levelSql += ` ORDER BY level_order`;
const levels = await query(levelSql, levelParams);
logger.info("계층 그룹 상세 조회", { groupCode, companyCode });
res.json({
success: true,
data: {
...group,
levels: levels,
},
});
} catch (error: any) {
logger.error("계층 그룹 상세 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "계층 그룹 상세 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
const generateHierarchyGroupCode = async (
companyCode: string
): Promise<string> => {
const prefix = "HG";
const result = await queryOne(
`SELECT COUNT(*) as cnt FROM cascading_hierarchy_group WHERE company_code = $1`,
[companyCode]
);
const count = parseInt(result?.cnt || "0", 10) + 1;
const timestamp = Date.now().toString(36).toUpperCase().slice(-4);
return `${prefix}_${timestamp}_${count.toString().padStart(3, "0")}`;
};
/**
*
*/
export const createHierarchyGroup = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "system";
const {
groupName,
description,
hierarchyType = "MULTI_TABLE",
maxLevels,
isFixedLevels = "Y",
// Self-reference 설정
selfRefTable,
selfRefIdColumn,
selfRefParentColumn,
selfRefValueColumn,
selfRefLabelColumn,
selfRefLevelColumn,
selfRefOrderColumn,
// BOM 설정
bomTable,
bomParentColumn,
bomChildColumn,
bomItemTable,
bomItemIdColumn,
bomItemLabelColumn,
bomQtyColumn,
bomLevelColumn,
// 메시지
emptyMessage,
noOptionsMessage,
loadingMessage,
// 레벨 (MULTI_TABLE 타입인 경우)
levels = [],
} = req.body;
// 필수 필드 검증
if (!groupName || !hierarchyType) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다. (groupName, hierarchyType)",
});
}
// 그룹 코드 자동 생성
const groupCode = await generateHierarchyGroupCode(companyCode);
// 그룹 생성
const insertGroupSql = `
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 ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, 'Y', $26, CURRENT_TIMESTAMP)
RETURNING *
`;
const group = await queryOne(insertGroupSql, [
groupCode,
groupName,
description || null,
hierarchyType,
maxLevels || null,
isFixedLevels,
selfRefTable || null,
selfRefIdColumn || null,
selfRefParentColumn || null,
selfRefValueColumn || null,
selfRefLabelColumn || null,
selfRefLevelColumn || null,
selfRefOrderColumn || null,
bomTable || null,
bomParentColumn || null,
bomChildColumn || null,
bomItemTable || null,
bomItemIdColumn || null,
bomItemLabelColumn || null,
bomQtyColumn || null,
bomLevelColumn || null,
emptyMessage || "선택해주세요",
noOptionsMessage || "옵션이 없습니다",
loadingMessage || "로딩 중...",
companyCode,
userId,
]);
// 레벨 생성 (MULTI_TABLE 타입인 경우)
if (hierarchyType === "MULTI_TABLE" && levels.length > 0) {
for (const level of levels) {
await query(
`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 ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, 'Y', CURRENT_TIMESTAMP)`,
[
groupCode,
companyCode,
level.levelOrder,
level.levelName,
level.levelCode || null,
level.tableName,
level.valueColumn,
level.labelColumn,
level.parentKeyColumn || null,
level.filterColumn || null,
level.filterValue || null,
level.orderColumn || null,
level.orderDirection || "ASC",
level.placeholder || `${level.levelName} 선택`,
level.isRequired || "Y",
level.isSearchable || "N",
]
);
}
}
logger.info("계층 그룹 생성", { groupCode, hierarchyType, companyCode });
res.status(201).json({
success: true,
message: "계층 그룹이 생성되었습니다.",
data: group,
});
} catch (error: any) {
logger.error("계층 그룹 생성 실패", { error: error.message });
res.status(500).json({
success: false,
message: "계층 그룹 생성에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const updateHierarchyGroup = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { groupCode } = req.params;
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "system";
const {
groupName,
description,
maxLevels,
isFixedLevels,
emptyMessage,
noOptionsMessage,
loadingMessage,
isActive,
} = req.body;
// 기존 그룹 확인
let checkSql = `SELECT * FROM cascading_hierarchy_group WHERE group_code = $1`;
const checkParams: any[] = [groupCode];
if (companyCode !== "*") {
checkSql += ` AND company_code = $2`;
checkParams.push(companyCode);
}
const existing = await queryOne(checkSql, checkParams);
if (!existing) {
return res.status(404).json({
success: false,
message: "계층 그룹을 찾을 수 없습니다.",
});
}
const updateSql = `
UPDATE cascading_hierarchy_group SET
group_name = COALESCE($1, group_name),
description = COALESCE($2, description),
max_levels = COALESCE($3, max_levels),
is_fixed_levels = COALESCE($4, is_fixed_levels),
empty_message = COALESCE($5, empty_message),
no_options_message = COALESCE($6, no_options_message),
loading_message = COALESCE($7, loading_message),
is_active = COALESCE($8, is_active),
updated_by = $9,
updated_date = CURRENT_TIMESTAMP
WHERE group_code = $10 AND company_code = $11
RETURNING *
`;
const result = await queryOne(updateSql, [
groupName,
description,
maxLevels,
isFixedLevels,
emptyMessage,
noOptionsMessage,
loadingMessage,
isActive,
userId,
groupCode,
existing.company_code,
]);
logger.info("계층 그룹 수정", { groupCode, companyCode });
res.json({
success: true,
message: "계층 그룹이 수정되었습니다.",
data: result,
});
} catch (error: any) {
logger.error("계층 그룹 수정 실패", { error: error.message });
res.status(500).json({
success: false,
message: "계층 그룹 수정에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const deleteHierarchyGroup = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { groupCode } = req.params;
const companyCode = req.user?.companyCode || "*";
// 레벨 먼저 삭제
let deleteLevelsSql = `DELETE FROM cascading_hierarchy_level WHERE group_code = $1`;
const levelParams: any[] = [groupCode];
if (companyCode !== "*") {
deleteLevelsSql += ` AND company_code = $2`;
levelParams.push(companyCode);
}
await query(deleteLevelsSql, levelParams);
// 그룹 삭제
let deleteGroupSql = `DELETE FROM cascading_hierarchy_group WHERE group_code = $1`;
const groupParams: any[] = [groupCode];
if (companyCode !== "*") {
deleteGroupSql += ` AND company_code = $2`;
groupParams.push(companyCode);
}
deleteGroupSql += ` RETURNING group_code`;
const result = await queryOne(deleteGroupSql, groupParams);
if (!result) {
return res.status(404).json({
success: false,
message: "계층 그룹을 찾을 수 없습니다.",
});
}
logger.info("계층 그룹 삭제", { groupCode, companyCode });
res.json({
success: true,
message: "계층 그룹이 삭제되었습니다.",
});
} catch (error: any) {
logger.error("계층 그룹 삭제 실패", { error: error.message });
res.status(500).json({
success: false,
message: "계층 그룹 삭제에 실패했습니다.",
error: error.message,
});
}
};
// =====================================================
// 계층 레벨 관리
// =====================================================
/**
*
*/
export const addLevel = async (req: AuthenticatedRequest, res: Response) => {
try {
const { groupCode } = req.params;
const companyCode = req.user?.companyCode || "*";
const {
levelOrder,
levelName,
levelCode,
tableName,
valueColumn,
labelColumn,
parentKeyColumn,
filterColumn,
filterValue,
orderColumn,
orderDirection = "ASC",
placeholder,
isRequired = "Y",
isSearchable = "N",
} = req.body;
// 그룹 존재 확인
const groupCheck = await queryOne(
`SELECT * FROM cascading_hierarchy_group WHERE group_code = $1 AND (company_code = $2 OR $2 = '*')`,
[groupCode, companyCode]
);
if (!groupCheck) {
return res.status(404).json({
success: false,
message: "계층 그룹을 찾을 수 없습니다.",
});
}
const insertSql = `
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 ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, 'Y', CURRENT_TIMESTAMP)
RETURNING *
`;
const result = await queryOne(insertSql, [
groupCode,
groupCheck.company_code,
levelOrder,
levelName,
levelCode || null,
tableName,
valueColumn,
labelColumn,
parentKeyColumn || null,
filterColumn || null,
filterValue || null,
orderColumn || null,
orderDirection,
placeholder || `${levelName} 선택`,
isRequired,
isSearchable,
]);
logger.info("계층 레벨 추가", { groupCode, levelOrder, levelName });
res.status(201).json({
success: true,
message: "레벨이 추가되었습니다.",
data: result,
});
} catch (error: any) {
logger.error("계층 레벨 추가 실패", { error: error.message });
res.status(500).json({
success: false,
message: "레벨 추가에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const updateLevel = async (req: AuthenticatedRequest, res: Response) => {
try {
const { levelId } = req.params;
const companyCode = req.user?.companyCode || "*";
const {
levelName,
tableName,
valueColumn,
labelColumn,
parentKeyColumn,
filterColumn,
filterValue,
orderColumn,
orderDirection,
placeholder,
isRequired,
isSearchable,
isActive,
} = req.body;
let checkSql = `SELECT * FROM cascading_hierarchy_level WHERE level_id = $1`;
const checkParams: any[] = [Number(levelId)];
if (companyCode !== "*") {
checkSql += ` AND company_code = $2`;
checkParams.push(companyCode);
}
const existing = await queryOne(checkSql, checkParams);
if (!existing) {
return res.status(404).json({
success: false,
message: "레벨을 찾을 수 없습니다.",
});
}
const updateSql = `
UPDATE cascading_hierarchy_level SET
level_name = COALESCE($1, level_name),
table_name = COALESCE($2, table_name),
value_column = COALESCE($3, value_column),
label_column = COALESCE($4, label_column),
parent_key_column = COALESCE($5, parent_key_column),
filter_column = COALESCE($6, filter_column),
filter_value = COALESCE($7, filter_value),
order_column = COALESCE($8, order_column),
order_direction = COALESCE($9, order_direction),
placeholder = COALESCE($10, placeholder),
is_required = COALESCE($11, is_required),
is_searchable = COALESCE($12, is_searchable),
is_active = COALESCE($13, is_active),
updated_date = CURRENT_TIMESTAMP
WHERE level_id = $14
RETURNING *
`;
const result = await queryOne(updateSql, [
levelName,
tableName,
valueColumn,
labelColumn,
parentKeyColumn,
filterColumn,
filterValue,
orderColumn,
orderDirection,
placeholder,
isRequired,
isSearchable,
isActive,
Number(levelId),
]);
logger.info("계층 레벨 수정", { levelId });
res.json({
success: true,
message: "레벨이 수정되었습니다.",
data: result,
});
} catch (error: any) {
logger.error("계층 레벨 수정 실패", { error: error.message });
res.status(500).json({
success: false,
message: "레벨 수정에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const deleteLevel = async (req: AuthenticatedRequest, res: Response) => {
try {
const { levelId } = req.params;
const companyCode = req.user?.companyCode || "*";
let deleteSql = `DELETE FROM cascading_hierarchy_level WHERE level_id = $1`;
const deleteParams: any[] = [Number(levelId)];
if (companyCode !== "*") {
deleteSql += ` AND company_code = $2`;
deleteParams.push(companyCode);
}
deleteSql += ` RETURNING level_id`;
const result = await queryOne(deleteSql, deleteParams);
if (!result) {
return res.status(404).json({
success: false,
message: "레벨을 찾을 수 없습니다.",
});
}
logger.info("계층 레벨 삭제", { levelId });
res.json({
success: true,
message: "레벨이 삭제되었습니다.",
});
} catch (error: any) {
logger.error("계층 레벨 삭제 실패", { error: error.message });
res.status(500).json({
success: false,
message: "레벨 삭제에 실패했습니다.",
error: error.message,
});
}
};
// =====================================================
// 계층 옵션 조회 API (실제 사용)
// =====================================================
/**
*
*/
export const getLevelOptions = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { groupCode, levelOrder } = req.params;
const { parentValue } = req.query;
const companyCode = req.user?.companyCode || "*";
// 레벨 정보 조회
let levelSql = `
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 = $1 AND l.level_order = $2 AND l.is_active = 'Y'
`;
const levelParams: any[] = [groupCode, Number(levelOrder)];
if (companyCode !== "*") {
levelSql += ` AND l.company_code = $3`;
levelParams.push(companyCode);
}
const level = await queryOne(levelSql, levelParams);
if (!level) {
return res.status(404).json({
success: false,
message: "레벨을 찾을 수 없습니다.",
});
}
// 옵션 조회
let optionsSql = `
SELECT
${level.value_column} as value,
${level.label_column} as label
FROM ${level.table_name}
WHERE 1=1
`;
const optionsParams: any[] = [];
let optionsParamIndex = 1;
// 부모 값 필터 (레벨 2 이상)
if (level.parent_key_column && parentValue) {
optionsSql += ` AND ${level.parent_key_column} = $${optionsParamIndex++}`;
optionsParams.push(parentValue);
}
// 고정 필터
if (level.filter_column && level.filter_value) {
optionsSql += ` AND ${level.filter_column} = $${optionsParamIndex++}`;
optionsParams.push(level.filter_value);
}
// 멀티테넌시 필터
if (companyCode !== "*") {
const columnCheck = await queryOne(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[level.table_name]
);
if (columnCheck) {
optionsSql += ` AND company_code = $${optionsParamIndex++}`;
optionsParams.push(companyCode);
}
}
// 정렬
if (level.order_column) {
optionsSql += ` ORDER BY ${level.order_column} ${level.order_direction || "ASC"}`;
} else {
optionsSql += ` ORDER BY ${level.label_column}`;
}
const optionsResult = await query(optionsSql, optionsParams);
logger.info("계층 레벨 옵션 조회", {
groupCode,
levelOrder,
parentValue,
optionCount: optionsResult.length,
});
res.json({
success: true,
data: optionsResult,
levelInfo: {
levelId: level.level_id,
levelName: level.level_name,
placeholder: level.placeholder,
isRequired: level.is_required,
isSearchable: level.is_searchable,
},
});
} catch (error: any) {
logger.error("계층 레벨 옵션 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "옵션 조회에 실패했습니다.",
error: error.message,
});
}
};

View File

@ -0,0 +1,537 @@
/**
* (Mutual Exclusion)
*
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { query, queryOne } from "../database/db";
import logger from "../utils/logger";
// =====================================================
// 상호 배제 규칙 CRUD
// =====================================================
/**
*
*/
export const getExclusions = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const companyCode = req.user?.companyCode || "*";
const { isActive } = req.query;
let sql = `
SELECT * FROM cascading_mutual_exclusion
WHERE 1=1
`;
const params: any[] = [];
let paramIndex = 1;
// 회사 필터
if (companyCode !== "*") {
sql += ` AND company_code = $${paramIndex++}`;
params.push(companyCode);
}
// 활성 상태 필터
if (isActive) {
sql += ` AND is_active = $${paramIndex++}`;
params.push(isActive);
}
sql += ` ORDER BY exclusion_name`;
const result = await query(sql, params);
logger.info("상호 배제 규칙 목록 조회", {
count: result.length,
companyCode,
});
res.json({
success: true,
data: result,
});
} catch (error: any) {
logger.error("상호 배제 규칙 목록 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "상호 배제 규칙 목록 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const getExclusionDetail = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { exclusionId } = req.params;
const companyCode = req.user?.companyCode || "*";
let sql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_id = $1`;
const params: any[] = [Number(exclusionId)];
if (companyCode !== "*") {
sql += ` AND company_code = $2`;
params.push(companyCode);
}
const result = await queryOne(sql, params);
if (!result) {
return res.status(404).json({
success: false,
message: "상호 배제 규칙을 찾을 수 없습니다.",
});
}
logger.info("상호 배제 규칙 상세 조회", { exclusionId, companyCode });
res.json({
success: true,
data: result,
});
} catch (error: any) {
logger.error("상호 배제 규칙 상세 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "상호 배제 규칙 상세 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
const generateExclusionCode = async (companyCode: string): Promise<string> => {
const prefix = "EX";
const result = await queryOne(
`SELECT COUNT(*) as cnt FROM cascading_mutual_exclusion WHERE company_code = $1`,
[companyCode]
);
const count = parseInt(result?.cnt || "0", 10) + 1;
const timestamp = Date.now().toString(36).toUpperCase().slice(-4);
return `${prefix}_${timestamp}_${count.toString().padStart(3, "0")}`;
};
/**
*
*/
export const createExclusion = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const companyCode = req.user?.companyCode || "*";
const {
exclusionName,
fieldNames, // 콤마로 구분된 필드명 (예: "source_warehouse,target_warehouse")
sourceTable,
valueColumn,
labelColumn,
exclusionType = "SAME_VALUE",
errorMessage = "동일한 값을 선택할 수 없습니다",
} = req.body;
// 필수 필드 검증
if (!exclusionName || !fieldNames || !sourceTable || !valueColumn) {
return res.status(400).json({
success: false,
message:
"필수 필드가 누락되었습니다. (exclusionName, fieldNames, sourceTable, valueColumn)",
});
}
// 배제 코드 자동 생성
const exclusionCode = await generateExclusionCode(companyCode);
// 중복 체크 (생략 - 자동 생성이므로 중복 불가)
const existingCheck = await queryOne(
`SELECT exclusion_id FROM cascading_mutual_exclusion WHERE exclusion_code = $1 AND company_code = $2`,
[exclusionCode, companyCode]
);
if (existingCheck) {
return res.status(409).json({
success: false,
message: "이미 존재하는 배제 코드입니다.",
});
}
const insertSql = `
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 ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'Y', CURRENT_TIMESTAMP)
RETURNING *
`;
const result = await queryOne(insertSql, [
exclusionCode,
exclusionName,
fieldNames,
sourceTable,
valueColumn,
labelColumn || null,
exclusionType,
errorMessage,
companyCode,
]);
logger.info("상호 배제 규칙 생성", { exclusionCode, companyCode });
res.status(201).json({
success: true,
message: "상호 배제 규칙이 생성되었습니다.",
data: result,
});
} catch (error: any) {
logger.error("상호 배제 규칙 생성 실패", { error: error.message });
res.status(500).json({
success: false,
message: "상호 배제 규칙 생성에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const updateExclusion = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { exclusionId } = req.params;
const companyCode = req.user?.companyCode || "*";
const {
exclusionName,
fieldNames,
sourceTable,
valueColumn,
labelColumn,
exclusionType,
errorMessage,
isActive,
} = req.body;
// 기존 규칙 확인
let checkSql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_id = $1`;
const checkParams: any[] = [Number(exclusionId)];
if (companyCode !== "*") {
checkSql += ` AND company_code = $2`;
checkParams.push(companyCode);
}
const existing = await queryOne(checkSql, checkParams);
if (!existing) {
return res.status(404).json({
success: false,
message: "상호 배제 규칙을 찾을 수 없습니다.",
});
}
const updateSql = `
UPDATE cascading_mutual_exclusion SET
exclusion_name = COALESCE($1, exclusion_name),
field_names = COALESCE($2, field_names),
source_table = COALESCE($3, source_table),
value_column = COALESCE($4, value_column),
label_column = COALESCE($5, label_column),
exclusion_type = COALESCE($6, exclusion_type),
error_message = COALESCE($7, error_message),
is_active = COALESCE($8, is_active)
WHERE exclusion_id = $9
RETURNING *
`;
const result = await queryOne(updateSql, [
exclusionName,
fieldNames,
sourceTable,
valueColumn,
labelColumn,
exclusionType,
errorMessage,
isActive,
Number(exclusionId),
]);
logger.info("상호 배제 규칙 수정", { exclusionId, companyCode });
res.json({
success: true,
message: "상호 배제 규칙이 수정되었습니다.",
data: result,
});
} catch (error: any) {
logger.error("상호 배제 규칙 수정 실패", { error: error.message });
res.status(500).json({
success: false,
message: "상호 배제 규칙 수정에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const deleteExclusion = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { exclusionId } = req.params;
const companyCode = req.user?.companyCode || "*";
let deleteSql = `DELETE FROM cascading_mutual_exclusion WHERE exclusion_id = $1`;
const deleteParams: any[] = [Number(exclusionId)];
if (companyCode !== "*") {
deleteSql += ` AND company_code = $2`;
deleteParams.push(companyCode);
}
deleteSql += ` RETURNING exclusion_id`;
const result = await queryOne(deleteSql, deleteParams);
if (!result) {
return res.status(404).json({
success: false,
message: "상호 배제 규칙을 찾을 수 없습니다.",
});
}
logger.info("상호 배제 규칙 삭제", { exclusionId, companyCode });
res.json({
success: true,
message: "상호 배제 규칙이 삭제되었습니다.",
});
} catch (error: any) {
logger.error("상호 배제 규칙 삭제 실패", { error: error.message });
res.status(500).json({
success: false,
message: "상호 배제 규칙 삭제에 실패했습니다.",
error: error.message,
});
}
};
// =====================================================
// 상호 배제 검증 API (실제 사용)
// =====================================================
/**
*
*
*/
export const validateExclusion = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { exclusionCode } = req.params;
const { fieldValues } = req.body; // { "source_warehouse": "WH001", "target_warehouse": "WH002" }
const companyCode = req.user?.companyCode || "*";
// 배제 규칙 조회
let exclusionSql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_code = $1 AND is_active = 'Y'`;
const exclusionParams: any[] = [exclusionCode];
if (companyCode !== "*") {
exclusionSql += ` AND company_code = $2`;
exclusionParams.push(companyCode);
}
const exclusion = await queryOne(exclusionSql, exclusionParams);
if (!exclusion) {
return res.status(404).json({
success: false,
message: "상호 배제 규칙을 찾을 수 없습니다.",
});
}
// 필드명 파싱
const fields = exclusion.field_names
.split(",")
.map((f: string) => f.trim());
// 필드 값 수집
const values: string[] = [];
for (const field of fields) {
if (fieldValues[field]) {
values.push(fieldValues[field]);
}
}
// 상호 배제 검증
let isValid = true;
let errorMessage = null;
let conflictingFields: string[] = [];
if (exclusion.exclusion_type === "SAME_VALUE") {
// 같은 값이 있는지 확인
const uniqueValues = new Set(values);
if (uniqueValues.size !== values.length) {
isValid = false;
errorMessage = exclusion.error_message;
// 충돌하는 필드 찾기
const valueCounts: Record<string, string[]> = {};
for (const field of fields) {
const val = fieldValues[field];
if (val) {
if (!valueCounts[val]) {
valueCounts[val] = [];
}
valueCounts[val].push(field);
}
}
for (const [, fieldList] of Object.entries(valueCounts)) {
if (fieldList.length > 1) {
conflictingFields = fieldList;
break;
}
}
}
}
logger.info("상호 배제 검증", {
exclusionCode,
isValid,
fieldValues,
});
res.json({
success: true,
data: {
isValid,
errorMessage: isValid ? null : errorMessage,
conflictingFields,
},
});
} catch (error: any) {
logger.error("상호 배제 검증 실패", { error: error.message });
res.status(500).json({
success: false,
message: "상호 배제 검증에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*
*/
export const getExcludedOptions = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { exclusionCode } = req.params;
const { currentField, selectedValues } = req.query; // selectedValues: 이미 선택된 값들 (콤마 구분)
const companyCode = req.user?.companyCode || "*";
// 배제 규칙 조회
let exclusionSql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_code = $1 AND is_active = 'Y'`;
const exclusionParams: any[] = [exclusionCode];
if (companyCode !== "*") {
exclusionSql += ` AND company_code = $2`;
exclusionParams.push(companyCode);
}
const exclusion = await queryOne(exclusionSql, exclusionParams);
if (!exclusion) {
return res.status(404).json({
success: false,
message: "상호 배제 규칙을 찾을 수 없습니다.",
});
}
// 옵션 조회
const labelColumn = exclusion.label_column || exclusion.value_column;
let optionsSql = `
SELECT
${exclusion.value_column} as value,
${labelColumn} as label
FROM ${exclusion.source_table}
WHERE 1=1
`;
const optionsParams: any[] = [];
let optionsParamIndex = 1;
// 멀티테넌시 필터
if (companyCode !== "*") {
const columnCheck = await queryOne(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[exclusion.source_table]
);
if (columnCheck) {
optionsSql += ` AND company_code = $${optionsParamIndex++}`;
optionsParams.push(companyCode);
}
}
// 이미 선택된 값 제외
if (selectedValues) {
const excludeValues = (selectedValues as string)
.split(",")
.map((v) => v.trim())
.filter((v) => v);
if (excludeValues.length > 0) {
const placeholders = excludeValues
.map((_, i) => `$${optionsParamIndex + i}`)
.join(",");
optionsSql += ` AND ${exclusion.value_column} NOT IN (${placeholders})`;
optionsParams.push(...excludeValues);
}
}
optionsSql += ` ORDER BY ${labelColumn}`;
const optionsResult = await query(optionsSql, optionsParams);
logger.info("상호 배제 옵션 조회", {
exclusionCode,
currentField,
excludedCount: (selectedValues as string)?.split(",").length || 0,
optionCount: optionsResult.length,
});
res.json({
success: true,
data: optionsResult,
});
} catch (error: any) {
logger.error("상호 배제 옵션 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "상호 배제 옵션 조회에 실패했습니다.",
error: error.message,
});
}
};

View File

@ -0,0 +1,798 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
const pool = getPool();
/**
*
*/
export const getCascadingRelations = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const companyCode = req.user?.companyCode || "*";
const { isActive } = req.query;
let query = `
SELECT
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
FROM cascading_relation
WHERE 1=1
`;
const params: any[] = [];
let paramIndex = 1;
// 멀티테넌시 필터링
// - 최고 관리자(company_code = "*"): 모든 데이터 조회 가능
// - 일반 회사: 자기 회사 데이터만 조회 (공통 데이터는 조회 불가)
if (companyCode !== "*") {
query += ` AND company_code = $${paramIndex}`;
params.push(companyCode);
paramIndex++;
}
// 활성 상태 필터링
if (isActive !== undefined) {
query += ` AND is_active = $${paramIndex}`;
params.push(isActive);
paramIndex++;
}
query += ` ORDER BY relation_name ASC`;
const result = await pool.query(query, params);
logger.info("연쇄 관계 목록 조회", {
companyCode,
count: result.rowCount,
});
return res.json({
success: true,
data: result.rows,
});
} catch (error: any) {
logger.error("연쇄 관계 목록 조회 실패", { error: error.message });
return res.status(500).json({
success: false,
message: "연쇄 관계 목록 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const getCascadingRelationById = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { id } = req.params;
const companyCode = req.user?.companyCode || "*";
let query = `
SELECT
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
FROM cascading_relation
WHERE relation_id = $1
`;
const params: any[] = [id];
// 멀티테넌시 필터링 (company_code = "*"는 최고 관리자 전용)
if (companyCode !== "*") {
query += ` AND company_code = $2`;
params.push(companyCode);
}
const result = await pool.query(query, params);
if (result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "연쇄 관계를 찾을 수 없습니다.",
});
}
return res.json({
success: true,
data: result.rows[0],
});
} catch (error: any) {
logger.error("연쇄 관계 상세 조회 실패", { error: error.message });
return res.status(500).json({
success: false,
message: "연쇄 관계 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const getCascadingRelationByCode = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { code } = req.params;
const companyCode = req.user?.companyCode || "*";
let query = `
SELECT
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
FROM cascading_relation
WHERE relation_code = $1
AND is_active = 'Y'
`;
const params: any[] = [code];
// 멀티테넌시 필터링 (company_code = "*"는 최고 관리자 전용)
if (companyCode !== "*") {
query += ` AND company_code = $2`;
params.push(companyCode);
}
query += ` LIMIT 1`;
const result = await pool.query(query, params);
if (result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "연쇄 관계를 찾을 수 없습니다.",
});
}
return res.json({
success: true,
data: result.rows[0],
});
} catch (error: any) {
logger.error("연쇄 관계 코드 조회 실패", { error: error.message });
return res.status(500).json({
success: false,
message: "연쇄 관계 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const createCascadingRelation = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "system";
const {
relationCode,
relationName,
description,
parentTable,
parentValueColumn,
parentLabelColumn,
childTable,
childFilterColumn,
childValueColumn,
childLabelColumn,
childOrderColumn,
childOrderDirection,
emptyParentMessage,
noOptionsMessage,
loadingMessage,
clearOnParentChange,
} = req.body;
// 필수 필드 검증
if (
!relationCode ||
!relationName ||
!parentTable ||
!parentValueColumn ||
!childTable ||
!childFilterColumn ||
!childValueColumn ||
!childLabelColumn
) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다.",
});
}
// 중복 코드 체크
const duplicateCheck = await pool.query(
`SELECT relation_id FROM cascading_relation
WHERE relation_code = $1 AND company_code = $2`,
[relationCode, companyCode]
);
if (duplicateCheck.rowCount && duplicateCheck.rowCount > 0) {
return res.status(400).json({
success: false,
message: "이미 존재하는 관계 코드입니다.",
});
}
const query = `
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 ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, 'Y', $18, CURRENT_TIMESTAMP)
RETURNING *
`;
const result = await pool.query(query, [
relationCode,
relationName,
description || null,
parentTable,
parentValueColumn,
parentLabelColumn || null,
childTable,
childFilterColumn,
childValueColumn,
childLabelColumn,
childOrderColumn || null,
childOrderDirection || "ASC",
emptyParentMessage || "상위 항목을 먼저 선택하세요",
noOptionsMessage || "선택 가능한 항목이 없습니다",
loadingMessage || "로딩 중...",
clearOnParentChange !== false ? "Y" : "N",
companyCode,
userId,
]);
logger.info("연쇄 관계 생성", {
relationId: result.rows[0].relation_id,
relationCode,
companyCode,
userId,
});
return res.status(201).json({
success: true,
data: result.rows[0],
message: "연쇄 관계가 생성되었습니다.",
});
} catch (error: any) {
logger.error("연쇄 관계 생성 실패", { error: error.message });
return res.status(500).json({
success: false,
message: "연쇄 관계 생성에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const updateCascadingRelation = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { id } = req.params;
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "system";
const {
relationName,
description,
parentTable,
parentValueColumn,
parentLabelColumn,
childTable,
childFilterColumn,
childValueColumn,
childLabelColumn,
childOrderColumn,
childOrderDirection,
emptyParentMessage,
noOptionsMessage,
loadingMessage,
clearOnParentChange,
isActive,
} = req.body;
// 권한 체크
const existingCheck = await pool.query(
`SELECT relation_id, company_code FROM cascading_relation WHERE relation_id = $1`,
[id]
);
if (existingCheck.rowCount === 0) {
return res.status(404).json({
success: false,
message: "연쇄 관계를 찾을 수 없습니다.",
});
}
// 다른 회사의 데이터는 수정 불가 (최고 관리자 제외)
const existingCompanyCode = existingCheck.rows[0].company_code;
if (
companyCode !== "*" &&
existingCompanyCode !== companyCode &&
existingCompanyCode !== "*"
) {
return res.status(403).json({
success: false,
message: "수정 권한이 없습니다.",
});
}
const query = `
UPDATE cascading_relation SET
relation_name = COALESCE($1, relation_name),
description = COALESCE($2, description),
parent_table = COALESCE($3, parent_table),
parent_value_column = COALESCE($4, parent_value_column),
parent_label_column = COALESCE($5, parent_label_column),
child_table = COALESCE($6, child_table),
child_filter_column = COALESCE($7, child_filter_column),
child_value_column = COALESCE($8, child_value_column),
child_label_column = COALESCE($9, child_label_column),
child_order_column = COALESCE($10, child_order_column),
child_order_direction = COALESCE($11, child_order_direction),
empty_parent_message = COALESCE($12, empty_parent_message),
no_options_message = COALESCE($13, no_options_message),
loading_message = COALESCE($14, loading_message),
clear_on_parent_change = COALESCE($15, clear_on_parent_change),
is_active = COALESCE($16, is_active),
updated_by = $17,
updated_date = CURRENT_TIMESTAMP
WHERE relation_id = $18
RETURNING *
`;
const result = await pool.query(query, [
relationName,
description,
parentTable,
parentValueColumn,
parentLabelColumn,
childTable,
childFilterColumn,
childValueColumn,
childLabelColumn,
childOrderColumn,
childOrderDirection,
emptyParentMessage,
noOptionsMessage,
loadingMessage,
clearOnParentChange !== undefined
? clearOnParentChange
? "Y"
: "N"
: null,
isActive !== undefined ? (isActive ? "Y" : "N") : null,
userId,
id,
]);
logger.info("연쇄 관계 수정", {
relationId: id,
companyCode,
userId,
});
return res.json({
success: true,
data: result.rows[0],
message: "연쇄 관계가 수정되었습니다.",
});
} catch (error: any) {
logger.error("연쇄 관계 수정 실패", { error: error.message });
return res.status(500).json({
success: false,
message: "연쇄 관계 수정에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const deleteCascadingRelation = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { id } = req.params;
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "system";
// 권한 체크
const existingCheck = await pool.query(
`SELECT relation_id, company_code FROM cascading_relation WHERE relation_id = $1`,
[id]
);
if (existingCheck.rowCount === 0) {
return res.status(404).json({
success: false,
message: "연쇄 관계를 찾을 수 없습니다.",
});
}
// 다른 회사의 데이터는 삭제 불가 (최고 관리자 제외)
const existingCompanyCode = existingCheck.rows[0].company_code;
if (
companyCode !== "*" &&
existingCompanyCode !== companyCode &&
existingCompanyCode !== "*"
) {
return res.status(403).json({
success: false,
message: "삭제 권한이 없습니다.",
});
}
// 소프트 삭제 (is_active = 'N')
await pool.query(
`UPDATE cascading_relation SET is_active = 'N', updated_by = $1, updated_date = CURRENT_TIMESTAMP WHERE relation_id = $2`,
[userId, id]
);
logger.info("연쇄 관계 삭제", {
relationId: id,
companyCode,
userId,
});
return res.json({
success: true,
message: "연쇄 관계가 삭제되었습니다.",
});
} catch (error: any) {
logger.error("연쇄 관계 삭제 실패", { error: error.message });
return res.status(500).json({
success: false,
message: "연쇄 관계 삭제에 실패했습니다.",
error: error.message,
});
}
};
/**
* 🆕 ( )
* parent_table에서 .
*/
export const getParentOptions = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { code } = req.params;
const companyCode = req.user?.companyCode || "*";
// 관계 정보 조회
let relationQuery = `
SELECT
parent_table,
parent_value_column,
parent_label_column
FROM cascading_relation
WHERE relation_code = $1
AND is_active = 'Y'
`;
const relationParams: any[] = [code];
// 멀티테넌시 필터링 (company_code = "*"는 최고 관리자 전용)
if (companyCode !== "*") {
relationQuery += ` AND company_code = $2`;
relationParams.push(companyCode);
}
relationQuery += ` LIMIT 1`;
const relationResult = await pool.query(relationQuery, relationParams);
if (relationResult.rowCount === 0) {
return res.status(404).json({
success: false,
message: "연쇄 관계를 찾을 수 없습니다.",
});
}
const relation = relationResult.rows[0];
// 라벨 컬럼이 없으면 값 컬럼 사용
const labelColumn =
relation.parent_label_column || relation.parent_value_column;
// 부모 옵션 조회
let optionsQuery = `
SELECT
${relation.parent_value_column} as value,
${labelColumn} as label
FROM ${relation.parent_table}
WHERE 1=1
`;
// 멀티테넌시 적용 (테이블에 company_code가 있는 경우)
const tableInfoResult = await pool.query(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[relation.parent_table]
);
const optionsParams: any[] = [];
// company_code = "*"는 최고 관리자 전용이므로 일반 회사는 자기 회사 데이터만
if (
tableInfoResult.rowCount &&
tableInfoResult.rowCount > 0 &&
companyCode !== "*"
) {
optionsQuery += ` AND company_code = $1`;
optionsParams.push(companyCode);
}
// status 컬럼이 있으면 활성 상태만 조회
const statusInfoResult = await pool.query(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'status'`,
[relation.parent_table]
);
if (statusInfoResult.rowCount && statusInfoResult.rowCount > 0) {
optionsQuery += ` AND (status IS NULL OR status != 'N')`;
}
// 정렬
optionsQuery += ` ORDER BY ${labelColumn} ASC`;
const optionsResult = await pool.query(optionsQuery, optionsParams);
logger.info("부모 옵션 조회", {
relationCode: code,
parentTable: relation.parent_table,
optionsCount: optionsResult.rowCount,
});
return res.json({
success: true,
data: optionsResult.rows,
});
} catch (error: any) {
logger.error("부모 옵션 조회 실패", { error: error.message });
return res.status(500).json({
success: false,
message: "부모 옵션 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
*
* API
*
* :
* - parentValue: 단일 (: "공정검사")
* - parentValues: 다중 (: "공정검사,출하검사" )
*/
export const getCascadingOptions = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { code } = req.params;
const { parentValue, parentValues } = req.query;
const companyCode = req.user?.companyCode || "*";
// 다중 부모값 파싱
let parentValueArray: string[] = [];
if (parentValues) {
// parentValues가 있으면 우선 사용 (다중 선택)
if (Array.isArray(parentValues)) {
parentValueArray = parentValues.map(v => String(v));
} else {
// 콤마로 구분된 문자열
parentValueArray = String(parentValues).split(',').map(v => v.trim()).filter(v => v);
}
} else if (parentValue) {
// 기존 단일 값 호환
parentValueArray = [String(parentValue)];
}
if (parentValueArray.length === 0) {
return res.json({
success: true,
data: [],
message: "부모 값이 없습니다.",
});
}
// 관계 정보 조회
let relationQuery = `
SELECT
child_table,
child_filter_column,
child_value_column,
child_label_column,
child_order_column,
child_order_direction
FROM cascading_relation
WHERE relation_code = $1
AND is_active = 'Y'
`;
const relationParams: any[] = [code];
// 멀티테넌시 필터링 (company_code = "*"는 최고 관리자 전용)
if (companyCode !== "*") {
relationQuery += ` AND company_code = $2`;
relationParams.push(companyCode);
}
relationQuery += ` LIMIT 1`;
const relationResult = await pool.query(relationQuery, relationParams);
if (relationResult.rowCount === 0) {
return res.status(404).json({
success: false,
message: "연쇄 관계를 찾을 수 없습니다.",
});
}
const relation = relationResult.rows[0];
// 자식 옵션 조회 - 다중 부모값에 대해 IN 절 사용
// SQL Injection 방지를 위해 파라미터화된 쿼리 사용
const placeholders = parentValueArray.map((_, idx) => `$${idx + 1}`).join(', ');
let optionsQuery = `
SELECT DISTINCT
${relation.child_value_column} as value,
${relation.child_label_column} as label,
${relation.child_filter_column} as parent_value
FROM ${relation.child_table}
WHERE ${relation.child_filter_column} IN (${placeholders})
`;
// 멀티테넌시 적용 (테이블에 company_code가 있는 경우)
const tableInfoResult = await pool.query(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[relation.child_table]
);
const optionsParams: any[] = [...parentValueArray];
let paramIndex = parentValueArray.length + 1;
// company_code = "*"는 최고 관리자 전용이므로 일반 회사는 자기 회사 데이터만
if (
tableInfoResult.rowCount &&
tableInfoResult.rowCount > 0 &&
companyCode !== "*"
) {
optionsQuery += ` AND company_code = $${paramIndex}`;
optionsParams.push(companyCode);
paramIndex++;
}
// 정렬
if (relation.child_order_column) {
optionsQuery += ` ORDER BY ${relation.child_order_column} ${relation.child_order_direction || "ASC"}`;
} else {
optionsQuery += ` ORDER BY ${relation.child_label_column} ASC`;
}
const optionsResult = await pool.query(optionsQuery, optionsParams);
logger.info("연쇄 옵션 조회 (다중 부모값 지원)", {
relationCode: code,
parentValues: parentValueArray,
optionsCount: optionsResult.rowCount,
});
return res.json({
success: true,
data: optionsResult.rows,
});
} catch (error: any) {
logger.error("연쇄 옵션 조회 실패", { error: error.message });
return res.status(500).json({
success: false,
message: "연쇄 옵션 조회에 실패했습니다.",
error: error.message,
});
}
};

File diff suppressed because it is too large Load Diff

View File

@ -282,3 +282,175 @@ export async function previewCodeMerge(
} }
} }
/**
* -
* oldValue를 newValue로
*/
export async function mergeCodeByValue(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
const { oldValue, newValue } = req.body;
const companyCode = req.user?.companyCode;
try {
// 입력값 검증
if (!oldValue || !newValue) {
res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다. (oldValue, newValue)",
});
return;
}
if (!companyCode) {
res.status(401).json({
success: false,
message: "인증 정보가 없습니다.",
});
return;
}
// 같은 값으로 병합 시도 방지
if (oldValue === newValue) {
res.status(400).json({
success: false,
message: "기존 값과 새 값이 동일합니다.",
});
return;
}
logger.info("값 기반 코드 병합 시작", {
oldValue,
newValue,
companyCode,
userId: req.user?.userId,
});
// PostgreSQL 함수 호출
const result = await pool.query(
"SELECT * FROM merge_code_by_value($1, $2, $3)",
[oldValue, newValue, companyCode]
);
// 결과 처리
const affectedData = Array.isArray(result) ? result : ((result as any).rows || []);
const totalRows = affectedData.reduce(
(sum: number, row: any) => sum + parseInt(row.out_rows_updated || 0),
0
);
logger.info("값 기반 코드 병합 완료", {
oldValue,
newValue,
affectedTablesCount: affectedData.length,
totalRowsUpdated: totalRows,
});
res.json({
success: true,
message: `코드 병합 완료: ${oldValue}${newValue}`,
data: {
oldValue,
newValue,
affectedData: affectedData.map((row: any) => ({
tableName: row.out_table_name,
columnName: row.out_column_name,
rowsUpdated: parseInt(row.out_rows_updated),
})),
totalRowsUpdated: totalRows,
},
});
} catch (error: any) {
logger.error("값 기반 코드 병합 실패:", {
error: error.message,
stack: error.stack,
oldValue,
newValue,
});
res.status(500).json({
success: false,
message: "코드 병합 중 오류가 발생했습니다.",
error: {
code: "CODE_MERGE_BY_VALUE_ERROR",
details: error.message,
},
});
}
}
/**
*
* /
*/
export async function previewMergeCodeByValue(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
const { oldValue } = req.body;
const companyCode = req.user?.companyCode;
try {
if (!oldValue) {
res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다. (oldValue)",
});
return;
}
if (!companyCode) {
res.status(401).json({
success: false,
message: "인증 정보가 없습니다.",
});
return;
}
logger.info("값 기반 코드 병합 미리보기", { oldValue, companyCode });
// PostgreSQL 함수 호출
const result = await pool.query(
"SELECT * FROM preview_merge_code_by_value($1, $2)",
[oldValue, companyCode]
);
const preview = Array.isArray(result) ? result : ((result as any).rows || []);
const totalRows = preview.reduce(
(sum: number, row: any) => sum + parseInt(row.out_affected_rows || 0),
0
);
logger.info("값 기반 코드 병합 미리보기 완료", {
tablesCount: preview.length,
totalRows,
});
res.json({
success: true,
message: "코드 병합 미리보기 완료",
data: {
oldValue,
preview: preview.map((row: any) => ({
tableName: row.out_table_name,
columnName: row.out_column_name,
affectedRows: parseInt(row.out_affected_rows),
})),
totalAffectedRows: totalRows,
},
});
} catch (error: any) {
logger.error("값 기반 코드 병합 미리보기 실패:", error);
res.status(500).json({
success: false,
message: "코드 병합 미리보기 중 오류가 발생했습니다.",
error: {
code: "PREVIEW_BY_VALUE_ERROR",
details: error.message,
},
});
}
}

View File

@ -94,7 +94,9 @@ export class CommonCodeController {
sortOrder: code.sort_order, sortOrder: code.sort_order,
isActive: code.is_active, isActive: code.is_active,
useYn: code.is_active, useYn: code.is_active,
companyCode: code.company_code, // 추가 companyCode: code.company_code,
parentCodeValue: code.parent_code_value, // 계층구조: 부모 코드값
depth: code.depth, // 계층구조: 깊이
// 기존 필드명도 유지 (하위 호환성) // 기존 필드명도 유지 (하위 호환성)
code_category: code.code_category, code_category: code.code_category,
@ -103,7 +105,9 @@ export class CommonCodeController {
code_name_eng: code.code_name_eng, code_name_eng: code.code_name_eng,
sort_order: code.sort_order, sort_order: code.sort_order,
is_active: code.is_active, is_active: code.is_active,
company_code: code.company_code, // 추가 company_code: code.company_code,
parent_code_value: code.parent_code_value, // 계층구조: 부모 코드값
// depth는 위에서 이미 정의됨 (snake_case와 camelCase 동일)
created_date: code.created_date, created_date: code.created_date,
created_by: code.created_by, created_by: code.created_by,
updated_date: code.updated_date, updated_date: code.updated_date,
@ -286,19 +290,17 @@ export class CommonCodeController {
}); });
} }
if (!menuObjid) { // menuObjid가 없으면 공통코드관리 메뉴의 기본 OBJID 사용 (전역 코드)
return res.status(400).json({ // 공통코드관리 메뉴 OBJID: 1757401858940
success: false, const DEFAULT_CODE_MANAGEMENT_MENU_OBJID = 1757401858940;
message: "메뉴 OBJID는 필수입니다.", const effectiveMenuObjid = menuObjid ? Number(menuObjid) : DEFAULT_CODE_MANAGEMENT_MENU_OBJID;
});
}
const code = await this.commonCodeService.createCode( const code = await this.commonCodeService.createCode(
categoryCode, categoryCode,
codeData, codeData,
userId, userId,
companyCode, companyCode,
Number(menuObjid) effectiveMenuObjid
); );
return res.status(201).json({ return res.status(201).json({
@ -588,4 +590,129 @@ export class CommonCodeController {
}); });
} }
} }
/**
*
* GET /api/common-codes/categories/:categoryCode/hierarchy
* Query: parentCodeValue (optional), depth (optional), menuObjid (optional)
*/
async getHierarchicalCodes(req: AuthenticatedRequest, res: Response) {
try {
const { categoryCode } = req.params;
const { parentCodeValue, depth, menuObjid } = req.query;
const userCompanyCode = req.user?.companyCode;
const menuObjidNum = menuObjid ? Number(menuObjid) : undefined;
// parentCodeValue가 빈 문자열이면 최상위 코드 조회
const parentValue = parentCodeValue === '' || parentCodeValue === undefined
? null
: parentCodeValue as string;
const codes = await this.commonCodeService.getHierarchicalCodes(
categoryCode,
parentValue,
depth ? parseInt(depth as string) : undefined,
userCompanyCode,
menuObjidNum
);
// 프론트엔드 형식으로 변환
const transformedData = codes.map((code: any) => ({
codeValue: code.code_value,
codeName: code.code_name,
codeNameEng: code.code_name_eng,
description: code.description,
sortOrder: code.sort_order,
isActive: code.is_active,
parentCodeValue: code.parent_code_value,
depth: code.depth,
// 기존 필드도 유지
code_category: code.code_category,
code_value: code.code_value,
code_name: code.code_name,
code_name_eng: code.code_name_eng,
sort_order: code.sort_order,
is_active: code.is_active,
parent_code_value: code.parent_code_value,
}));
return res.json({
success: true,
data: transformedData,
message: `계층구조 코드 조회 성공 (${categoryCode})`,
});
} catch (error) {
logger.error(`계층구조 코드 조회 실패 (${req.params.categoryCode}):`, error);
return res.status(500).json({
success: false,
message: "계층구조 코드 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
*
* GET /api/common-codes/categories/:categoryCode/tree
*/
async getCodeTree(req: AuthenticatedRequest, res: Response) {
try {
const { categoryCode } = req.params;
const { menuObjid } = req.query;
const userCompanyCode = req.user?.companyCode;
const menuObjidNum = menuObjid ? Number(menuObjid) : undefined;
const result = await this.commonCodeService.getCodeTree(
categoryCode,
userCompanyCode,
menuObjidNum
);
return res.json({
success: true,
data: result,
message: `코드 트리 조회 성공 (${categoryCode})`,
});
} catch (error) {
logger.error(`코드 트리 조회 실패 (${req.params.categoryCode}):`, error);
return res.status(500).json({
success: false,
message: "코드 트리 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
*
* GET /api/common-codes/categories/:categoryCode/codes/:codeValue/has-children
*/
async hasChildren(req: AuthenticatedRequest, res: Response) {
try {
const { categoryCode, codeValue } = req.params;
const companyCode = req.user?.companyCode;
const hasChildren = await this.commonCodeService.hasChildren(
categoryCode,
codeValue,
companyCode
);
return res.json({
success: true,
data: { hasChildren },
message: "자식 코드 확인 완료",
});
} catch (error) {
logger.error(
`자식 코드 확인 실패 (${req.params.categoryCode}.${req.params.codeValue}):`,
error
);
return res.status(500).json({
success: false,
message: "자식 코드 확인 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
} }

View File

@ -1,43 +1,25 @@
import { Request, Response } from "express"; import { Request, Response } from "express";
import { pool, queryOne } from "../database/db";
import logger from "../utils/logger"; import logger from "../utils/logger";
import { PasswordEncryption } from "../utils/passwordEncryption"; import { ExternalDbConnectionPoolService } from "../services/externalDbConnectionPoolService";
import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory";
// 외부 DB 커넥터를 가져오는 헬퍼 함수 // 외부 DB 커넥터를 가져오는 헬퍼 함수 (연결 풀 사용)
export async function getExternalDbConnector(connectionId: number) { export async function getExternalDbConnector(connectionId: number) {
// 외부 DB 연결 정보 조회 const poolService = ExternalDbConnectionPoolService.getInstance();
const connection = await queryOne<any>(
`SELECT * FROM external_db_connections WHERE id = $1`,
[connectionId]
);
if (!connection) { // 연결 풀 래퍼를 반환 (executeQuery 메서드를 가진 객체)
throw new Error(`외부 DB 연결 정보를 찾을 수 없습니다. ID: ${connectionId}`); return {
} executeQuery: async (sql: string, params?: any[]) => {
const result = await poolService.executeQuery(connectionId, sql, params);
// 패스워드 복호화 return { rows: result };
const decryptedPassword = PasswordEncryption.decrypt(connection.password); },
// DB 연결 설정
const config = {
host: connection.host,
port: connection.port,
user: connection.username,
password: decryptedPassword,
database: connection.database_name,
}; };
// DB 커넥터 생성
return await DatabaseConnectorFactory.createConnector(
connection.db_type || "mariadb",
config,
connectionId
);
} }
// 동적 계층 구조 데이터 조회 (범용) // 동적 계층 구조 데이터 조회 (범용)
export const getHierarchyData = async (req: Request, res: Response): Promise<Response> => { export const getHierarchyData = async (
req: Request,
res: Response
): Promise<Response> => {
try { try {
const { externalDbConnectionId, hierarchyConfig } = req.body; const { externalDbConnectionId, hierarchyConfig } = req.body;
@ -48,7 +30,9 @@ export const getHierarchyData = async (req: Request, res: Response): Promise<Res
}); });
} }
const connector = await getExternalDbConnector(Number(externalDbConnectionId)); const connector = await getExternalDbConnector(
Number(externalDbConnectionId)
);
const config = JSON.parse(hierarchyConfig); const config = JSON.parse(hierarchyConfig);
const result: any = { const result: any = {
@ -69,7 +53,7 @@ export const getHierarchyData = async (req: Request, res: Response): Promise<Res
for (const level of config.levels) { for (const level of config.levels) {
const levelQuery = `SELECT * FROM ${level.tableName} LIMIT 1000`; const levelQuery = `SELECT * FROM ${level.tableName} LIMIT 1000`;
const levelResult = await connector.executeQuery(levelQuery); const levelResult = await connector.executeQuery(levelQuery);
result.levels.push({ result.levels.push({
level: level.level, level: level.level,
name: level.name, name: level.name,
@ -94,7 +78,10 @@ export const getHierarchyData = async (req: Request, res: Response): Promise<Res
logger.info("동적 계층 구조 데이터 조회", { logger.info("동적 계층 구조 데이터 조회", {
externalDbConnectionId, externalDbConnectionId,
warehouseCount: result.warehouse?.length || 0, warehouseCount: result.warehouse?.length || 0,
levelCounts: result.levels.map((l: any) => ({ level: l.level, count: l.data.length })), levelCounts: result.levels.map((l: any) => ({
level: l.level,
count: l.data.length,
})),
}); });
return res.json({ return res.json({
@ -112,22 +99,35 @@ export const getHierarchyData = async (req: Request, res: Response): Promise<Res
}; };
// 특정 레벨의 하위 데이터 조회 // 특정 레벨의 하위 데이터 조회
export const getChildrenData = async (req: Request, res: Response): Promise<Response> => { export const getChildrenData = async (
req: Request,
res: Response
): Promise<Response> => {
try { try {
const { externalDbConnectionId, hierarchyConfig, parentLevel, parentKey } = req.body; const { externalDbConnectionId, hierarchyConfig, parentLevel, parentKey } =
req.body;
if (!externalDbConnectionId || !hierarchyConfig || !parentLevel || !parentKey) { if (
!externalDbConnectionId ||
!hierarchyConfig ||
!parentLevel ||
!parentKey
) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: "필수 파라미터가 누락되었습니다.", message: "필수 파라미터가 누락되었습니다.",
}); });
} }
const connector = await getExternalDbConnector(Number(externalDbConnectionId)); const connector = await getExternalDbConnector(
Number(externalDbConnectionId)
);
const config = JSON.parse(hierarchyConfig); const config = JSON.parse(hierarchyConfig);
// 다음 레벨 찾기 // 다음 레벨 찾기
const nextLevel = config.levels?.find((l: any) => l.level === parentLevel + 1); const nextLevel = config.levels?.find(
(l: any) => l.level === parentLevel + 1
);
if (!nextLevel) { if (!nextLevel) {
return res.json({ return res.json({
@ -168,7 +168,10 @@ export const getChildrenData = async (req: Request, res: Response): Promise<Resp
}; };
// 창고 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지 // 창고 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
export const getWarehouses = async (req: Request, res: Response): Promise<Response> => { export const getWarehouses = async (
req: Request,
res: Response
): Promise<Response> => {
try { try {
const { externalDbConnectionId, tableName } = req.query; const { externalDbConnectionId, tableName } = req.query;
@ -186,7 +189,9 @@ export const getWarehouses = async (req: Request, res: Response): Promise<Respon
}); });
} }
const connector = await getExternalDbConnector(Number(externalDbConnectionId)); const connector = await getExternalDbConnector(
Number(externalDbConnectionId)
);
// 테이블명을 사용하여 모든 컬럼 조회 // 테이블명을 사용하여 모든 컬럼 조회
const query = `SELECT * FROM ${tableName} LIMIT 100`; const query = `SELECT * FROM ${tableName} LIMIT 100`;
@ -215,7 +220,10 @@ export const getWarehouses = async (req: Request, res: Response): Promise<Respon
}; };
// 구역 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지 // 구역 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
export const getAreas = async (req: Request, res: Response): Promise<Response> => { export const getAreas = async (
req: Request,
res: Response
): Promise<Response> => {
try { try {
const { externalDbConnectionId, warehouseKey, tableName } = req.query; const { externalDbConnectionId, warehouseKey, tableName } = req.query;
@ -226,7 +234,9 @@ export const getAreas = async (req: Request, res: Response): Promise<Response> =
}); });
} }
const connector = await getExternalDbConnector(Number(externalDbConnectionId)); const connector = await getExternalDbConnector(
Number(externalDbConnectionId)
);
const query = ` const query = `
SELECT * FROM ${tableName} SELECT * FROM ${tableName}
@ -258,7 +268,10 @@ export const getAreas = async (req: Request, res: Response): Promise<Response> =
}; };
// 위치 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지 // 위치 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
export const getLocations = async (req: Request, res: Response): Promise<Response> => { export const getLocations = async (
req: Request,
res: Response
): Promise<Response> => {
try { try {
const { externalDbConnectionId, areaKey, tableName } = req.query; const { externalDbConnectionId, areaKey, tableName } = req.query;
@ -269,7 +282,9 @@ export const getLocations = async (req: Request, res: Response): Promise<Respons
}); });
} }
const connector = await getExternalDbConnector(Number(externalDbConnectionId)); const connector = await getExternalDbConnector(
Number(externalDbConnectionId)
);
const query = ` const query = `
SELECT * FROM ${tableName} SELECT * FROM ${tableName}
@ -301,28 +316,38 @@ export const getLocations = async (req: Request, res: Response): Promise<Respons
}; };
// 자재 목록 조회 (동적 컬럼 매핑 지원) // 자재 목록 조회 (동적 컬럼 매핑 지원)
export const getMaterials = async (req: Request, res: Response): Promise<Response> => { export const getMaterials = async (
req: Request,
res: Response
): Promise<Response> => {
try { try {
const { const {
externalDbConnectionId, externalDbConnectionId,
locaKey, locaKey,
tableName, tableName,
keyColumn, keyColumn,
locationKeyColumn, locationKeyColumn,
layerColumn layerColumn,
} = req.query; } = req.query;
if (!externalDbConnectionId || !locaKey || !tableName || !locationKeyColumn) { if (
!externalDbConnectionId ||
!locaKey ||
!tableName ||
!locationKeyColumn
) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: "필수 파라미터가 누락되었습니다.", message: "필수 파라미터가 누락되었습니다.",
}); });
} }
const connector = await getExternalDbConnector(Number(externalDbConnectionId)); const connector = await getExternalDbConnector(
Number(externalDbConnectionId)
);
// 동적 쿼리 생성 // 동적 쿼리 생성
const orderByClause = layerColumn ? `ORDER BY ${layerColumn}` : ''; const orderByClause = layerColumn ? `ORDER BY ${layerColumn}` : "";
const query = ` const query = `
SELECT * FROM ${tableName} SELECT * FROM ${tableName}
WHERE ${locationKeyColumn} = '${locaKey}' WHERE ${locationKeyColumn} = '${locaKey}'
@ -356,7 +381,10 @@ export const getMaterials = async (req: Request, res: Response): Promise<Respons
}; };
// 자재 개수 조회 (여러 Location 일괄) - 레거시, 호환성 유지 // 자재 개수 조회 (여러 Location 일괄) - 레거시, 호환성 유지
export const getMaterialCounts = async (req: Request, res: Response): Promise<Response> => { export const getMaterialCounts = async (
req: Request,
res: Response
): Promise<Response> => {
try { try {
const { externalDbConnectionId, locationKeys, tableName } = req.body; const { externalDbConnectionId, locationKeys, tableName } = req.body;
@ -367,7 +395,9 @@ export const getMaterialCounts = async (req: Request, res: Response): Promise<Re
}); });
} }
const connector = await getExternalDbConnector(Number(externalDbConnectionId)); const connector = await getExternalDbConnector(
Number(externalDbConnectionId)
);
const keysString = locationKeys.map((key: string) => `'${key}'`).join(","); const keysString = locationKeys.map((key: string) => `'${key}'`).join(",");

View File

@ -0,0 +1,459 @@
// 공차중계 운전자 컨트롤러
import { Response } from "express";
import { query } from "../database/db";
import { logger } from "../utils/logger";
import { AuthenticatedRequest } from "../types/auth";
export class DriverController {
/**
* GET /api/driver/profile
*
*/
static async getProfile(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user?.userId;
if (!userId) {
res.status(401).json({
success: false,
message: "인증이 필요합니다.",
});
return;
}
// 사용자 정보 조회
const userResult = await query<any>(
`SELECT
user_id, user_name, cell_phone, license_number, vehicle_number, signup_type, branch_name
FROM user_info
WHERE user_id = $1`,
[userId]
);
if (userResult.length === 0) {
res.status(404).json({
success: false,
message: "사용자를 찾을 수 없습니다.",
});
return;
}
const user = userResult[0];
// 공차중계 사용자가 아닌 경우
if (user.signup_type !== "DRIVER") {
res.status(403).json({
success: false,
message: "공차중계 사용자만 접근할 수 있습니다.",
});
return;
}
// 차량 정보 조회
const vehicleResult = await query<any>(
`SELECT
vehicle_number, vehicle_type, driver_name, driver_phone, status
FROM vehicles
WHERE user_id = $1`,
[userId]
);
const vehicle = vehicleResult.length > 0 ? vehicleResult[0] : null;
res.status(200).json({
success: true,
data: {
userId: user.user_id,
userName: user.user_name,
phoneNumber: user.cell_phone,
licenseNumber: user.license_number,
vehicleNumber: user.vehicle_number,
vehicleType: vehicle?.vehicle_type || null,
vehicleStatus: vehicle?.status || null,
branchName: user.branch_name || null,
},
});
} catch (error) {
logger.error("운전자 프로필 조회 오류:", error);
res.status(500).json({
success: false,
message: "프로필 조회 중 오류가 발생했습니다.",
});
}
}
/**
* PUT /api/driver/profile
* (, , , , )
*/
static async updateProfile(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user?.userId;
if (!userId) {
res.status(401).json({
success: false,
message: "인증이 필요합니다.",
});
return;
}
const { userName, phoneNumber, licenseNumber, vehicleNumber, vehicleType, branchName } = req.body;
// 공차중계 사용자 확인
const userCheck = await query<any>(
`SELECT signup_type, vehicle_number FROM user_info WHERE user_id = $1`,
[userId]
);
if (userCheck.length === 0) {
res.status(404).json({
success: false,
message: "사용자를 찾을 수 없습니다.",
});
return;
}
if (userCheck[0].signup_type !== "DRIVER") {
res.status(403).json({
success: false,
message: "공차중계 사용자만 접근할 수 있습니다.",
});
return;
}
const oldVehicleNumber = userCheck[0].vehicle_number;
// 차량번호 변경 시 중복 확인
if (vehicleNumber && vehicleNumber !== oldVehicleNumber) {
const duplicateCheck = await query<any>(
`SELECT vehicle_number FROM vehicles WHERE vehicle_number = $1 AND user_id != $2`,
[vehicleNumber, userId]
);
if (duplicateCheck.length > 0) {
res.status(400).json({
success: false,
message: "이미 등록된 차량번호입니다.",
});
return;
}
}
// user_info 업데이트
await query(
`UPDATE user_info SET
user_name = COALESCE($1, user_name),
cell_phone = COALESCE($2, cell_phone),
license_number = COALESCE($3, license_number),
vehicle_number = COALESCE($4, vehicle_number),
branch_name = COALESCE($5, branch_name)
WHERE user_id = $6`,
[userName || null, phoneNumber || null, licenseNumber || null, vehicleNumber || null, branchName || null, userId]
);
// vehicles 테이블 업데이트
await query(
`UPDATE vehicles SET
vehicle_number = COALESCE($1, vehicle_number),
vehicle_type = COALESCE($2, vehicle_type),
driver_name = COALESCE($3, driver_name),
driver_phone = COALESCE($4, driver_phone),
branch_name = COALESCE($5, branch_name),
updated_at = NOW()
WHERE user_id = $6`,
[vehicleNumber || null, vehicleType || null, userName || null, phoneNumber || null, branchName || null, userId]
);
logger.info(`운전자 프로필 수정 완료: ${userId}`);
res.status(200).json({
success: true,
message: "프로필이 수정되었습니다.",
});
} catch (error) {
logger.error("운전자 프로필 수정 오류:", error);
res.status(500).json({
success: false,
message: "프로필 수정 중 오류가 발생했습니다.",
});
}
}
/**
* PUT /api/driver/status
* (/ )
*/
static async updateStatus(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user?.userId;
if (!userId) {
res.status(401).json({
success: false,
message: "인증이 필요합니다.",
});
return;
}
const { status } = req.body;
// 허용된 상태값만 (대기: off, 정비: maintenance)
const allowedStatuses = ["off", "maintenance"];
if (!status || !allowedStatuses.includes(status)) {
res.status(400).json({
success: false,
message: "유효하지 않은 상태값입니다. (off: 대기, maintenance: 정비)",
});
return;
}
// 공차중계 사용자 확인
const userCheck = await query<any>(
`SELECT signup_type FROM user_info WHERE user_id = $1`,
[userId]
);
if (userCheck.length === 0 || userCheck[0].signup_type !== "DRIVER") {
res.status(403).json({
success: false,
message: "공차중계 사용자만 접근할 수 있습니다.",
});
return;
}
// vehicles 테이블 상태 업데이트
const updateResult = await query(
`UPDATE vehicles SET status = $1, updated_at = NOW() WHERE user_id = $2`,
[status, userId]
);
logger.info(`차량 상태 변경: ${userId} -> ${status}`);
res.status(200).json({
success: true,
message: `차량 상태가 ${status === "off" ? "대기" : "정비"}로 변경되었습니다.`,
});
} catch (error) {
logger.error("차량 상태 변경 오류:", error);
res.status(500).json({
success: false,
message: "상태 변경 중 오류가 발생했습니다.",
});
}
}
/**
* DELETE /api/driver/vehicle
* (user_id = NULL , )
*/
static async deleteVehicle(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user?.userId;
if (!userId) {
res.status(401).json({
success: false,
message: "인증이 필요합니다.",
});
return;
}
// 공차중계 사용자 확인
const userCheck = await query<any>(
`SELECT signup_type, vehicle_number FROM user_info WHERE user_id = $1`,
[userId]
);
if (userCheck.length === 0 || userCheck[0].signup_type !== "DRIVER") {
res.status(403).json({
success: false,
message: "공차중계 사용자만 접근할 수 있습니다.",
});
return;
}
// vehicles 테이블에서 user_id를 NULL로 변경하고 status를 disabled로 (기록 보존)
await query(
`UPDATE vehicles SET user_id = NULL, status = 'disabled', updated_at = NOW() WHERE user_id = $1`,
[userId]
);
// user_info에서 vehicle_number를 NULL로 변경
await query(
`UPDATE user_info SET vehicle_number = NULL WHERE user_id = $1`,
[userId]
);
logger.info(`차량 삭제 완료 (기록 보존): ${userId}`);
res.status(200).json({
success: true,
message: "차량이 삭제되었습니다.",
});
} catch (error) {
logger.error("차량 삭제 오류:", error);
res.status(500).json({
success: false,
message: "차량 삭제 중 오류가 발생했습니다.",
});
}
}
/**
* POST /api/driver/vehicle
*
*/
static async registerVehicle(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user?.userId;
const companyCode = req.user?.companyCode;
if (!userId) {
res.status(401).json({
success: false,
message: "인증이 필요합니다.",
});
return;
}
const { vehicleNumber, vehicleType, branchName } = req.body;
if (!vehicleNumber) {
res.status(400).json({
success: false,
message: "차량번호는 필수입니다.",
});
return;
}
// 공차중계 사용자 확인
const userCheck = await query<any>(
`SELECT signup_type, user_name, cell_phone, vehicle_number, company_code FROM user_info WHERE user_id = $1`,
[userId]
);
if (userCheck.length === 0 || userCheck[0].signup_type !== "DRIVER") {
res.status(403).json({
success: false,
message: "공차중계 사용자만 접근할 수 있습니다.",
});
return;
}
// 이미 차량이 있는지 확인
if (userCheck[0].vehicle_number) {
res.status(400).json({
success: false,
message: "이미 등록된 차량이 있습니다. 먼저 기존 차량을 삭제해주세요.",
});
return;
}
// 차량번호 중복 확인
const duplicateCheck = await query<any>(
`SELECT vehicle_number FROM vehicles WHERE vehicle_number = $1 AND user_id IS NOT NULL`,
[vehicleNumber]
);
if (duplicateCheck.length > 0) {
res.status(400).json({
success: false,
message: "이미 등록된 차량번호입니다.",
});
return;
}
const userName = userCheck[0].user_name;
const userPhone = userCheck[0].cell_phone;
// 사용자의 company_code 사용 (req.user에서 가져오거나 DB에서 조회한 값 사용)
const userCompanyCode = companyCode || userCheck[0].company_code;
// vehicles 테이블에 새 차량 등록 (company_code 포함, status는 'off')
await query(
`INSERT INTO vehicles (vehicle_number, vehicle_type, user_id, driver_name, driver_phone, branch_name, status, company_code, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, 'off', $7, NOW(), NOW())`,
[vehicleNumber, vehicleType || null, userId, userName, userPhone, branchName || null, userCompanyCode]
);
// user_info에 vehicle_number 업데이트
await query(
`UPDATE user_info SET vehicle_number = $1 WHERE user_id = $2`,
[vehicleNumber, userId]
);
logger.info(`새 차량 등록 완료: ${userId} -> ${vehicleNumber} (company_code: ${userCompanyCode})`);
res.status(200).json({
success: true,
message: "차량이 등록되었습니다.",
});
} catch (error) {
logger.error("차량 등록 오류:", error);
res.status(500).json({
success: false,
message: "차량 등록 중 오류가 발생했습니다.",
});
}
}
/**
* DELETE /api/driver/account
* ( )
*/
static async deleteAccount(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user?.userId;
if (!userId) {
res.status(401).json({
success: false,
message: "인증이 필요합니다.",
});
return;
}
// 공차중계 사용자 확인
const userCheck = await query<any>(
`SELECT signup_type FROM user_info WHERE user_id = $1`,
[userId]
);
if (userCheck.length === 0) {
res.status(404).json({
success: false,
message: "사용자를 찾을 수 없습니다.",
});
return;
}
if (userCheck[0].signup_type !== "DRIVER") {
res.status(403).json({
success: false,
message: "공차중계 사용자만 탈퇴할 수 있습니다.",
});
return;
}
// vehicles 테이블에서 삭제
await query(`DELETE FROM vehicles WHERE user_id = $1`, [userId]);
// user_info 테이블에서 삭제
await query(`DELETE FROM user_info WHERE user_id = $1`, [userId]);
logger.info(`회원 탈퇴 완료: ${userId}`);
res.status(200).json({
success: true,
message: "회원 탈퇴가 완료되었습니다.",
});
} catch (error) {
logger.error("회원 탈퇴 오류:", error);
res.status(500).json({
success: false,
message: "회원 탈퇴 처리 중 오류가 발생했습니다.",
});
}
}
}

View File

@ -231,7 +231,7 @@ export const deleteFormData = async (
try { try {
const { id } = req.params; const { id } = req.params;
const { companyCode, userId } = req.user as any; const { companyCode, userId } = req.user as any;
const { tableName } = req.body; const { tableName, screenId } = req.body;
if (!tableName) { if (!tableName) {
return res.status(400).json({ return res.status(400).json({
@ -240,7 +240,16 @@ export const deleteFormData = async (
}); });
} }
await dynamicFormService.deleteFormData(id, tableName, companyCode, userId); // userId 추가 // screenId를 숫자로 변환 (문자열로 전달될 수 있음)
const parsedScreenId = screenId ? parseInt(screenId, 10) : undefined;
await dynamicFormService.deleteFormData(
id,
tableName,
companyCode,
userId,
parsedScreenId // screenId 추가 (제어관리 실행용)
);
res.json({ res.json({
success: true, success: true,
@ -427,7 +436,8 @@ export const updateFieldValue = async (
): Promise<Response | void> => { ): Promise<Response | void> => {
try { try {
const { companyCode, userId } = req.user as any; const { companyCode, userId } = req.user as any;
const { tableName, keyField, keyValue, updateField, updateValue } = req.body; const { tableName, keyField, keyValue, updateField, updateValue } =
req.body;
console.log("🔄 [updateFieldValue] 요청:", { console.log("🔄 [updateFieldValue] 요청:", {
tableName, tableName,
@ -440,16 +450,27 @@ export const updateFieldValue = async (
}); });
// 필수 필드 검증 // 필수 필드 검증
if (!tableName || !keyField || keyValue === undefined || !updateField || updateValue === undefined) { if (
!tableName ||
!keyField ||
keyValue === undefined ||
!updateField ||
updateValue === undefined
) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: "필수 필드가 누락되었습니다. (tableName, keyField, keyValue, updateField, updateValue)", message:
"필수 필드가 누락되었습니다. (tableName, keyField, keyValue, updateField, updateValue)",
}); });
} }
// SQL 인젝션 방지를 위한 테이블명/컬럼명 검증 // SQL 인젝션 방지를 위한 테이블명/컬럼명 검증
const validNamePattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/; const validNamePattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
if (!validNamePattern.test(tableName) || !validNamePattern.test(keyField) || !validNamePattern.test(updateField)) { if (
!validNamePattern.test(tableName) ||
!validNamePattern.test(keyField) ||
!validNamePattern.test(updateField)
) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: "유효하지 않은 테이블명 또는 컬럼명입니다.", message: "유효하지 않은 테이블명 또는 컬럼명입니다.",
@ -482,3 +503,132 @@ export const updateFieldValue = async (
}); });
} }
}; };
/**
* ( )
* POST /api/dynamic-form/location-history
*/
export const saveLocationHistory = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode, userId: loginUserId } = req.user as any;
const {
latitude,
longitude,
accuracy,
altitude,
speed,
heading,
tripId,
tripStatus,
departure,
arrival,
departureName,
destinationName,
recordedAt,
vehicleId,
userId: requestUserId, // 프론트엔드에서 보낸 userId (차량 번호판 등)
} = req.body;
// 프론트엔드에서 보낸 userId가 있으면 그것을 사용 (차량 번호판 등)
// 없으면 로그인한 사용자의 userId 사용
const userId = requestUserId || loginUserId;
console.log("📍 [saveLocationHistory] 요청:", {
userId,
requestUserId,
loginUserId,
companyCode,
latitude,
longitude,
tripId,
});
// 필수 필드 검증
if (latitude === undefined || longitude === undefined) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다. (latitude, longitude)",
});
}
const result = await dynamicFormService.saveLocationHistory({
userId,
companyCode,
latitude,
longitude,
accuracy,
altitude,
speed,
heading,
tripId,
tripStatus: tripStatus || "active",
departure,
arrival,
departureName,
destinationName,
recordedAt: recordedAt || new Date().toISOString(),
vehicleId,
});
console.log("✅ [saveLocationHistory] 성공:", result);
res.json({
success: true,
data: result,
message: "위치 이력이 저장되었습니다.",
});
} catch (error: any) {
console.error("❌ [saveLocationHistory] 실패:", error);
res.status(500).json({
success: false,
message: error.message || "위치 이력 저장에 실패했습니다.",
});
}
};
/**
* ( )
* GET /api/dynamic-form/location-history/:tripId
*/
export const getLocationHistory = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode } = req.user as any;
const { tripId } = req.params;
const { userId, startDate, endDate, limit } = req.query;
console.log("📍 [getLocationHistory] 요청:", {
tripId,
userId,
startDate,
endDate,
limit,
});
const result = await dynamicFormService.getLocationHistory({
companyCode,
tripId,
userId: userId as string,
startDate: startDate as string,
endDate: endDate as string,
limit: limit ? parseInt(limit as string) : 1000,
});
res.json({
success: true,
data: result,
count: result.length,
});
} catch (error: any) {
console.error("❌ [getLocationHistory] 실패:", error);
res.status(500).json({
success: false,
message: error.message || "위치 이력 조회에 실패했습니다.",
});
}
};

View File

@ -29,6 +29,7 @@ export class EntityJoinController {
screenEntityConfigs, // 화면별 엔티티 설정 (JSON 문자열) screenEntityConfigs, // 화면별 엔티티 설정 (JSON 문자열)
autoFilter, // 🔒 멀티테넌시 자동 필터 autoFilter, // 🔒 멀티테넌시 자동 필터
dataFilter, // 🆕 데이터 필터 (JSON 문자열) dataFilter, // 🆕 데이터 필터 (JSON 문자열)
excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함 userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
...otherParams ...otherParams
} = req.query; } = req.query;
@ -65,11 +66,23 @@ export class EntityJoinController {
const userField = parsedAutoFilter.userField || "companyCode"; const userField = parsedAutoFilter.userField || "companyCode";
const userValue = ((req as any).user as any)[userField]; const userValue = ((req as any).user as any)[userField];
if (userValue) { // 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 허용)
searchConditions[filterColumn] = userValue; let finalCompanyCode = userValue;
if (parsedAutoFilter.companyCodeOverride && userValue === "*") {
// 최고 관리자만 다른 회사 코드로 오버라이드 가능
finalCompanyCode = parsedAutoFilter.companyCodeOverride;
logger.info("🔓 최고 관리자 회사 코드 오버라이드:", {
originalCompanyCode: userValue,
overrideCompanyCode: parsedAutoFilter.companyCodeOverride,
tableName,
});
}
if (finalCompanyCode) {
searchConditions[filterColumn] = finalCompanyCode;
logger.info("🔒 Entity 조인에 멀티테넌시 필터 적용:", { logger.info("🔒 Entity 조인에 멀티테넌시 필터 적용:", {
filterColumn, filterColumn,
userValue, finalCompanyCode,
tableName, tableName,
}); });
} }
@ -125,6 +138,19 @@ export class EntityJoinController {
} }
} }
// 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외)
let parsedExcludeFilter: any = undefined;
if (excludeFilter) {
try {
parsedExcludeFilter =
typeof excludeFilter === "string" ? JSON.parse(excludeFilter) : excludeFilter;
logger.info("제외 필터 파싱 완료:", parsedExcludeFilter);
} catch (error) {
logger.warn("제외 필터 파싱 오류:", error);
parsedExcludeFilter = undefined;
}
}
const result = await tableManagementService.getTableDataWithEntityJoins( const result = await tableManagementService.getTableDataWithEntityJoins(
tableName, tableName,
{ {
@ -141,6 +167,7 @@ export class EntityJoinController {
additionalJoinColumns: parsedAdditionalJoinColumns, additionalJoinColumns: parsedAdditionalJoinColumns,
screenEntityConfigs: parsedScreenEntityConfigs, screenEntityConfigs: parsedScreenEntityConfigs,
dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달 dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달
excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달
} }
); );
@ -409,18 +436,16 @@ export class EntityJoinController {
config.referenceTable config.referenceTable
); );
// 현재 display_column으로 사용 중인 컬럼 제외 // 현재 display_column 정보 (참고용으로만 사용, 필터링하지 않음)
const currentDisplayColumn = const currentDisplayColumn =
config.displayColumn || config.displayColumns[0]; config.displayColumn || config.displayColumns[0];
const availableColumns = columns.filter(
(col) => col.columnName !== currentDisplayColumn // 모든 컬럼 표시 (기본 표시 컬럼도 포함)
);
return { return {
joinConfig: config, joinConfig: config,
tableName: config.referenceTable, tableName: config.referenceTable,
currentDisplayColumn: currentDisplayColumn, currentDisplayColumn: currentDisplayColumn,
availableColumns: availableColumns.map((col) => ({ availableColumns: columns.map((col) => ({
columnName: col.columnName, columnName: col.columnName,
columnLabel: col.displayName || col.columnName, columnLabel: col.displayName || col.columnName,
dataType: col.dataType, dataType: col.dataType,

View File

@ -32,10 +32,32 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
const companyCode = req.user!.companyCode; const companyCode = req.user!.companyCode;
// 검색 필드 파싱 // 검색 필드 파싱
const fields = searchFields const requestedFields = searchFields
? (searchFields as string).split(",").map((f) => f.trim()) ? (searchFields as string).split(",").map((f) => f.trim())
: []; : [];
// 🆕 테이블의 실제 컬럼 목록 조회
const pool = getPool();
const columnsResult = await pool.query(
`SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = $1`,
[tableName]
);
const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name));
// 🆕 존재하는 컬럼만 필터링
const fields = requestedFields.filter((field) => {
if (existingColumns.has(field)) {
return true;
} else {
logger.warn(`엔티티 검색: 테이블 "${tableName}"에 컬럼 "${field}"이(가) 존재하지 않아 제외`);
return false;
}
});
const existingColumnsArray = Array.from(existingColumns);
logger.info(`엔티티 검색 필드 확인 - 테이블: ${tableName}, 요청필드: [${requestedFields.join(", ")}], 유효필드: [${fields.join(", ")}], 테이블컬럼(샘플): [${existingColumnsArray.slice(0, 10).join(", ")}]`);
// WHERE 조건 생성 // WHERE 조건 생성
const whereConditions: string[] = []; const whereConditions: string[] = [];
const params: any[] = []; const params: any[] = [];
@ -43,32 +65,131 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
// 멀티테넌시 필터링 // 멀티테넌시 필터링
if (companyCode !== "*") { if (companyCode !== "*") {
whereConditions.push(`company_code = $${paramIndex}`); // 🆕 company_code 컬럼이 있는 경우에만 필터링
params.push(companyCode); if (existingColumns.has("company_code")) {
paramIndex++; whereConditions.push(`company_code = $${paramIndex}`);
params.push(companyCode);
paramIndex++;
}
} }
// 검색 조건 // 검색 조건
if (searchText && fields.length > 0) { if (searchText) {
const searchConditions = fields.map((field) => { // 유효한 검색 필드가 없으면 기본 텍스트 컬럼에서 검색
const condition = `${field}::text ILIKE $${paramIndex}`; let searchableFields = fields;
paramIndex++; if (searchableFields.length === 0) {
return condition; // 기본 검색 컬럼: name, code, description 등 일반적인 컬럼명
}); const defaultSearchColumns = [
whereConditions.push(`(${searchConditions.join(" OR ")})`); 'name', 'code', 'description', 'title', 'label',
'item_name', 'item_code', 'item_number',
'equipment_name', 'equipment_code',
'inspection_item', 'consumable_name', // 소모품명 추가
'supplier_name', 'customer_name', 'product_name',
];
searchableFields = defaultSearchColumns.filter(col => existingColumns.has(col));
logger.info(`엔티티 검색: 기본 검색 필드 사용 - 테이블: ${tableName}, 검색필드: [${searchableFields.join(", ")}]`);
}
if (searchableFields.length > 0) {
const searchConditions = searchableFields.map((field) => {
const condition = `${field}::text ILIKE $${paramIndex}`;
paramIndex++;
return condition;
});
whereConditions.push(`(${searchConditions.join(" OR ")})`);
// 검색어 파라미터 추가 // 검색어 파라미터 추가
fields.forEach(() => { searchableFields.forEach(() => {
params.push(`%${searchText}%`); params.push(`%${searchText}%`);
}); });
}
} }
// 추가 필터 조건 // 추가 필터 조건 (존재하는 컬럼만)
// 지원 연산자: =, !=, >, <, >=, <=, in, notIn, like
// 특수 키 형식: column__operator (예: division__in, name__like)
const additionalFilter = JSON.parse(filterCondition as string); const additionalFilter = JSON.parse(filterCondition as string);
for (const [key, value] of Object.entries(additionalFilter)) { for (const [key, value] of Object.entries(additionalFilter)) {
whereConditions.push(`${key} = $${paramIndex}`); // 특수 키 형식 파싱: column__operator
params.push(value); let columnName = key;
paramIndex++; let operator = "=";
if (key.includes("__")) {
const parts = key.split("__");
columnName = parts[0];
operator = parts[1] || "=";
}
if (!existingColumns.has(columnName)) {
logger.warn("엔티티 검색: 필터 조건에 존재하지 않는 컬럼 제외", { tableName, key, columnName });
continue;
}
// 연산자별 WHERE 조건 생성
switch (operator) {
case "=":
whereConditions.push(`"${columnName}" = $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case "!=":
whereConditions.push(`"${columnName}" != $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case ">":
whereConditions.push(`"${columnName}" > $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case "<":
whereConditions.push(`"${columnName}" < $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case ">=":
whereConditions.push(`"${columnName}" >= $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case "<=":
whereConditions.push(`"${columnName}" <= $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case "in":
// IN 연산자: 값이 배열이거나 쉼표로 구분된 문자열
const inValues = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim());
if (inValues.length > 0) {
const placeholders = inValues.map((_, i) => `$${paramIndex + i}`).join(", ");
whereConditions.push(`"${columnName}" IN (${placeholders})`);
params.push(...inValues);
paramIndex += inValues.length;
}
break;
case "notIn":
// NOT IN 연산자
const notInValues = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim());
if (notInValues.length > 0) {
const placeholders = notInValues.map((_, i) => `$${paramIndex + i}`).join(", ");
whereConditions.push(`"${columnName}" NOT IN (${placeholders})`);
params.push(...notInValues);
paramIndex += notInValues.length;
}
break;
case "like":
whereConditions.push(`"${columnName}"::text ILIKE $${paramIndex}`);
params.push(`%${value}%`);
paramIndex++;
break;
default:
// 알 수 없는 연산자는 등호로 처리
whereConditions.push(`"${columnName}" = $${paramIndex}`);
params.push(value);
paramIndex++;
break;
}
} }
// 페이징 // 페이징
@ -78,8 +199,7 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
? `WHERE ${whereConditions.join(" AND ")}` ? `WHERE ${whereConditions.join(" AND ")}`
: ""; : "";
// 쿼리 실행 // 쿼리 실행 (pool은 위에서 이미 선언됨)
const pool = getPool();
const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`; const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`;
const dataQuery = ` const dataQuery = `
SELECT * FROM ${tableName} ${whereClause} SELECT * FROM ${tableName} ${whereClause}

View File

@ -0,0 +1,208 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../middleware/authMiddleware";
import excelMappingService from "../services/excelMappingService";
import { logger } from "../utils/logger";
/**
* 릿
* POST /api/excel-mapping/find
*/
export async function findMappingByColumns(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, excelColumns } = req.body;
const companyCode = req.user?.companyCode || "*";
if (!tableName || !excelColumns || !Array.isArray(excelColumns)) {
res.status(400).json({
success: false,
message: "tableName과 excelColumns(배열)가 필요합니다.",
});
return;
}
logger.info("엑셀 매핑 템플릿 조회 요청", {
tableName,
excelColumns,
companyCode,
userId: req.user?.userId,
});
const template = await excelMappingService.findMappingByColumns(
tableName,
excelColumns,
companyCode
);
if (template) {
res.json({
success: true,
data: template,
message: "기존 매핑 템플릿을 찾았습니다.",
});
} else {
res.json({
success: true,
data: null,
message: "일치하는 매핑 템플릿이 없습니다.",
});
}
} catch (error: any) {
logger.error("매핑 템플릿 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "매핑 템플릿 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
* 릿 (UPSERT)
* POST /api/excel-mapping/save
*/
export async function saveMappingTemplate(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, excelColumns, columnMappings } = req.body;
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId;
if (!tableName || !excelColumns || !columnMappings) {
res.status(400).json({
success: false,
message: "tableName, excelColumns, columnMappings가 필요합니다.",
});
return;
}
logger.info("엑셀 매핑 템플릿 저장 요청", {
tableName,
excelColumns,
columnMappings,
companyCode,
userId,
});
const template = await excelMappingService.saveMappingTemplate(
tableName,
excelColumns,
columnMappings,
companyCode,
userId
);
res.json({
success: true,
data: template,
message: "매핑 템플릿이 저장되었습니다.",
});
} catch (error: any) {
logger.error("매핑 템플릿 저장 실패", { error: error.message });
res.status(500).json({
success: false,
message: "매핑 템플릿 저장 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
* 릿
* GET /api/excel-mapping/list/:tableName
*/
export async function getMappingTemplates(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const companyCode = req.user?.companyCode || "*";
if (!tableName) {
res.status(400).json({
success: false,
message: "tableName이 필요합니다.",
});
return;
}
logger.info("매핑 템플릿 목록 조회 요청", {
tableName,
companyCode,
});
const templates = await excelMappingService.getMappingTemplates(
tableName,
companyCode
);
res.json({
success: true,
data: templates,
});
} catch (error: any) {
logger.error("매핑 템플릿 목록 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "매핑 템플릿 목록 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
* 릿
* DELETE /api/excel-mapping/:id
*/
export async function deleteMappingTemplate(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { id } = req.params;
const companyCode = req.user?.companyCode || "*";
if (!id) {
res.status(400).json({
success: false,
message: "id가 필요합니다.",
});
return;
}
logger.info("매핑 템플릿 삭제 요청", {
id,
companyCode,
});
const deleted = await excelMappingService.deleteMappingTemplate(
parseInt(id),
companyCode
);
if (deleted) {
res.json({
success: true,
message: "매핑 템플릿이 삭제되었습니다.",
});
} else {
res.status(404).json({
success: false,
message: "삭제할 매핑 템플릿을 찾을 수 없습니다.",
});
}
} catch (error: any) {
logger.error("매핑 템플릿 삭제 실패", { error: error.message });
res.status(500).json({
success: false,
message: "매핑 템플릿 삭제 중 오류가 발생했습니다.",
error: error.message,
});
}
}

View File

@ -341,6 +341,64 @@ export const uploadFiles = async (
}); });
} }
// 🆕 레코드 모드: 해당 행의 attachments 컬럼 자동 업데이트
const isRecordMode = req.body.isRecordMode === "true" || req.body.isRecordMode === true;
// 🔍 디버깅: 레코드 모드 조건 확인
console.log("🔍 [파일 업로드] 레코드 모드 조건 확인:", {
isRecordMode,
linkedTable,
recordId,
columnName,
finalTargetObjid,
"req.body.isRecordMode": req.body.isRecordMode,
"req.body.linkedTable": req.body.linkedTable,
"req.body.recordId": req.body.recordId,
"req.body.columnName": req.body.columnName,
});
if (isRecordMode && linkedTable && recordId && columnName) {
try {
// 해당 레코드의 모든 첨부파일 조회
const allFiles = await query<any>(
`SELECT objid, real_file_name, file_size, file_ext, file_path, regdate
FROM attach_file_info
WHERE target_objid = $1 AND status = 'ACTIVE'
ORDER BY regdate DESC`,
[finalTargetObjid]
);
// attachments JSONB 형태로 변환
const attachmentsJson = allFiles.map((f: any) => ({
objid: f.objid.toString(),
realFileName: f.real_file_name,
fileSize: Number(f.file_size),
fileExt: f.file_ext,
filePath: f.file_path,
regdate: f.regdate?.toISOString(),
}));
// 해당 테이블의 attachments 컬럼 업데이트
// 🔒 멀티테넌시: company_code 필터 추가
await query(
`UPDATE ${linkedTable}
SET ${columnName} = $1::jsonb, updated_date = NOW()
WHERE id = $2 AND company_code = $3`,
[JSON.stringify(attachmentsJson), recordId, companyCode]
);
console.log("📎 [레코드 모드] attachments 컬럼 자동 업데이트:", {
tableName: linkedTable,
recordId: recordId,
columnName: columnName,
fileCount: attachmentsJson.length,
});
} catch (updateError) {
// attachments 컬럼 업데이트 실패해도 파일 업로드는 성공으로 처리
console.warn("⚠️ attachments 컬럼 업데이트 실패 (무시):", updateError);
}
}
res.json({ res.json({
success: true, success: true,
message: `${files.length}개 파일 업로드 완료`, message: `${files.length}개 파일 업로드 완료`,
@ -405,6 +463,56 @@ export const deleteFile = async (
["DELETED", parseInt(objid)] ["DELETED", parseInt(objid)]
); );
// 🆕 레코드 모드: 해당 행의 attachments 컬럼 자동 업데이트
const targetObjid = fileRecord.target_objid;
if (targetObjid && !targetObjid.startsWith('screen_files:') && !targetObjid.startsWith('temp_')) {
// targetObjid 파싱: tableName:recordId:columnName 형식
const parts = targetObjid.split(':');
if (parts.length >= 3) {
const [tableName, recordId, columnName] = parts;
try {
// 해당 레코드의 남은 첨부파일 조회
const remainingFiles = await query<any>(
`SELECT objid, real_file_name, file_size, file_ext, file_path, regdate
FROM attach_file_info
WHERE target_objid = $1 AND status = 'ACTIVE'
ORDER BY regdate DESC`,
[targetObjid]
);
// attachments JSONB 형태로 변환
const attachmentsJson = remainingFiles.map((f: any) => ({
objid: f.objid.toString(),
realFileName: f.real_file_name,
fileSize: Number(f.file_size),
fileExt: f.file_ext,
filePath: f.file_path,
regdate: f.regdate?.toISOString(),
}));
// 해당 테이블의 attachments 컬럼 업데이트
// 🔒 멀티테넌시: company_code 필터 추가
await query(
`UPDATE ${tableName}
SET ${columnName} = $1::jsonb, updated_date = NOW()
WHERE id = $2 AND company_code = $3`,
[JSON.stringify(attachmentsJson), recordId, fileRecord.company_code]
);
console.log("📎 [파일 삭제] attachments 컬럼 자동 업데이트:", {
tableName,
recordId,
columnName,
remainingFiles: attachmentsJson.length,
});
} catch (updateError) {
// attachments 컬럼 업데이트 실패해도 파일 삭제는 성공으로 처리
console.warn("⚠️ attachments 컬럼 업데이트 실패 (무시):", updateError);
}
}
}
res.json({ res.json({
success: true, success: true,
message: "파일이 삭제되었습니다.", message: "파일이 삭제되었습니다.",

View File

@ -32,8 +32,17 @@ export class FlowController {
*/ */
createFlowDefinition = async (req: Request, res: Response): Promise<void> => { createFlowDefinition = async (req: Request, res: Response): Promise<void> => {
try { try {
const { name, description, tableName, dbSourceType, dbConnectionId } = const {
req.body; name,
description,
tableName,
dbSourceType,
dbConnectionId,
// REST API 관련 필드
restApiConnectionId,
restApiEndpoint,
restApiJsonPath,
} = req.body;
const userId = (req as any).user?.userId || "system"; const userId = (req as any).user?.userId || "system";
const userCompanyCode = (req as any).user?.companyCode; const userCompanyCode = (req as any).user?.companyCode;
@ -43,6 +52,9 @@ export class FlowController {
tableName, tableName,
dbSourceType, dbSourceType,
dbConnectionId, dbConnectionId,
restApiConnectionId,
restApiEndpoint,
restApiJsonPath,
userCompanyCode, userCompanyCode,
}); });
@ -54,8 +66,12 @@ export class FlowController {
return; return;
} }
// 테이블 이름이 제공된 경우에만 존재 확인 // REST API 또는 다중 연결인 경우 테이블 존재 확인 스킵
if (tableName) { const isRestApi = dbSourceType === "restapi" || dbSourceType === "multi_restapi";
const isMultiConnection = dbSourceType === "multi_restapi" || dbSourceType === "multi_external_db";
// 테이블 이름이 제공된 경우에만 존재 확인 (REST API 및 다중 연결 제외)
if (tableName && !isRestApi && !isMultiConnection && !tableName.startsWith("_restapi_") && !tableName.startsWith("_multi_restapi_") && !tableName.startsWith("_multi_external_db_")) {
const tableExists = const tableExists =
await this.flowDefinitionService.checkTableExists(tableName); await this.flowDefinitionService.checkTableExists(tableName);
if (!tableExists) { if (!tableExists) {
@ -68,7 +84,17 @@ export class FlowController {
} }
const flowDef = await this.flowDefinitionService.create( const flowDef = await this.flowDefinitionService.create(
{ name, description, tableName, dbSourceType, dbConnectionId }, {
name,
description,
tableName,
dbSourceType,
dbConnectionId,
restApiConnectionId,
restApiEndpoint,
restApiJsonPath,
restApiConnections: req.body.restApiConnections, // 다중 REST API 설정
},
userId, userId,
userCompanyCode userCompanyCode
); );
@ -811,4 +837,53 @@ export class FlowController {
}); });
} }
}; };
/**
* ( )
*/
updateStepData = async (req: Request, res: Response): Promise<void> => {
try {
const { flowId, stepId, recordId } = req.params;
const updateData = req.body;
const userId = (req as any).user?.userId || "system";
const userCompanyCode = (req as any).user?.companyCode;
if (!flowId || !stepId || !recordId) {
res.status(400).json({
success: false,
message: "flowId, stepId, and recordId are required",
});
return;
}
if (!updateData || Object.keys(updateData).length === 0) {
res.status(400).json({
success: false,
message: "Update data is required",
});
return;
}
const result = await this.flowExecutionService.updateStepData(
parseInt(flowId),
parseInt(stepId),
recordId,
updateData,
userId,
userCompanyCode
);
res.json({
success: true,
message: "Data updated successfully",
data: result,
});
} catch (error: any) {
console.error("Error updating step data:", error);
res.status(500).json({
success: false,
message: error.message || "Failed to update step data",
});
}
};
} }

View File

@ -10,7 +10,10 @@ import {
SaveLangTextsRequest, SaveLangTextsRequest,
GetUserTextParams, GetUserTextParams,
BatchTranslationRequest, BatchTranslationRequest,
GenerateKeyRequest,
CreateOverrideKeyRequest,
ApiResponse, ApiResponse,
LangCategory,
} from "../types/multilang"; } from "../types/multilang";
/** /**
@ -187,7 +190,7 @@ export const getLangKeys = async (
res: Response res: Response
): Promise<void> => { ): Promise<void> => {
try { try {
const { companyCode, menuCode, keyType, searchText } = req.query; const { companyCode, menuCode, keyType, searchText, categoryId } = req.query;
logger.info("다국어 키 목록 조회 요청", { logger.info("다국어 키 목록 조회 요청", {
query: req.query, query: req.query,
user: req.user, user: req.user,
@ -199,6 +202,7 @@ export const getLangKeys = async (
menuCode: menuCode as string, menuCode: menuCode as string,
keyType: keyType as string, keyType: keyType as string,
searchText: searchText as string, searchText: searchText as string,
categoryId: categoryId ? parseInt(categoryId as string, 10) : undefined,
}); });
const response: ApiResponse<any[]> = { const response: ApiResponse<any[]> = {
@ -630,6 +634,391 @@ export const deleteLanguage = async (
} }
}; };
// =====================================================
// 카테고리 관련 API
// =====================================================
/**
* GET /api/multilang/categories
* API ( )
*/
export const getCategories = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
logger.info("카테고리 목록 조회 요청", { user: req.user });
const multiLangService = new MultiLangService();
const categories = await multiLangService.getCategories();
const response: ApiResponse<LangCategory[]> = {
success: true,
message: "카테고리 목록 조회 성공",
data: categories,
};
res.status(200).json(response);
} catch (error) {
logger.error("카테고리 목록 조회 실패:", error);
res.status(500).json({
success: false,
message: "카테고리 목록 조회 중 오류가 발생했습니다.",
error: {
code: "CATEGORY_LIST_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};
/**
* GET /api/multilang/categories/:categoryId
* API
*/
export const getCategoryById = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { categoryId } = req.params;
logger.info("카테고리 상세 조회 요청", { categoryId, user: req.user });
const multiLangService = new MultiLangService();
const category = await multiLangService.getCategoryById(parseInt(categoryId));
if (!category) {
res.status(404).json({
success: false,
message: "카테고리를 찾을 수 없습니다.",
error: {
code: "CATEGORY_NOT_FOUND",
details: `Category ID ${categoryId} not found`,
},
});
return;
}
const response: ApiResponse<LangCategory> = {
success: true,
message: "카테고리 상세 조회 성공",
data: category,
};
res.status(200).json(response);
} catch (error) {
logger.error("카테고리 상세 조회 실패:", error);
res.status(500).json({
success: false,
message: "카테고리 상세 조회 중 오류가 발생했습니다.",
error: {
code: "CATEGORY_DETAIL_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};
/**
* GET /api/multilang/categories/:categoryId/path
* API ( )
*/
export const getCategoryPath = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { categoryId } = req.params;
logger.info("카테고리 경로 조회 요청", { categoryId, user: req.user });
const multiLangService = new MultiLangService();
const path = await multiLangService.getCategoryPath(parseInt(categoryId));
const response: ApiResponse<LangCategory[]> = {
success: true,
message: "카테고리 경로 조회 성공",
data: path,
};
res.status(200).json(response);
} catch (error) {
logger.error("카테고리 경로 조회 실패:", error);
res.status(500).json({
success: false,
message: "카테고리 경로 조회 중 오류가 발생했습니다.",
error: {
code: "CATEGORY_PATH_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};
// =====================================================
// 자동 생성 및 오버라이드 관련 API
// =====================================================
/**
* POST /api/multilang/keys/generate
* API
*/
export const generateKey = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const generateData: GenerateKeyRequest = req.body;
logger.info("키 자동 생성 요청", { generateData, user: req.user });
// 필수 입력값 검증
if (!generateData.companyCode || !generateData.categoryId || !generateData.keyMeaning) {
res.status(400).json({
success: false,
message: "회사 코드, 카테고리 ID, 키 의미는 필수입니다.",
error: {
code: "MISSING_REQUIRED_FIELDS",
details: "companyCode, categoryId, and keyMeaning are required",
},
});
return;
}
// 권한 검사: 공통 키(*)는 최고 관리자만 생성 가능
if (generateData.companyCode === "*" && req.user?.companyCode !== "*") {
res.status(403).json({
success: false,
message: "공통 키는 최고 관리자만 생성할 수 있습니다.",
error: {
code: "PERMISSION_DENIED",
details: "Only super admin can create common keys",
},
});
return;
}
// 회사 관리자는 자기 회사 키만 생성 가능
if (generateData.companyCode !== "*" &&
req.user?.companyCode !== "*" &&
generateData.companyCode !== req.user?.companyCode) {
res.status(403).json({
success: false,
message: "다른 회사의 키를 생성할 권한이 없습니다.",
error: {
code: "PERMISSION_DENIED",
details: "Cannot create keys for other companies",
},
});
return;
}
const multiLangService = new MultiLangService();
const keyId = await multiLangService.generateKey({
...generateData,
createdBy: req.user?.userId || "system",
});
const response: ApiResponse<number> = {
success: true,
message: "키가 성공적으로 생성되었습니다.",
data: keyId,
};
res.status(201).json(response);
} catch (error) {
logger.error("키 자동 생성 실패:", error);
res.status(500).json({
success: false,
message: "키 자동 생성 중 오류가 발생했습니다.",
error: {
code: "KEY_GENERATE_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};
/**
* POST /api/multilang/keys/preview
* API
*/
export const previewKey = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { categoryId, keyMeaning, companyCode } = req.body;
logger.info("키 미리보기 요청", { categoryId, keyMeaning, companyCode, user: req.user });
if (!categoryId || !keyMeaning || !companyCode) {
res.status(400).json({
success: false,
message: "카테고리 ID, 키 의미, 회사 코드는 필수입니다.",
error: {
code: "MISSING_REQUIRED_FIELDS",
details: "categoryId, keyMeaning, and companyCode are required",
},
});
return;
}
const multiLangService = new MultiLangService();
const preview = await multiLangService.previewGeneratedKey(
parseInt(categoryId),
keyMeaning,
companyCode
);
const response: ApiResponse<{
langKey: string;
exists: boolean;
isOverride: boolean;
baseKeyId?: number;
}> = {
success: true,
message: "키 미리보기 성공",
data: preview,
};
res.status(200).json(response);
} catch (error) {
logger.error("키 미리보기 실패:", error);
res.status(500).json({
success: false,
message: "키 미리보기 중 오류가 발생했습니다.",
error: {
code: "KEY_PREVIEW_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};
/**
* POST /api/multilang/keys/override
* API
*/
export const createOverrideKey = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const overrideData: CreateOverrideKeyRequest = req.body;
logger.info("오버라이드 키 생성 요청", { overrideData, user: req.user });
// 필수 입력값 검증
if (!overrideData.companyCode || !overrideData.baseKeyId) {
res.status(400).json({
success: false,
message: "회사 코드와 원본 키 ID는 필수입니다.",
error: {
code: "MISSING_REQUIRED_FIELDS",
details: "companyCode and baseKeyId are required",
},
});
return;
}
// 최고 관리자(*)는 오버라이드 키를 만들 수 없음 (이미 공통 키)
if (overrideData.companyCode === "*") {
res.status(400).json({
success: false,
message: "공통 키에 대한 오버라이드는 생성할 수 없습니다.",
error: {
code: "INVALID_OVERRIDE",
details: "Cannot create override for common keys",
},
});
return;
}
// 회사 관리자는 자기 회사 오버라이드만 생성 가능
if (req.user?.companyCode !== "*" &&
overrideData.companyCode !== req.user?.companyCode) {
res.status(403).json({
success: false,
message: "다른 회사의 오버라이드 키를 생성할 권한이 없습니다.",
error: {
code: "PERMISSION_DENIED",
details: "Cannot create override keys for other companies",
},
});
return;
}
const multiLangService = new MultiLangService();
const keyId = await multiLangService.createOverrideKey({
...overrideData,
createdBy: req.user?.userId || "system",
});
const response: ApiResponse<number> = {
success: true,
message: "오버라이드 키가 성공적으로 생성되었습니다.",
data: keyId,
};
res.status(201).json(response);
} catch (error) {
logger.error("오버라이드 키 생성 실패:", error);
res.status(500).json({
success: false,
message: "오버라이드 키 생성 중 오류가 발생했습니다.",
error: {
code: "OVERRIDE_KEY_CREATE_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};
/**
* GET /api/multilang/keys/overrides/:companyCode
* API
*/
export const getOverrideKeys = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { companyCode } = req.params;
logger.info("오버라이드 키 목록 조회 요청", { companyCode, user: req.user });
// 권한 검사: 최고 관리자 또는 해당 회사 관리자만 조회 가능
if (req.user?.companyCode !== "*" && companyCode !== req.user?.companyCode) {
res.status(403).json({
success: false,
message: "다른 회사의 오버라이드 키를 조회할 권한이 없습니다.",
error: {
code: "PERMISSION_DENIED",
details: "Cannot view override keys for other companies",
},
});
return;
}
const multiLangService = new MultiLangService();
const keys = await multiLangService.getOverrideKeys(companyCode);
const response: ApiResponse<any[]> = {
success: true,
message: "오버라이드 키 목록 조회 성공",
data: keys,
};
res.status(200).json(response);
} catch (error) {
logger.error("오버라이드 키 목록 조회 실패:", error);
res.status(500).json({
success: false,
message: "오버라이드 키 목록 조회 중 오류가 발생했습니다.",
error: {
code: "OVERRIDE_KEYS_LIST_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};
/** /**
* POST /api/multilang/batch * POST /api/multilang/batch
* API * API
@ -710,3 +1099,86 @@ export const getBatchTranslations = async (
}); });
} }
}; };
/**
* POST /api/multilang/screen-labels
* API
*/
export const generateScreenLabelKeys = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { screenId, menuObjId, labels } = req.body;
logger.info("화면 라벨 다국어 키 생성 요청", {
screenId,
menuObjId,
labelCount: labels?.length,
user: req.user,
});
// 필수 파라미터 검증
if (!screenId) {
res.status(400).json({
success: false,
message: "screenId는 필수입니다.",
error: { code: "MISSING_SCREEN_ID" },
});
return;
}
if (!labels || !Array.isArray(labels) || labels.length === 0) {
res.status(400).json({
success: false,
message: "labels 배열이 필요합니다.",
error: { code: "MISSING_LABELS" },
});
return;
}
// 화면의 회사 정보 조회 (사용자 회사가 아닌 화면 소속 회사 기준)
const { queryOne } = await import("../database/db");
const screenInfo = await queryOne<{ company_code: string }>(
`SELECT company_code FROM screen_definitions WHERE screen_id = $1`,
[screenId]
);
const companyCode = screenInfo?.company_code || req.user?.companyCode || "*";
// 회사명 조회
const companyInfo = await queryOne<{ company_name: string }>(
`SELECT company_name FROM company_mng WHERE company_code = $1`,
[companyCode]
);
const companyName = companyCode === "*" ? "공통" : (companyInfo?.company_name || companyCode);
logger.info("화면 소속 회사 정보", { screenId, companyCode, companyName });
const multiLangService = new MultiLangService();
const results = await multiLangService.generateScreenLabelKeys({
screenId: Number(screenId),
companyCode,
companyName,
menuObjId,
labels,
});
const response: ApiResponse<typeof results> = {
success: true,
message: `${results.length}개의 다국어 키가 생성되었습니다.`,
data: results,
};
res.status(200).json(response);
} catch (error) {
logger.error("화면 라벨 다국어 키 생성 실패:", error);
res.status(500).json({
success: false,
message: "화면 라벨 다국어 키 생성 중 오류가 발생했습니다.",
error: {
code: "SCREEN_LABEL_KEY_GENERATION_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};

View File

@ -217,11 +217,14 @@ router.post("/:ruleId/allocate", authenticateToken, async (req: AuthenticatedReq
const companyCode = req.user!.companyCode; const companyCode = req.user!.companyCode;
const { ruleId } = req.params; const { ruleId } = req.params;
logger.info("코드 할당 요청", { ruleId, companyCode });
try { try {
const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode); const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode);
logger.info("코드 할당 성공", { ruleId, allocatedCode });
return res.json({ success: true, data: { generatedCode: allocatedCode } }); return res.json({ success: true, data: { generatedCode: allocatedCode } });
} catch (error: any) { } catch (error: any) {
logger.error("코드 할당 실패", { error: error.message }); logger.error("코드 할당 실패", { ruleId, companyCode, error: error.message });
return res.status(500).json({ success: false, error: error.message }); return res.status(500).json({ success: false, error: error.message });
} }
}); });

View File

@ -1,276 +0,0 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
/**
*
* 형식: ORD + YYMMDD + 4 퀀
* : ORD250114001
*/
async function generateOrderNumber(companyCode: string): Promise<string> {
const pool = getPool();
const today = new Date();
const year = today.getFullYear().toString().slice(2); // 25
const month = String(today.getMonth() + 1).padStart(2, "0"); // 01
const day = String(today.getDate()).padStart(2, "0"); // 14
const dateStr = `${year}${month}${day}`; // 250114
// 당일 수주 카운트 조회
const countQuery = `
SELECT COUNT(*) as count
FROM order_mng_master
WHERE objid LIKE $1
AND writer LIKE $2
`;
const pattern = `ORD${dateStr}%`;
const result = await pool.query(countQuery, [pattern, `%${companyCode}%`]);
const count = parseInt(result.rows[0]?.count || "0");
const seq = count + 1;
return `ORD${dateStr}${String(seq).padStart(4, "0")}`; // ORD250114001
}
/**
* API
* POST /api/orders
*/
export async function createOrder(req: AuthenticatedRequest, res: Response) {
const pool = getPool();
try {
const {
inputMode, // 입력 방식
customerCode, // 거래처 코드
deliveryDate, // 납품일
items, // 품목 목록
memo, // 메모
} = req.body;
// 멀티테넌시
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
// 유효성 검사
if (!customerCode) {
return res.status(400).json({
success: false,
message: "거래처 코드는 필수입니다",
});
}
if (!items || items.length === 0) {
return res.status(400).json({
success: false,
message: "품목은 최소 1개 이상 필요합니다",
});
}
// 수주 번호 생성
const orderNo = await generateOrderNumber(companyCode);
// 전체 금액 계산
const totalAmount = items.reduce(
(sum: number, item: any) => sum + (item.amount || 0),
0
);
// 수주 마스터 생성
const masterQuery = `
INSERT INTO order_mng_master (
objid,
partner_objid,
final_delivery_date,
reason,
status,
reg_date,
writer
) VALUES ($1, $2, $3, $4, $5, NOW(), $6)
RETURNING *
`;
const masterResult = await pool.query(masterQuery, [
orderNo,
customerCode,
deliveryDate || null,
memo || null,
"진행중",
`${userId}|${companyCode}`,
]);
const masterObjid = masterResult.rows[0].objid;
// 수주 상세 (품목) 생성
for (let i = 0; i < items.length; i++) {
const item = items[i];
const subObjid = `${orderNo}_${i + 1}`;
const subQuery = `
INSERT INTO order_mng_sub (
objid,
order_mng_master_objid,
part_objid,
partner_objid,
partner_price,
partner_qty,
delivery_date,
status,
regdate,
writer
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9)
`;
await pool.query(subQuery, [
subObjid,
masterObjid,
item.item_code || item.id, // 품목 코드
customerCode,
item.unit_price || 0,
item.quantity || 0,
item.delivery_date || deliveryDate || null,
"진행중",
`${userId}|${companyCode}`,
]);
}
logger.info("수주 등록 성공", {
companyCode,
orderNo,
masterObjid,
itemCount: items.length,
totalAmount,
});
res.json({
success: true,
data: {
orderNo,
masterObjid,
itemCount: items.length,
totalAmount,
},
message: "수주가 등록되었습니다",
});
} catch (error: any) {
logger.error("수주 등록 오류", {
error: error.message,
stack: error.stack,
});
res.status(500).json({
success: false,
message: error.message || "수주 등록 중 오류가 발생했습니다",
});
}
}
/**
* API ( + JOIN)
* GET /api/orders
*/
export async function getOrders(req: AuthenticatedRequest, res: Response) {
const pool = getPool();
try {
const { page = "1", limit = "20", searchText = "" } = req.query;
const companyCode = req.user!.companyCode;
const offset = (parseInt(page as string) - 1) * parseInt(limit as string);
// WHERE 조건
const whereConditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
// 멀티테넌시 (writer 필드에 company_code 포함)
if (companyCode !== "*") {
whereConditions.push(`m.writer LIKE $${paramIndex}`);
params.push(`%${companyCode}%`);
paramIndex++;
}
// 검색
if (searchText) {
whereConditions.push(`m.objid LIKE $${paramIndex}`);
params.push(`%${searchText}%`);
paramIndex++;
}
const whereClause =
whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
// 카운트 쿼리 (고유한 수주 개수)
const countQuery = `
SELECT COUNT(DISTINCT m.objid) as count
FROM order_mng_master m
${whereClause}
`;
const countResult = await pool.query(countQuery, params);
const total = parseInt(countResult.rows[0]?.count || "0");
// 데이터 쿼리 (마스터 + 품목 JOIN)
const dataQuery = `
SELECT
m.objid as order_no,
m.partner_objid,
m.final_delivery_date,
m.reason,
m.status,
m.reg_date,
m.writer,
COALESCE(
json_agg(
CASE WHEN s.objid IS NOT NULL THEN
json_build_object(
'sub_objid', s.objid,
'part_objid', s.part_objid,
'partner_price', s.partner_price,
'partner_qty', s.partner_qty,
'delivery_date', s.delivery_date,
'status', s.status,
'regdate', s.regdate
)
END
ORDER BY s.regdate
) FILTER (WHERE s.objid IS NOT NULL),
'[]'::json
) as items
FROM order_mng_master m
LEFT JOIN order_mng_sub s ON m.objid = s.order_mng_master_objid
${whereClause}
GROUP BY m.objid, m.partner_objid, m.final_delivery_date, m.reason, m.status, m.reg_date, m.writer
ORDER BY m.reg_date DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
params.push(parseInt(limit as string));
params.push(offset);
const dataResult = await pool.query(dataQuery, params);
logger.info("수주 목록 조회 성공", {
companyCode,
total,
page: parseInt(page as string),
itemCount: dataResult.rows.length,
});
res.json({
success: true,
data: dataResult.rows,
pagination: {
total,
page: parseInt(page as string),
limit: parseInt(limit as string),
},
});
} catch (error: any) {
logger.error("수주 목록 조회 오류", { error: error.message });
res.status(500).json({
success: false,
message: error.message,
});
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -148,11 +148,42 @@ export const updateScreenInfo = async (
try { try {
const { id } = req.params; const { id } = req.params;
const { companyCode } = req.user as any; const { companyCode } = req.user as any;
const { screenName, tableName, description, isActive } = req.body; const {
screenName,
tableName,
description,
isActive,
// REST API 관련 필드 추가
dataSourceType,
dbSourceType,
dbConnectionId,
restApiConnectionId,
restApiEndpoint,
restApiJsonPath,
} = req.body;
console.log("화면 정보 수정 요청:", {
screenId: id,
dataSourceType,
restApiConnectionId,
restApiEndpoint,
restApiJsonPath,
});
await screenManagementService.updateScreenInfo( await screenManagementService.updateScreenInfo(
parseInt(id), parseInt(id),
{ screenName, tableName, description, isActive }, {
screenName,
tableName,
description,
isActive,
dataSourceType,
dbSourceType,
dbConnectionId,
restApiConnectionId,
restApiEndpoint,
restApiJsonPath,
},
companyCode companyCode
); );
res.json({ success: true, message: "화면 정보가 수정되었습니다." }); res.json({ success: true, message: "화면 정보가 수정되었습니다." });
@ -294,6 +325,53 @@ export const getDeletedScreens = async (
} }
}; };
// 활성 화면 일괄 삭제 (휴지통으로 이동)
export const bulkDeleteScreens = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { companyCode, userId } = req.user as any;
const { screenIds, deleteReason, force } = req.body;
if (!Array.isArray(screenIds) || screenIds.length === 0) {
return res.status(400).json({
success: false,
message: "삭제할 화면 ID 목록이 필요합니다.",
});
}
const result = await screenManagementService.bulkDeleteScreens(
screenIds,
companyCode,
userId,
deleteReason,
force || false
);
let message = `${result.deletedCount}개 화면이 휴지통으로 이동되었습니다.`;
if (result.skippedCount > 0) {
message += ` (${result.skippedCount}개 화면은 삭제되지 않았습니다.)`;
}
return res.json({
success: true,
message,
result: {
deletedCount: result.deletedCount,
skippedCount: result.skippedCount,
errors: result.errors,
},
});
} catch (error) {
console.error("활성 화면 일괄 삭제 실패:", error);
return res.status(500).json({
success: false,
message: "일괄 삭제에 실패했습니다.",
});
}
};
// 휴지통 화면 일괄 영구 삭제 // 휴지통 화면 일괄 영구 삭제
export const bulkPermanentDeleteScreens = async ( export const bulkPermanentDeleteScreens = async (
req: AuthenticatedRequest, req: AuthenticatedRequest,

View File

@ -30,6 +30,29 @@ export const getCategoryColumns = async (req: AuthenticatedRequest, res: Respons
} }
}; };
/**
* (Select )
*/
export const getAllCategoryColumns = async (req: AuthenticatedRequest, res: Response) => {
try {
const companyCode = req.user!.companyCode;
const columns = await tableCategoryValueService.getAllCategoryColumns(companyCode);
return res.json({
success: true,
data: columns,
});
} catch (error: any) {
logger.error(`전체 카테고리 컬럼 조회 실패: ${error.message}`);
return res.status(500).json({
success: false,
message: "전체 카테고리 컬럼 조회 중 오류가 발생했습니다",
error: error.message,
});
}
};
/** /**
* ( ) * ( )
* *
@ -527,6 +550,53 @@ export const deleteColumnMappingsByColumn = async (req: AuthenticatedRequest, re
} }
}; };
/**
*
*
* POST /api/table-categories/labels-by-codes
*
* Body:
* - valueCodes: 카테고리 (: ["CATEGORY_767659DCUF", "CATEGORY_8292565608"])
*
* Response:
* - { [code]: label }
*/
export const getCategoryLabelsByCodes = async (req: AuthenticatedRequest, res: Response) => {
try {
const companyCode = req.user!.companyCode;
const { valueCodes } = req.body;
if (!valueCodes || !Array.isArray(valueCodes) || valueCodes.length === 0) {
return res.json({
success: true,
data: {},
});
}
logger.info("카테고리 코드로 라벨 조회", {
valueCodes,
companyCode,
});
const labels = await tableCategoryValueService.getCategoryLabelsByCodes(
valueCodes,
companyCode
);
return res.json({
success: true,
data: labels,
});
} catch (error: any) {
logger.error(`카테고리 라벨 조회 실패: ${error.message}`);
return res.status(500).json({
success: false,
message: "카테고리 라벨 조회 중 오류가 발생했습니다",
error: error.message,
});
}
};
/** /**
* 2 * 2
* *

View File

@ -67,7 +67,7 @@ export class TableHistoryController {
const whereClause = whereConditions.join(" AND "); const whereClause = whereConditions.join(" AND ");
// 이력 조회 쿼리 // 이력 조회 쿼리 (log_id로 정렬 - 시간 데이터 불일치 문제 해결)
const historyQuery = ` const historyQuery = `
SELECT SELECT
log_id, log_id,
@ -84,7 +84,7 @@ export class TableHistoryController {
full_row_after full_row_after
FROM ${logTableName} FROM ${logTableName}
WHERE ${whereClause} WHERE ${whereClause}
ORDER BY changed_at DESC ORDER BY log_id DESC
LIMIT ${limitParam} OFFSET ${offsetParam} LIMIT ${limitParam} OFFSET ${offsetParam}
`; `;
@ -196,7 +196,7 @@ export class TableHistoryController {
const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : ""; const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : "";
// 이력 조회 쿼리 // 이력 조회 쿼리 (log_id로 정렬 - 시간 데이터 불일치 문제 해결)
const historyQuery = ` const historyQuery = `
SELECT SELECT
log_id, log_id,
@ -213,7 +213,7 @@ export class TableHistoryController {
full_row_after full_row_after
FROM ${logTableName} FROM ${logTableName}
${whereClause} ${whereClause}
ORDER BY changed_at DESC ORDER BY log_id DESC
LIMIT ${limitParam} OFFSET ${offsetParam} LIMIT ${limitParam} OFFSET ${offsetParam}
`; `;

View File

@ -767,20 +767,33 @@ export async function getTableData(
const tableManagementService = new TableManagementService(); const tableManagementService = new TableManagementService();
// 🆕 현재 사용자 필터 적용 // 🆕 현재 사용자 필터 적용 (autoFilter가 없거나 enabled가 명시적으로 false가 아니면 기본 적용)
let enhancedSearch = { ...search }; let enhancedSearch = { ...search };
if (autoFilter?.enabled && req.user) { const shouldApplyAutoFilter = autoFilter?.enabled !== false; // 기본값: true
const filterColumn = autoFilter.filterColumn || "company_code"; if (shouldApplyAutoFilter && req.user) {
const userField = autoFilter.userField || "companyCode"; const filterColumn = autoFilter?.filterColumn || "company_code";
const userField = autoFilter?.userField || "companyCode";
const userValue = (req.user as any)[userField]; const userValue = (req.user as any)[userField];
if (userValue) { // 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 허용)
enhancedSearch[filterColumn] = userValue; let finalCompanyCode = userValue;
if (autoFilter?.companyCodeOverride && userValue === "*") {
// 최고 관리자만 다른 회사 코드로 오버라이드 가능
finalCompanyCode = autoFilter.companyCodeOverride;
logger.info("🔓 최고 관리자 회사 코드 오버라이드:", {
originalCompanyCode: userValue,
overrideCompanyCode: autoFilter.companyCodeOverride,
tableName,
});
}
if (finalCompanyCode) {
enhancedSearch[filterColumn] = finalCompanyCode;
logger.info("🔍 현재 사용자 필터 적용:", { logger.info("🔍 현재 사용자 필터 적용:", {
filterColumn, filterColumn,
userField, userField,
userValue, userValue: finalCompanyCode,
tableName, tableName,
}); });
} else { } else {
@ -870,6 +883,27 @@ export async function addTableData(
const tableManagementService = new TableManagementService(); const tableManagementService = new TableManagementService();
// 🆕 멀티테넌시: company_code 자동 추가 (테이블에 company_code 컬럼이 있는 경우)
const companyCode = req.user?.companyCode;
if (companyCode && !data.company_code) {
// 테이블에 company_code 컬럼이 있는지 확인
const hasCompanyCodeColumn = await tableManagementService.hasColumn(tableName, "company_code");
if (hasCompanyCodeColumn) {
data.company_code = companyCode;
logger.info(`멀티테넌시: company_code 자동 추가 - ${companyCode}`);
}
}
// 🆕 writer 컬럼 자동 추가 (테이블에 writer 컬럼이 있고 값이 없는 경우)
const userId = req.user?.userId;
if (userId && !data.writer) {
const hasWriterColumn = await tableManagementService.hasColumn(tableName, "writer");
if (hasWriterColumn) {
data.writer = userId;
logger.info(`writer 자동 추가 - ${userId}`);
}
}
// 데이터 추가 // 데이터 추가
await tableManagementService.addTableData(tableName, data); await tableManagementService.addTableData(tableName, data);
@ -1800,3 +1834,449 @@ export async function getCategoryColumnsByMenu(
}); });
} }
} }
/**
* API
*
* () .
*
* :
* {
* mainTable: { tableName: string, primaryKeyColumn: string },
* mainData: Record<string, any>,
* subTables: Array<{
* tableName: string,
* linkColumn: { mainField: string, subColumn: string },
* items: Record<string, any>[],
* options?: {
* saveMainAsFirst?: boolean,
* mainFieldMappings?: Array<{ formField: string, targetColumn: string }>,
* mainMarkerColumn?: string,
* mainMarkerValue?: any,
* subMarkerValue?: any,
* deleteExistingBefore?: boolean,
* }
* }>,
* isUpdate?: boolean
* }
*/
export async function multiTableSave(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
const pool = require("../database/db").getPool();
const client = await pool.connect();
try {
const { mainTable, mainData, subTables, isUpdate } = req.body;
const companyCode = req.user?.companyCode || "*";
logger.info("=== 다중 테이블 저장 시작 ===", {
mainTable,
mainDataKeys: Object.keys(mainData || {}),
subTablesCount: subTables?.length || 0,
isUpdate,
companyCode,
});
// 유효성 검사
if (!mainTable?.tableName || !mainTable?.primaryKeyColumn) {
res.status(400).json({
success: false,
message: "메인 테이블 설정이 올바르지 않습니다.",
});
return;
}
if (!mainData || Object.keys(mainData).length === 0) {
res.status(400).json({
success: false,
message: "저장할 메인 데이터가 없습니다.",
});
return;
}
await client.query("BEGIN");
// 1. 메인 테이블 저장
const mainTableName = mainTable.tableName;
const pkColumn = mainTable.primaryKeyColumn;
const pkValue = mainData[pkColumn];
// company_code 자동 추가 (최고 관리자가 아닌 경우)
if (companyCode !== "*" && !mainData.company_code) {
mainData.company_code = companyCode;
}
let mainResult: any;
if (isUpdate && pkValue) {
// UPDATE
const updateColumns = Object.keys(mainData)
.filter(col => col !== pkColumn)
.map((col, idx) => `"${col}" = $${idx + 1}`)
.join(", ");
const updateValues = Object.keys(mainData)
.filter(col => col !== pkColumn)
.map(col => mainData[col]);
// updated_at 컬럼 존재 여부 확인
const hasUpdatedAt = await client.query(`
SELECT 1 FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'updated_at'
`, [mainTableName]);
const updatedAtClause = hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 ? ", updated_at = NOW()" : "";
const updateQuery = `
UPDATE "${mainTableName}"
SET ${updateColumns}${updatedAtClause}
WHERE "${pkColumn}" = $${updateValues.length + 1}
${companyCode !== "*" ? `AND company_code = $${updateValues.length + 2}` : ""}
RETURNING *
`;
const updateParams = companyCode !== "*"
? [...updateValues, pkValue, companyCode]
: [...updateValues, pkValue];
logger.info("메인 테이블 UPDATE:", { query: updateQuery, paramsCount: updateParams.length });
mainResult = await client.query(updateQuery, updateParams);
} else {
// INSERT
const columns = Object.keys(mainData).map(col => `"${col}"`).join(", ");
const placeholders = Object.keys(mainData).map((_, idx) => `$${idx + 1}`).join(", ");
const values = Object.values(mainData);
// updated_at 컬럼 존재 여부 확인
const hasUpdatedAt = await client.query(`
SELECT 1 FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'updated_at'
`, [mainTableName]);
const updatedAtClause = hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 ? ", updated_at = NOW()" : "";
const updateSetClause = Object.keys(mainData)
.filter(col => col !== pkColumn)
.map(col => `"${col}" = EXCLUDED."${col}"`)
.join(", ");
const insertQuery = `
INSERT INTO "${mainTableName}" (${columns})
VALUES (${placeholders})
ON CONFLICT ("${pkColumn}") DO UPDATE SET
${updateSetClause}${updatedAtClause}
RETURNING *
`;
logger.info("메인 테이블 INSERT/UPSERT:", { query: insertQuery, paramsCount: values.length });
mainResult = await client.query(insertQuery, values);
}
if (mainResult.rowCount === 0) {
throw new Error("메인 테이블 저장 실패");
}
const savedMainData = mainResult.rows[0];
const savedPkValue = savedMainData[pkColumn];
logger.info("메인 테이블 저장 완료:", { pkColumn, savedPkValue });
// 2. 서브 테이블 저장
const subTableResults: any[] = [];
for (const subTableConfig of subTables || []) {
const { tableName, linkColumn, items, options } = subTableConfig;
// saveMainAsFirst가 활성화된 경우, items가 비어있어도 메인 데이터를 서브 테이블에 저장해야 함
const hasSaveMainAsFirst = options?.saveMainAsFirst &&
options?.mainFieldMappings &&
options.mainFieldMappings.length > 0;
if (!tableName || (!items?.length && !hasSaveMainAsFirst)) {
logger.info(`서브 테이블 ${tableName} 스킵: 데이터 없음 (saveMainAsFirst: ${hasSaveMainAsFirst})`);
continue;
}
logger.info(`서브 테이블 ${tableName} 저장 시작:`, {
itemsCount: items?.length || 0,
linkColumn,
options,
hasSaveMainAsFirst,
});
// 기존 데이터 삭제 옵션
if (options?.deleteExistingBefore && linkColumn?.subColumn) {
const deleteQuery = options?.deleteOnlySubItems && options?.mainMarkerColumn
? `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1 AND "${options.mainMarkerColumn}" = $2`
: `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1`;
const deleteParams = options?.deleteOnlySubItems && options?.mainMarkerColumn
? [savedPkValue, options.subMarkerValue ?? false]
: [savedPkValue];
logger.info(`서브 테이블 ${tableName} 기존 데이터 삭제:`, { deleteQuery, deleteParams });
await client.query(deleteQuery, deleteParams);
}
// 메인 데이터도 서브 테이블에 저장 (옵션)
// mainFieldMappings가 비어 있으면 건너뜀 (필수 컬럼 누락 방지)
logger.info(`saveMainAsFirst 옵션 확인:`, {
saveMainAsFirst: options?.saveMainAsFirst,
mainFieldMappings: options?.mainFieldMappings,
mainFieldMappingsLength: options?.mainFieldMappings?.length,
linkColumn,
mainDataKeys: Object.keys(mainData),
});
if (options?.saveMainAsFirst && options?.mainFieldMappings && options.mainFieldMappings.length > 0 && linkColumn?.subColumn) {
const mainSubItem: Record<string, any> = {
[linkColumn.subColumn]: savedPkValue,
};
// 메인 필드 매핑 적용
for (const mapping of options.mainFieldMappings) {
if (mapping.formField && mapping.targetColumn) {
mainSubItem[mapping.targetColumn] = mainData[mapping.formField];
}
}
// 메인 마커 설정
if (options.mainMarkerColumn) {
mainSubItem[options.mainMarkerColumn] = options.mainMarkerValue ?? true;
}
// company_code 추가
if (companyCode !== "*") {
mainSubItem.company_code = companyCode;
}
// 먼저 기존 데이터 존재 여부 확인 (user_id + is_primary 조합)
const checkQuery = `
SELECT * FROM "${tableName}"
WHERE "${linkColumn.subColumn}" = $1
${options.mainMarkerColumn ? `AND "${options.mainMarkerColumn}" = $2` : ""}
${companyCode !== "*" ? `AND company_code = $${options.mainMarkerColumn ? 3 : 2}` : ""}
LIMIT 1
`;
const checkParams: any[] = [savedPkValue];
if (options.mainMarkerColumn) {
checkParams.push(options.mainMarkerValue ?? true);
}
if (companyCode !== "*") {
checkParams.push(companyCode);
}
const existingResult = await client.query(checkQuery, checkParams);
if (existingResult.rows.length > 0) {
// UPDATE
const updateColumns = Object.keys(mainSubItem)
.filter(col => col !== linkColumn.subColumn && col !== options.mainMarkerColumn && col !== "company_code")
.map((col, idx) => `"${col}" = $${idx + 1}`)
.join(", ");
const updateValues = Object.keys(mainSubItem)
.filter(col => col !== linkColumn.subColumn && col !== options.mainMarkerColumn && col !== "company_code")
.map(col => mainSubItem[col]);
if (updateColumns) {
const updateQuery = `
UPDATE "${tableName}"
SET ${updateColumns}
WHERE "${linkColumn.subColumn}" = $${updateValues.length + 1}
${options.mainMarkerColumn ? `AND "${options.mainMarkerColumn}" = $${updateValues.length + 2}` : ""}
${companyCode !== "*" ? `AND company_code = $${updateValues.length + (options.mainMarkerColumn ? 3 : 2)}` : ""}
RETURNING *
`;
const updateParams = [...updateValues, savedPkValue];
if (options.mainMarkerColumn) {
updateParams.push(options.mainMarkerValue ?? true);
}
if (companyCode !== "*") {
updateParams.push(companyCode);
}
const updateResult = await client.query(updateQuery, updateParams);
subTableResults.push({ tableName, type: "main", data: updateResult.rows[0] });
} else {
subTableResults.push({ tableName, type: "main", data: existingResult.rows[0] });
}
} else {
// INSERT
const mainSubColumns = Object.keys(mainSubItem).map(col => `"${col}"`).join(", ");
const mainSubPlaceholders = Object.keys(mainSubItem).map((_, idx) => `$${idx + 1}`).join(", ");
const mainSubValues = Object.values(mainSubItem);
const insertQuery = `
INSERT INTO "${tableName}" (${mainSubColumns})
VALUES (${mainSubPlaceholders})
RETURNING *
`;
const insertResult = await client.query(insertQuery, mainSubValues);
subTableResults.push({ tableName, type: "main", data: insertResult.rows[0] });
}
}
// 서브 아이템들 저장
for (const item of items) {
// 연결 컬럼 값 설정
if (linkColumn?.subColumn) {
item[linkColumn.subColumn] = savedPkValue;
}
// company_code 추가
if (companyCode !== "*" && !item.company_code) {
item.company_code = companyCode;
}
const subColumns = Object.keys(item).map(col => `"${col}"`).join(", ");
const subPlaceholders = Object.keys(item).map((_, idx) => `$${idx + 1}`).join(", ");
const subValues = Object.values(item);
const subInsertQuery = `
INSERT INTO "${tableName}" (${subColumns})
VALUES (${subPlaceholders})
RETURNING *
`;
logger.info(`서브 테이블 ${tableName} 아이템 저장:`, { subInsertQuery, subValuesCount: subValues.length });
const subResult = await client.query(subInsertQuery, subValues);
subTableResults.push({ tableName, type: "sub", data: subResult.rows[0] });
}
logger.info(`서브 테이블 ${tableName} 저장 완료`);
}
await client.query("COMMIT");
logger.info("=== 다중 테이블 저장 완료 ===", {
mainTable: mainTableName,
mainPk: savedPkValue,
subTableResultsCount: subTableResults.length,
});
res.json({
success: true,
message: "다중 테이블 저장이 완료되었습니다.",
data: {
main: savedMainData,
subTables: subTableResults,
},
});
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("다중 테이블 저장 실패:", {
message: error.message,
stack: error.stack,
});
res.status(500).json({
success: false,
message: error.message || "다중 테이블 저장에 실패했습니다.",
error: error.message,
});
} finally {
client.release();
}
}
/**
*
* column_labels의 entity/category
*/
export async function getTableEntityRelations(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { leftTable, rightTable } = req.query;
if (!leftTable || !rightTable) {
res.status(400).json({
success: false,
message: "leftTable과 rightTable 파라미터가 필요합니다.",
});
return;
}
logger.info("=== 테이블 엔티티 관계 조회 ===", { leftTable, rightTable });
// 두 테이블의 컬럼 라벨 정보 조회
const columnLabelsQuery = `
SELECT
table_name,
column_name,
column_label,
web_type,
detail_settings
FROM column_labels
WHERE table_name IN ($1, $2)
AND web_type IN ('entity', 'category')
`;
const result = await query(columnLabelsQuery, [leftTable, rightTable]);
// 관계 분석
const relations: Array<{
fromTable: string;
fromColumn: string;
toTable: string;
toColumn: string;
relationType: string;
}> = [];
for (const row of result) {
try {
const detailSettings = typeof row.detail_settings === "string"
? JSON.parse(row.detail_settings)
: row.detail_settings;
if (detailSettings && detailSettings.referenceTable) {
const refTable = detailSettings.referenceTable;
const refColumn = detailSettings.referenceColumn || "id";
// leftTable과 rightTable 간의 관계인지 확인
if (
(row.table_name === leftTable && refTable === rightTable) ||
(row.table_name === rightTable && refTable === leftTable)
) {
relations.push({
fromTable: row.table_name,
fromColumn: row.column_name,
toTable: refTable,
toColumn: refColumn,
relationType: row.web_type,
});
}
}
} catch (parseError) {
logger.warn("detail_settings 파싱 오류:", {
table: row.table_name,
column: row.column_name,
error: parseError
});
}
}
logger.info("테이블 엔티티 관계 조회 완료", {
leftTable,
rightTable,
relationsCount: relations.length
});
res.json({
success: true,
data: {
leftTable,
rightTable,
relations,
},
});
} catch (error: any) {
logger.error("테이블 엔티티 관계 조회 실패:", error);
res.status(500).json({
success: false,
message: "테이블 엔티티 관계 조회에 실패했습니다.",
error: error.message,
});
}
}

View File

@ -0,0 +1,365 @@
/**
*
* API
*/
import { Request, Response } from "express";
import { TaxInvoiceService } from "../services/taxInvoiceService";
import { logger } from "../utils/logger";
interface AuthenticatedRequest extends Request {
user?: {
userId: string;
companyCode: string;
};
}
export class TaxInvoiceController {
/**
*
* GET /api/tax-invoice
*/
static async getList(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
return;
}
const {
page = "1",
pageSize = "20",
invoice_type,
invoice_status,
start_date,
end_date,
search,
buyer_name,
cost_type,
} = req.query;
const result = await TaxInvoiceService.getList(companyCode, {
page: parseInt(page as string, 10),
pageSize: parseInt(pageSize as string, 10),
invoice_type: invoice_type as "sales" | "purchase" | undefined,
invoice_status: invoice_status as string | undefined,
start_date: start_date as string | undefined,
end_date: end_date as string | undefined,
search: search as string | undefined,
buyer_name: buyer_name as string | undefined,
cost_type: cost_type as any,
});
res.json({
success: true,
data: result.data,
pagination: {
page: result.page,
pageSize: result.pageSize,
total: result.total,
totalPages: Math.ceil(result.total / result.pageSize),
},
});
} catch (error: any) {
logger.error("세금계산서 목록 조회 실패:", error);
res.status(500).json({
success: false,
message: error.message || "세금계산서 목록 조회 중 오류가 발생했습니다.",
});
}
}
/**
*
* GET /api/tax-invoice/:id
*/
static async getById(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
return;
}
const { id } = req.params;
const result = await TaxInvoiceService.getById(id, companyCode);
if (!result) {
res.status(404).json({ success: false, message: "세금계산서를 찾을 수 없습니다." });
return;
}
res.json({
success: true,
data: result,
});
} catch (error: any) {
logger.error("세금계산서 상세 조회 실패:", error);
res.status(500).json({
success: false,
message: error.message || "세금계산서 조회 중 오류가 발생했습니다.",
});
}
}
/**
*
* POST /api/tax-invoice
*/
static async create(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
const userId = req.user?.userId;
if (!companyCode || !userId) {
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
return;
}
const data = req.body;
// 필수 필드 검증
if (!data.invoice_type) {
res.status(400).json({ success: false, message: "세금계산서 유형은 필수입니다." });
return;
}
if (!data.invoice_date) {
res.status(400).json({ success: false, message: "작성일자는 필수입니다." });
return;
}
if (data.supply_amount === undefined || data.supply_amount === null) {
res.status(400).json({ success: false, message: "공급가액은 필수입니다." });
return;
}
const result = await TaxInvoiceService.create(data, companyCode, userId);
res.status(201).json({
success: true,
data: result,
message: "세금계산서가 생성되었습니다.",
});
} catch (error: any) {
logger.error("세금계산서 생성 실패:", error);
res.status(500).json({
success: false,
message: error.message || "세금계산서 생성 중 오류가 발생했습니다.",
});
}
}
/**
*
* PUT /api/tax-invoice/:id
*/
static async update(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
const userId = req.user?.userId;
if (!companyCode || !userId) {
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
return;
}
const { id } = req.params;
const data = req.body;
const result = await TaxInvoiceService.update(id, data, companyCode, userId);
if (!result) {
res.status(404).json({ success: false, message: "세금계산서를 찾을 수 없습니다." });
return;
}
res.json({
success: true,
data: result,
message: "세금계산서가 수정되었습니다.",
});
} catch (error: any) {
logger.error("세금계산서 수정 실패:", error);
res.status(500).json({
success: false,
message: error.message || "세금계산서 수정 중 오류가 발생했습니다.",
});
}
}
/**
*
* DELETE /api/tax-invoice/:id
*/
static async delete(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
const userId = req.user?.userId;
if (!companyCode || !userId) {
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
return;
}
const { id } = req.params;
const result = await TaxInvoiceService.delete(id, companyCode, userId);
if (!result) {
res.status(404).json({ success: false, message: "세금계산서를 찾을 수 없습니다." });
return;
}
res.json({
success: true,
message: "세금계산서가 삭제되었습니다.",
});
} catch (error: any) {
logger.error("세금계산서 삭제 실패:", error);
res.status(500).json({
success: false,
message: error.message || "세금계산서 삭제 중 오류가 발생했습니다.",
});
}
}
/**
*
* POST /api/tax-invoice/:id/issue
*/
static async issue(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
const userId = req.user?.userId;
if (!companyCode || !userId) {
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
return;
}
const { id } = req.params;
const result = await TaxInvoiceService.issue(id, companyCode, userId);
if (!result) {
res.status(404).json({
success: false,
message: "세금계산서를 찾을 수 없거나 이미 발행된 상태입니다.",
});
return;
}
res.json({
success: true,
data: result,
message: "세금계산서가 발행되었습니다.",
});
} catch (error: any) {
logger.error("세금계산서 발행 실패:", error);
res.status(500).json({
success: false,
message: error.message || "세금계산서 발행 중 오류가 발생했습니다.",
});
}
}
/**
*
* POST /api/tax-invoice/:id/cancel
*/
static async cancel(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
const userId = req.user?.userId;
if (!companyCode || !userId) {
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
return;
}
const { id } = req.params;
const { reason } = req.body;
const result = await TaxInvoiceService.cancel(id, companyCode, userId, reason);
if (!result) {
res.status(404).json({
success: false,
message: "세금계산서를 찾을 수 없거나 취소할 수 없는 상태입니다.",
});
return;
}
res.json({
success: true,
data: result,
message: "세금계산서가 취소되었습니다.",
});
} catch (error: any) {
logger.error("세금계산서 취소 실패:", error);
res.status(500).json({
success: false,
message: error.message || "세금계산서 취소 중 오류가 발생했습니다.",
});
}
}
/**
*
* GET /api/tax-invoice/stats/monthly
*/
static async getMonthlyStats(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
return;
}
const { year, month } = req.query;
const now = new Date();
const targetYear = year ? parseInt(year as string, 10) : now.getFullYear();
const targetMonth = month ? parseInt(month as string, 10) : now.getMonth() + 1;
const result = await TaxInvoiceService.getMonthlyStats(companyCode, targetYear, targetMonth);
res.json({
success: true,
data: result,
period: { year: targetYear, month: targetMonth },
});
} catch (error: any) {
logger.error("월별 통계 조회 실패:", error);
res.status(500).json({
success: false,
message: error.message || "통계 조회 중 오류가 발생했습니다.",
});
}
}
/**
*
* GET /api/tax-invoice/stats/cost-type
*/
static async getCostTypeStats(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
return;
}
const { year, month } = req.query;
const targetYear = year ? parseInt(year as string, 10) : undefined;
const targetMonth = month ? parseInt(month as string, 10) : undefined;
const result = await TaxInvoiceService.getCostTypeStats(companyCode, targetYear, targetMonth);
res.json({
success: true,
data: result,
period: { year: targetYear, month: targetMonth },
});
} catch (error: any) {
logger.error("비용 유형별 통계 조회 실패:", error);
res.status(500).json({
success: false,
message: error.message || "통계 조회 중 오류가 발생했습니다.",
});
}
}
}

View File

@ -0,0 +1,206 @@
/**
*
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../middleware/authMiddleware";
import { vehicleReportService } from "../services/vehicleReportService";
/**
*
* GET /api/vehicle/reports/daily
*/
export const getDailyReport = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode } = req.user as any;
const { startDate, endDate, userId, vehicleId } = req.query;
console.log("📊 [getDailyReport] 요청:", { companyCode, startDate, endDate });
const result = await vehicleReportService.getDailyReport(companyCode, {
startDate: startDate as string,
endDate: endDate as string,
userId: userId as string,
vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined,
});
res.json({
success: true,
data: result,
});
} catch (error: any) {
console.error("❌ [getDailyReport] 실패:", error);
res.status(500).json({
success: false,
message: error.message || "일별 통계 조회에 실패했습니다.",
});
}
};
/**
*
* GET /api/vehicle/reports/weekly
*/
export const getWeeklyReport = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode } = req.user as any;
const { year, month, userId, vehicleId } = req.query;
console.log("📊 [getWeeklyReport] 요청:", { companyCode, year, month });
const result = await vehicleReportService.getWeeklyReport(companyCode, {
year: year ? parseInt(year as string) : new Date().getFullYear(),
month: month ? parseInt(month as string) : new Date().getMonth() + 1,
userId: userId as string,
vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined,
});
res.json({
success: true,
data: result,
});
} catch (error: any) {
console.error("❌ [getWeeklyReport] 실패:", error);
res.status(500).json({
success: false,
message: error.message || "주별 통계 조회에 실패했습니다.",
});
}
};
/**
*
* GET /api/vehicle/reports/monthly
*/
export const getMonthlyReport = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode } = req.user as any;
const { year, userId, vehicleId } = req.query;
console.log("📊 [getMonthlyReport] 요청:", { companyCode, year });
const result = await vehicleReportService.getMonthlyReport(companyCode, {
year: year ? parseInt(year as string) : new Date().getFullYear(),
userId: userId as string,
vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined,
});
res.json({
success: true,
data: result,
});
} catch (error: any) {
console.error("❌ [getMonthlyReport] 실패:", error);
res.status(500).json({
success: false,
message: error.message || "월별 통계 조회에 실패했습니다.",
});
}
};
/**
* ()
* GET /api/vehicle/reports/summary
*/
export const getSummaryReport = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode } = req.user as any;
const { period } = req.query; // today, week, month, year
console.log("📊 [getSummaryReport] 요청:", { companyCode, period });
const result = await vehicleReportService.getSummaryReport(
companyCode,
(period as string) || "today"
);
res.json({
success: true,
data: result,
});
} catch (error: any) {
console.error("❌ [getSummaryReport] 실패:", error);
res.status(500).json({
success: false,
message: error.message || "요약 통계 조회에 실패했습니다.",
});
}
};
/**
*
* GET /api/vehicle/reports/by-driver
*/
export const getDriverReport = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode } = req.user as any;
const { startDate, endDate, limit } = req.query;
console.log("📊 [getDriverReport] 요청:", { companyCode, startDate, endDate });
const result = await vehicleReportService.getDriverReport(companyCode, {
startDate: startDate as string,
endDate: endDate as string,
limit: limit ? parseInt(limit as string) : 10,
});
res.json({
success: true,
data: result,
});
} catch (error: any) {
console.error("❌ [getDriverReport] 실패:", error);
res.status(500).json({
success: false,
message: error.message || "운전자별 통계 조회에 실패했습니다.",
});
}
};
/**
*
* GET /api/vehicle/reports/by-route
*/
export const getRouteReport = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode } = req.user as any;
const { startDate, endDate, limit } = req.query;
console.log("📊 [getRouteReport] 요청:", { companyCode, startDate, endDate });
const result = await vehicleReportService.getRouteReport(companyCode, {
startDate: startDate as string,
endDate: endDate as string,
limit: limit ? parseInt(limit as string) : 10,
});
res.json({
success: true,
data: result,
});
} catch (error: any) {
console.error("❌ [getRouteReport] 실패:", error);
res.status(500).json({
success: false,
message: error.message || "구간별 통계 조회에 실패했습니다.",
});
}
};

View File

@ -0,0 +1,301 @@
/**
*
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../middleware/authMiddleware";
import { vehicleTripService } from "../services/vehicleTripService";
/**
*
* POST /api/vehicle/trip/start
*/
export const startTrip = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode, userId } = req.user as any;
const { vehicleId, departure, arrival, departureName, destinationName, latitude, longitude } = req.body;
console.log("🚗 [startTrip] 요청:", { userId, companyCode, departure, arrival });
if (latitude === undefined || longitude === undefined) {
return res.status(400).json({
success: false,
message: "위치 정보(latitude, longitude)가 필요합니다.",
});
}
const result = await vehicleTripService.startTrip({
userId,
companyCode,
vehicleId,
departure,
arrival,
departureName,
destinationName,
latitude,
longitude,
});
console.log("✅ [startTrip] 성공:", result);
res.json({
success: true,
data: result,
message: "운행이 시작되었습니다.",
});
} catch (error: any) {
console.error("❌ [startTrip] 실패:", error);
res.status(500).json({
success: false,
message: error.message || "운행 시작에 실패했습니다.",
});
}
};
/**
*
* POST /api/vehicle/trip/end
*/
export const endTrip = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode, userId } = req.user as any;
const { tripId, latitude, longitude } = req.body;
console.log("🚗 [endTrip] 요청:", { userId, companyCode, tripId });
if (!tripId) {
return res.status(400).json({
success: false,
message: "tripId가 필요합니다.",
});
}
if (latitude === undefined || longitude === undefined) {
return res.status(400).json({
success: false,
message: "위치 정보(latitude, longitude)가 필요합니다.",
});
}
const result = await vehicleTripService.endTrip({
tripId,
userId,
companyCode,
latitude,
longitude,
});
console.log("✅ [endTrip] 성공:", result);
res.json({
success: true,
data: result,
message: "운행이 종료되었습니다.",
});
} catch (error: any) {
console.error("❌ [endTrip] 실패:", error);
res.status(500).json({
success: false,
message: error.message || "운행 종료에 실패했습니다.",
});
}
};
/**
* ( )
* POST /api/vehicle/trip/location
*/
export const addTripLocation = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode, userId } = req.user as any;
const { tripId, latitude, longitude, accuracy, speed } = req.body;
if (!tripId) {
return res.status(400).json({
success: false,
message: "tripId가 필요합니다.",
});
}
if (latitude === undefined || longitude === undefined) {
return res.status(400).json({
success: false,
message: "위치 정보(latitude, longitude)가 필요합니다.",
});
}
const result = await vehicleTripService.addLocation({
tripId,
userId,
companyCode,
latitude,
longitude,
accuracy,
speed,
});
res.json({
success: true,
data: result,
});
} catch (error: any) {
console.error("❌ [addTripLocation] 실패:", error);
res.status(500).json({
success: false,
message: error.message || "위치 기록에 실패했습니다.",
});
}
};
/**
*
* GET /api/vehicle/trips
*/
export const getTripList = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode } = req.user as any;
const { userId, vehicleId, status, startDate, endDate, departure, arrival, limit, offset } = req.query;
console.log("🚗 [getTripList] 요청:", { companyCode, userId, status, startDate, endDate });
const result = await vehicleTripService.getTripList(companyCode, {
userId: userId as string,
vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined,
status: status as string,
startDate: startDate as string,
endDate: endDate as string,
departure: departure as string,
arrival: arrival as string,
limit: limit ? parseInt(limit as string) : 50,
offset: offset ? parseInt(offset as string) : 0,
});
res.json({
success: true,
data: result.data,
total: result.total,
});
} catch (error: any) {
console.error("❌ [getTripList] 실패:", error);
res.status(500).json({
success: false,
message: error.message || "운행 이력 조회에 실패했습니다.",
});
}
};
/**
* ( )
* GET /api/vehicle/trips/:tripId
*/
export const getTripDetail = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode } = req.user as any;
const { tripId } = req.params;
console.log("🚗 [getTripDetail] 요청:", { companyCode, tripId });
const result = await vehicleTripService.getTripDetail(tripId, companyCode);
if (!result) {
return res.status(404).json({
success: false,
message: "운행 정보를 찾을 수 없습니다.",
});
}
res.json({
success: true,
data: result,
});
} catch (error: any) {
console.error("❌ [getTripDetail] 실패:", error);
res.status(500).json({
success: false,
message: error.message || "운행 상세 조회에 실패했습니다.",
});
}
};
/**
* ( )
* GET /api/vehicle/trip/active
*/
export const getActiveTrip = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode, userId } = req.user as any;
const result = await vehicleTripService.getActiveTrip(userId, companyCode);
res.json({
success: true,
data: result,
hasActiveTrip: !!result,
});
} catch (error: any) {
console.error("❌ [getActiveTrip] 실패:", error);
res.status(500).json({
success: false,
message: error.message || "활성 운행 조회에 실패했습니다.",
});
}
};
/**
*
* POST /api/vehicle/trip/cancel
*/
export const cancelTrip = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode } = req.user as any;
const { tripId } = req.body;
if (!tripId) {
return res.status(400).json({
success: false,
message: "tripId가 필요합니다.",
});
}
const result = await vehicleTripService.cancelTrip(tripId, companyCode);
if (!result) {
return res.status(404).json({
success: false,
message: "취소할 운행을 찾을 수 없습니다.",
});
}
res.json({
success: true,
message: "운행이 취소되었습니다.",
});
} catch (error: any) {
console.error("❌ [cancelTrip] 실패:", error);
res.status(500).json({
success: false,
message: error.message || "운행 취소에 실패했습니다.",
});
}
};

View File

@ -54,16 +54,17 @@ export const authenticateToken = (
next(); next();
} catch (error) { } catch (error) {
logger.error( const errorMessage = error instanceof Error ? error.message : "Unknown error";
`인증 실패: ${error instanceof Error ? error.message : "Unknown error"} (${req.ip})` logger.error(`인증 실패: ${errorMessage} (${req.ip})`);
);
// 토큰 만료 에러인지 확인
const isTokenExpired = errorMessage.includes("만료");
res.status(401).json({ res.status(401).json({
success: false, success: false,
error: { error: {
code: "INVALID_TOKEN", code: isTokenExpired ? "TOKEN_EXPIRED" : "INVALID_TOKEN",
details: details: errorMessage || "토큰 검증에 실패했습니다.",
error instanceof Error ? error.message : "토큰 검증에 실패했습니다.",
}, },
}); });
} }

View File

@ -28,6 +28,16 @@ export const errorHandler = (
// PostgreSQL 에러 처리 (pg 라이브러리) // PostgreSQL 에러 처리 (pg 라이브러리)
if ((err as any).code) { if ((err as any).code) {
const pgError = err as any; const pgError = err as any;
// 원본 에러 메시지 로깅 (디버깅용)
console.error("🔴 PostgreSQL Error:", {
code: pgError.code,
message: pgError.message,
detail: pgError.detail,
hint: pgError.hint,
table: pgError.table,
column: pgError.column,
constraint: pgError.constraint,
});
// PostgreSQL 에러 코드 참조: https://www.postgresql.org/docs/current/errcodes-appendix.html // PostgreSQL 에러 코드 참조: https://www.postgresql.org/docs/current/errcodes-appendix.html
if (pgError.code === "23505") { if (pgError.code === "23505") {
// unique_violation // unique_violation
@ -42,7 +52,7 @@ export const errorHandler = (
// 기타 무결성 제약 조건 위반 // 기타 무결성 제약 조건 위반
error = new AppError("데이터 무결성 제약 조건 위반입니다.", 400); error = new AppError("데이터 무결성 제약 조건 위반입니다.", 400);
} else { } else {
error = new AppError("데이터베이스 오류가 발생했습니다.", 500); error = new AppError(`데이터베이스 오류: ${pgError.message}`, 500);
} }
} }

View File

@ -18,6 +18,8 @@ import {
getDepartmentList, // 부서 목록 조회 getDepartmentList, // 부서 목록 조회
checkDuplicateUserId, // 사용자 ID 중복 체크 checkDuplicateUserId, // 사용자 ID 중복 체크
saveUser, // 사용자 등록/수정 saveUser, // 사용자 등록/수정
saveUserWithDept, // 사원 + 부서 통합 저장 (NEW!)
getUserWithDept, // 사원 + 부서 조회 (NEW!)
getCompanyList, getCompanyList,
getCompanyListFromDB, // 실제 DB에서 회사 목록 조회 getCompanyListFromDB, // 실제 DB에서 회사 목록 조회
getCompanyByCode, // 회사 단건 조회 getCompanyByCode, // 회사 단건 조회
@ -50,8 +52,10 @@ router.delete("/menus/:menuId", deleteMenu); // 메뉴 삭제
router.get("/users", getUserList); router.get("/users", getUserList);
router.get("/users/:userId", getUserInfo); // 사용자 상세 조회 router.get("/users/:userId", getUserInfo); // 사용자 상세 조회
router.get("/users/:userId/history", getUserHistory); // 사용자 변경이력 조회 router.get("/users/:userId/history", getUserHistory); // 사용자 변경이력 조회
router.get("/users/:userId/with-dept", getUserWithDept); // 사원 + 부서 조회 (NEW!)
router.patch("/users/:userId/status", changeUserStatus); // 사용자 상태 변경 router.patch("/users/:userId/status", changeUserStatus); // 사용자 상태 변경
router.post("/users", saveUser); // 사용자 등록/수정 (기존) router.post("/users", saveUser); // 사용자 등록/수정 (기존)
router.post("/users/with-dept", saveUserWithDept); // 사원 + 부서 통합 저장 (NEW!)
router.put("/users/:userId", saveUser); // 사용자 수정 (REST API) router.put("/users/:userId", saveUser); // 사용자 수정 (REST API)
router.put("/profile", updateProfile); // 프로필 수정 router.put("/profile", updateProfile); // 프로필 수정
router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크 router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크

View File

@ -41,4 +41,16 @@ router.post("/logout", AuthController.logout);
*/ */
router.post("/refresh", AuthController.refreshToken); router.post("/refresh", AuthController.refreshToken);
/**
* POST /api/auth/signup
* API
*/
router.post("/signup", AuthController.signup);
/**
* POST /api/auth/switch-company
* WACE 전용: 다른
*/
router.post("/switch-company", AuthController.switchCompany);
export default router; export default router;

View File

@ -79,4 +79,10 @@ router.post("/rest-api/preview", authenticateToken, BatchManagementController.pr
*/ */
router.post("/rest-api/save", authenticateToken, BatchManagementController.saveRestApiBatch); router.post("/rest-api/save", authenticateToken, BatchManagementController.saveRestApiBatch);
/**
* GET /api/batch-management/auth-services
*
*/
router.get("/auth-services", authenticateToken, BatchManagementController.getAuthServiceNames);
export default router; export default router;

View File

@ -0,0 +1,59 @@
/**
* (Auto-Fill)
*/
import express from "express";
import {
getAutoFillGroups,
getAutoFillGroupDetail,
createAutoFillGroup,
updateAutoFillGroup,
deleteAutoFillGroup,
getAutoFillMasterOptions,
getAutoFillData,
} from "../controllers/cascadingAutoFillController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = express.Router();
// 인증 미들웨어 적용
router.use(authenticateToken);
// =====================================================
// 자동 입력 그룹 관리 API
// =====================================================
// 그룹 목록 조회
router.get("/groups", getAutoFillGroups);
// 그룹 상세 조회 (매핑 포함)
router.get("/groups/:groupCode", getAutoFillGroupDetail);
// 그룹 생성
router.post("/groups", createAutoFillGroup);
// 그룹 수정
router.put("/groups/:groupCode", updateAutoFillGroup);
// 그룹 삭제
router.delete("/groups/:groupCode", deleteAutoFillGroup);
// =====================================================
// 자동 입력 데이터 조회 API (실제 사용)
// =====================================================
// 마스터 옵션 목록 조회
router.get("/options/:groupCode", getAutoFillMasterOptions);
// 자동 입력 데이터 조회
router.get("/data/:groupCode", getAutoFillData);
export default router;

View File

@ -0,0 +1,55 @@
/**
* (Conditional Cascading)
*/
import express from "express";
import {
getConditions,
getConditionDetail,
createCondition,
updateCondition,
deleteCondition,
getFilteredOptions,
} from "../controllers/cascadingConditionController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = express.Router();
// 인증 미들웨어 적용
router.use(authenticateToken);
// =====================================================
// 조건부 연쇄 규칙 관리 API
// =====================================================
// 규칙 목록 조회
router.get("/", getConditions);
// 규칙 상세 조회
router.get("/:conditionId", getConditionDetail);
// 규칙 생성
router.post("/", createCondition);
// 규칙 수정
router.put("/:conditionId", updateCondition);
// 규칙 삭제
router.delete("/:conditionId", deleteCondition);
// =====================================================
// 조건부 필터링 적용 API (실제 사용)
// =====================================================
// 조건에 따른 필터링된 옵션 조회
router.get("/filtered-options/:relationCode", getFilteredOptions);
export default router;

View File

@ -0,0 +1,71 @@
/**
* (Hierarchy)
*/
import express from "express";
import {
getHierarchyGroups,
getHierarchyGroupDetail,
createHierarchyGroup,
updateHierarchyGroup,
deleteHierarchyGroup,
addLevel,
updateLevel,
deleteLevel,
getLevelOptions,
} from "../controllers/cascadingHierarchyController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = express.Router();
// 인증 미들웨어 적용
router.use(authenticateToken);
// =====================================================
// 계층 그룹 관리 API
// =====================================================
// 그룹 목록 조회
router.get("/", getHierarchyGroups);
// 그룹 상세 조회 (레벨 포함)
router.get("/:groupCode", getHierarchyGroupDetail);
// 그룹 생성
router.post("/", createHierarchyGroup);
// 그룹 수정
router.put("/:groupCode", updateHierarchyGroup);
// 그룹 삭제
router.delete("/:groupCode", deleteHierarchyGroup);
// =====================================================
// 계층 레벨 관리 API
// =====================================================
// 레벨 추가
router.post("/:groupCode/levels", addLevel);
// 레벨 수정
router.put("/levels/:levelId", updateLevel);
// 레벨 삭제
router.delete("/levels/:levelId", deleteLevel);
// =====================================================
// 계층 옵션 조회 API (실제 사용)
// =====================================================
// 특정 레벨의 옵션 조회
router.get("/:groupCode/options/:levelOrder", getLevelOptions);
export default router;

View File

@ -0,0 +1,59 @@
/**
* (Mutual Exclusion)
*/
import express from "express";
import {
getExclusions,
getExclusionDetail,
createExclusion,
updateExclusion,
deleteExclusion,
validateExclusion,
getExcludedOptions,
} from "../controllers/cascadingMutualExclusionController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = express.Router();
// 인증 미들웨어 적용
router.use(authenticateToken);
// =====================================================
// 상호 배제 규칙 관리 API
// =====================================================
// 규칙 목록 조회
router.get("/", getExclusions);
// 규칙 상세 조회
router.get("/:exclusionId", getExclusionDetail);
// 규칙 생성
router.post("/", createExclusion);
// 규칙 수정
router.put("/:exclusionId", updateExclusion);
// 규칙 삭제
router.delete("/:exclusionId", deleteExclusion);
// =====================================================
// 상호 배제 검증 및 옵션 API (실제 사용)
// =====================================================
// 상호 배제 검증
router.post("/validate/:exclusionCode", validateExclusion);
// 배제된 옵션 조회
router.get("/options/:exclusionCode", getExcludedOptions);
export default router;

View File

@ -0,0 +1,44 @@
import { Router } from "express";
import {
getCascadingRelations,
getCascadingRelationById,
getCascadingRelationByCode,
createCascadingRelation,
updateCascadingRelation,
deleteCascadingRelation,
getCascadingOptions,
getParentOptions,
} from "../controllers/cascadingRelationController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = Router();
// 모든 라우트에 인증 적용
router.use(authenticateToken);
// 연쇄 관계 목록 조회
router.get("/", getCascadingRelations);
// 연쇄 관계 상세 조회 (ID)
router.get("/:id", getCascadingRelationById);
// 연쇄 관계 코드로 조회
router.get("/code/:code", getCascadingRelationByCode);
// 🆕 연쇄 관계로 부모 옵션 조회 (상위 선택 역할용)
router.get("/parent-options/:code", getParentOptions);
// 연쇄 관계로 자식 옵션 조회 (실제 드롭다운에서 사용)
router.get("/options/:code", getCascadingOptions);
// 연쇄 관계 생성
router.post("/", createCascadingRelation);
// 연쇄 관계 수정
router.put("/:id", updateCascadingRelation);
// 연쇄 관계 삭제
router.delete("/:id", deleteCascadingRelation);
export default router;

View File

@ -0,0 +1,74 @@
import { Router } from "express";
import {
getCategoryValueCascadingGroups,
getCategoryValueCascadingGroupById,
getCategoryValueCascadingByCode,
createCategoryValueCascadingGroup,
updateCategoryValueCascadingGroup,
deleteCategoryValueCascadingGroup,
saveCategoryValueCascadingMappings,
getCategoryValueCascadingOptions,
getCategoryValueCascadingParentOptions,
getCategoryValueCascadingChildOptions,
getCategoryValueCascadingMappingsByTable,
} from "../controllers/categoryValueCascadingController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = Router();
// 모든 라우트에 인증 적용
router.use(authenticateToken);
// ============================================
// 카테고리 값 연쇄관계 그룹 CRUD
// ============================================
// 그룹 목록 조회
router.get("/groups", getCategoryValueCascadingGroups);
// 그룹 상세 조회 (ID)
router.get("/groups/:groupId", getCategoryValueCascadingGroupById);
// 관계 코드로 조회
router.get("/code/:code", getCategoryValueCascadingByCode);
// 그룹 생성
router.post("/groups", createCategoryValueCascadingGroup);
// 그룹 수정
router.put("/groups/:groupId", updateCategoryValueCascadingGroup);
// 그룹 삭제
router.delete("/groups/:groupId", deleteCategoryValueCascadingGroup);
// ============================================
// 카테고리 값 연쇄관계 매핑
// ============================================
// 매핑 일괄 저장
router.post("/groups/:groupId/mappings", saveCategoryValueCascadingMappings);
// ============================================
// 연쇄 옵션 조회 (실제 드롭다운에서 사용)
// ============================================
// 부모 카테고리 값 목록 조회
router.get("/parent-options/:code", getCategoryValueCascadingParentOptions);
// 자식 카테고리 값 목록 조회 (매핑 설정 UI용)
router.get("/child-options/:code", getCategoryValueCascadingChildOptions);
// 연쇄 옵션 조회 (부모 값 기반 자식 옵션)
router.get("/options/:code", getCategoryValueCascadingOptions);
// ============================================
// 테이블별 매핑 조회 (테이블 목록 표시용)
// ============================================
// 테이블명으로 해당 테이블의 모든 연쇄관계 매핑 조회
router.get(
"/table/:tableName/mappings",
getCategoryValueCascadingMappingsByTable
);
export default router;

View File

@ -3,6 +3,8 @@ import {
mergeCodeAllTables, mergeCodeAllTables,
getTablesWithColumn, getTablesWithColumn,
previewCodeMerge, previewCodeMerge,
mergeCodeByValue,
previewMergeCodeByValue,
} from "../controllers/codeMergeController"; } from "../controllers/codeMergeController";
import { authenticateToken } from "../middleware/authMiddleware"; import { authenticateToken } from "../middleware/authMiddleware";
@ -13,7 +15,7 @@ router.use(authenticateToken);
/** /**
* POST /api/code-merge/merge-all-tables * POST /api/code-merge/merge-all-tables
* ( ) * ( - )
* Body: { columnName, oldValue, newValue } * Body: { columnName, oldValue, newValue }
*/ */
router.post("/merge-all-tables", mergeCodeAllTables); router.post("/merge-all-tables", mergeCodeAllTables);
@ -26,10 +28,24 @@ router.get("/tables-with-column/:columnName", getTablesWithColumn);
/** /**
* POST /api/code-merge/preview * POST /api/code-merge/preview
* ( ) * ( )
* Body: { columnName, oldValue } * Body: { columnName, oldValue }
*/ */
router.post("/preview", previewCodeMerge); router.post("/preview", previewCodeMerge);
/**
* POST /api/code-merge/merge-by-value
* ( )
* Body: { oldValue, newValue }
*/
router.post("/merge-by-value", mergeCodeByValue);
/**
* POST /api/code-merge/preview-by-value
* ( )
* Body: { oldValue }
*/
router.post("/preview-by-value", previewMergeCodeByValue);
export default router; export default router;

View File

@ -46,6 +46,21 @@ router.put("/categories/:categoryCode/codes/reorder", (req, res) =>
commonCodeController.reorderCodes(req, res) commonCodeController.reorderCodes(req, res)
); );
// 계층구조 코드 조회 (구체적인 경로를 먼저 배치)
router.get("/categories/:categoryCode/hierarchy", (req, res) =>
commonCodeController.getHierarchicalCodes(req, res)
);
// 코드 트리 조회
router.get("/categories/:categoryCode/tree", (req, res) =>
commonCodeController.getCodeTree(req, res)
);
// 자식 코드 존재 여부 확인
router.get("/categories/:categoryCode/codes/:codeValue/has-children", (req, res) =>
commonCodeController.hasChildren(req, res)
);
router.put("/categories/:categoryCode/codes/:codeValue", (req, res) => router.put("/categories/:categoryCode/codes/:codeValue", (req, res) =>
commonCodeController.updateCode(req, res) commonCodeController.updateCode(req, res)
); );

View File

@ -1,10 +1,262 @@
import express from "express"; import express from "express";
import { dataService } from "../services/dataService"; import { dataService } from "../services/dataService";
import { masterDetailExcelService } from "../services/masterDetailExcelService";
import { authenticateToken } from "../middleware/authMiddleware"; import { authenticateToken } from "../middleware/authMiddleware";
import { AuthenticatedRequest } from "../types/auth"; import { AuthenticatedRequest } from "../types/auth";
const router = express.Router(); const router = express.Router();
// ================================
// 마스터-디테일 엑셀 API
// ================================
/**
* -
* GET /api/data/master-detail/relation/:screenId
*/
router.get(
"/master-detail/relation/:screenId",
authenticateToken,
async (req: AuthenticatedRequest, res) => {
try {
const { screenId } = req.params;
if (!screenId || isNaN(parseInt(screenId))) {
return res.status(400).json({
success: false,
message: "유효한 screenId가 필요합니다.",
});
}
console.log(`🔍 마스터-디테일 관계 조회: screenId=${screenId}`);
const relation = await masterDetailExcelService.getMasterDetailRelation(
parseInt(screenId)
);
if (!relation) {
return res.json({
success: true,
data: null,
message: "마스터-디테일 구조가 아닙니다.",
});
}
console.log(`✅ 마스터-디테일 관계 발견:`, {
masterTable: relation.masterTable,
detailTable: relation.detailTable,
joinKey: relation.masterKeyColumn,
});
return res.json({
success: true,
data: relation,
});
} catch (error: any) {
console.error("마스터-디테일 관계 조회 오류:", error);
return res.status(500).json({
success: false,
message: "마스터-디테일 관계 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
);
/**
* -
* POST /api/data/master-detail/download
*/
router.post(
"/master-detail/download",
authenticateToken,
async (req: AuthenticatedRequest, res) => {
try {
const { screenId, filters } = req.body;
const companyCode = req.user?.companyCode || "*";
if (!screenId) {
return res.status(400).json({
success: false,
message: "screenId가 필요합니다.",
});
}
console.log(`📥 마스터-디테일 엑셀 다운로드: screenId=${screenId}`);
// 1. 마스터-디테일 관계 조회
const relation = await masterDetailExcelService.getMasterDetailRelation(
parseInt(screenId)
);
if (!relation) {
return res.status(400).json({
success: false,
message: "마스터-디테일 구조가 아닙니다.",
});
}
// 2. JOIN 데이터 조회
const data = await masterDetailExcelService.getJoinedData(
relation,
companyCode,
filters
);
console.log(`✅ 마스터-디테일 데이터 조회 완료: ${data.data.length}`);
return res.json({
success: true,
data,
});
} catch (error: any) {
console.error("마스터-디테일 다운로드 오류:", error);
return res.status(500).json({
success: false,
message: "마스터-디테일 다운로드 중 오류가 발생했습니다.",
error: error.message,
});
}
}
);
/**
* -
* POST /api/data/master-detail/upload
*/
router.post(
"/master-detail/upload",
authenticateToken,
async (req: AuthenticatedRequest, res) => {
try {
const { screenId, data } = req.body;
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId;
if (!screenId || !data || !Array.isArray(data)) {
return res.status(400).json({
success: false,
message: "screenId와 data 배열이 필요합니다.",
});
}
console.log(`📤 마스터-디테일 엑셀 업로드: screenId=${screenId}, rows=${data.length}`);
// 1. 마스터-디테일 관계 조회
const relation = await masterDetailExcelService.getMasterDetailRelation(
parseInt(screenId)
);
if (!relation) {
return res.status(400).json({
success: false,
message: "마스터-디테일 구조가 아닙니다.",
});
}
// 2. 데이터 업로드
const result = await masterDetailExcelService.uploadJoinedData(
relation,
data,
companyCode,
userId
);
console.log(`✅ 마스터-디테일 업로드 완료:`, {
masterInserted: result.masterInserted,
masterUpdated: result.masterUpdated,
detailInserted: result.detailInserted,
errors: result.errors.length,
});
return res.json({
success: result.success,
data: result,
message: result.success
? `마스터 ${result.masterInserted + result.masterUpdated}건, 디테일 ${result.detailInserted}건 처리되었습니다.`
: "업로드 중 오류가 발생했습니다.",
});
} catch (error: any) {
console.error("마스터-디테일 업로드 오류:", error);
return res.status(500).json({
success: false,
message: "마스터-디테일 업로드 중 오류가 발생했습니다.",
error: error.message,
});
}
}
);
/**
* -
* - UI에서
* -
* -
*
* POST /api/data/master-detail/upload-simple
*/
router.post(
"/master-detail/upload-simple",
authenticateToken,
async (req: AuthenticatedRequest, res) => {
try {
const { screenId, detailData, masterFieldValues, numberingRuleId, afterUploadFlowId, afterUploadFlows } = req.body;
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "system";
if (!screenId || !detailData || !Array.isArray(detailData)) {
return res.status(400).json({
success: false,
message: "screenId와 detailData 배열이 필요합니다.",
});
}
console.log(`📤 마스터-디테일 간단 모드 업로드: screenId=${screenId}, rows=${detailData.length}`);
console.log(` 마스터 필드 값:`, masterFieldValues);
console.log(` 채번 규칙 ID:`, numberingRuleId);
console.log(` 업로드 후 제어:`, afterUploadFlows?.length > 0 ? `${afterUploadFlows.length}` : afterUploadFlowId || "없음");
// 업로드 실행
const result = await masterDetailExcelService.uploadSimple(
parseInt(screenId),
detailData,
masterFieldValues || {},
numberingRuleId,
companyCode,
userId,
afterUploadFlowId, // 업로드 후 제어 실행 (단일, 하위 호환성)
afterUploadFlows // 업로드 후 제어 실행 (다중)
);
console.log(`✅ 마스터-디테일 간단 모드 업로드 완료:`, {
masterInserted: result.masterInserted,
detailInserted: result.detailInserted,
generatedKey: result.generatedKey,
errors: result.errors.length,
});
return res.json({
success: result.success,
data: result,
message: result.success
? `마스터 1건(${result.generatedKey}), 디테일 ${result.detailInserted}건 처리되었습니다.`
: "업로드 중 오류가 발생했습니다.",
});
} catch (error: any) {
console.error("마스터-디테일 간단 모드 업로드 오류:", error);
return res.status(500).json({
success: false,
message: "마스터-디테일 업로드 중 오류가 발생했습니다.",
error: error.message,
});
}
}
);
// ================================
// 기존 데이터 API
// ================================
/** /**
* API ( ) * API ( )
* GET /api/data/join?leftTable=...&rightTable=...&leftColumn=...&rightColumn=...&leftValue=... * GET /api/data/join?leftTable=...&rightTable=...&leftColumn=...&rightColumn=...&leftValue=...
@ -698,6 +950,7 @@ router.post(
try { try {
const { tableName } = req.params; const { tableName } = req.params;
const filterConditions = req.body; const filterConditions = req.body;
const userCompany = req.user?.companyCode;
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) { if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
return res.status(400).json({ return res.status(400).json({
@ -706,11 +959,12 @@ router.post(
}); });
} }
console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions }); console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions, userCompany });
const result = await dataService.deleteGroupRecords( const result = await dataService.deleteGroupRecords(
tableName, tableName,
filterConditions filterConditions,
userCompany // 회사 코드 전달
); );
if (!result.success) { if (!result.success) {

View File

@ -215,49 +215,132 @@ router.delete("/:flowId", async (req: Request, res: Response) => {
}); });
/** /**
* *
* POST /api/dataflow/node-flows/:flowId/execute * GET /api/dataflow/node-flows/:flowId/source-table
* (tableSource, externalDBSource)
*/ */
router.post("/:flowId/execute", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { router.get("/:flowId/source-table", async (req: Request, res: Response) => {
try { try {
const { flowId } = req.params; const { flowId } = req.params;
const contextData = req.body;
logger.info(`🚀 플로우 실행 요청: flowId=${flowId}`, { const flow = await queryOne<{ flow_data: any }>(
contextDataKeys: Object.keys(contextData), `SELECT flow_data FROM node_flows WHERE flow_id = $1`,
userId: req.user?.userId, [flowId]
companyCode: req.user?.companyCode, );
});
// 사용자 정보를 contextData에 추가 if (!flow) {
const enrichedContextData = { return res.status(404).json({
...contextData, success: false,
userId: req.user?.userId, message: "플로우를 찾을 수 없습니다.",
userName: req.user?.userName, });
companyCode: req.user?.companyCode, }
};
// 플로우 실행 const flowData =
const result = await NodeFlowExecutionService.executeFlow( typeof flow.flow_data === "string"
parseInt(flowId, 10), ? JSON.parse(flow.flow_data)
enrichedContextData : flow.flow_data;
const nodes = flowData.nodes || [];
// 소스 노드 찾기 (tableSource, externalDBSource 타입)
const sourceNode = nodes.find(
(node: any) =>
node.type === "tableSource" || node.type === "externalDBSource"
);
if (!sourceNode || !sourceNode.data?.tableName) {
return res.json({
success: true,
data: {
sourceTable: null,
sourceNodeType: null,
message: "소스 노드가 없거나 테이블명이 설정되지 않았습니다.",
},
});
}
logger.info(
`플로우 소스 테이블 조회: flowId=${flowId}, table=${sourceNode.data.tableName}`
); );
return res.json({ return res.json({
success: result.success, success: true,
message: result.message, data: {
data: result, sourceTable: sourceNode.data.tableName,
sourceNodeType: sourceNode.type,
sourceNodeId: sourceNode.id,
displayName: sourceNode.data.displayName,
},
}); });
} catch (error) { } catch (error) {
logger.error("플로우 실행 실패:", error); logger.error("플로우 소스 테이블 조회 실패:", error);
return res.status(500).json({ return res.status(500).json({
success: false, success: false,
message: message: "플로우 소스 테이블을 조회하지 못했습니다.",
error instanceof Error
? error.message
: "플로우 실행 중 오류가 발생했습니다.",
}); });
} }
}); });
/**
*
* POST /api/dataflow/node-flows/:flowId/execute
*/
router.post(
"/:flowId/execute",
authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
try {
const { flowId } = req.params;
const contextData = req.body;
logger.info(`🚀 플로우 실행 요청: flowId=${flowId}`, {
contextDataKeys: Object.keys(contextData),
userId: req.user?.userId,
companyCode: req.user?.companyCode,
});
// 🔍 디버깅: req.user 전체 확인
logger.info(`🔍 req.user 전체 정보:`, {
user: req.user,
hasUser: !!req.user,
});
// 사용자 정보를 contextData에 추가
const enrichedContextData = {
...contextData,
userId: req.user?.userId,
userName: req.user?.userName,
companyCode: req.user?.companyCode,
};
// 🔍 디버깅: enrichedContextData 확인
logger.info(`🔍 enrichedContextData:`, {
userId: enrichedContextData.userId,
companyCode: enrichedContextData.companyCode,
});
// 플로우 실행
const result = await NodeFlowExecutionService.executeFlow(
parseInt(flowId, 10),
enrichedContextData
);
return res.json({
success: result.success,
message: result.message,
data: result,
});
} catch (error) {
logger.error("플로우 실행 실패:", error);
return res.status(500).json({
success: false,
message:
error instanceof Error
? error.message
: "플로우 실행 중 오류가 발생했습니다.",
});
}
}
);
export default router; export default router;

View File

@ -0,0 +1,48 @@
// 공차중계 운전자 API 라우터
import { Router } from "express";
import { DriverController } from "../controllers/driverController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = Router();
// 모든 라우트에 인증 필요
router.use(authenticateToken);
/**
* GET /api/driver/profile
*
*/
router.get("/profile", DriverController.getProfile);
/**
* PUT /api/driver/profile
* (, , , , )
*/
router.put("/profile", DriverController.updateProfile);
/**
* PUT /api/driver/status
* (/)
*/
router.put("/status", DriverController.updateStatus);
/**
* DELETE /api/driver/vehicle
* ( )
*/
router.delete("/vehicle", DriverController.deleteVehicle);
/**
* POST /api/driver/vehicle
*
*/
router.post("/vehicle", DriverController.registerVehicle);
/**
* DELETE /api/driver/account
*
*/
router.delete("/account", DriverController.deleteAccount);
export default router;

View File

@ -12,6 +12,8 @@ import {
validateFormData, validateFormData,
getTableColumns, getTableColumns,
getTablePrimaryKeys, getTablePrimaryKeys,
saveLocationHistory,
getLocationHistory,
} from "../controllers/dynamicFormController"; } from "../controllers/dynamicFormController";
const router = express.Router(); const router = express.Router();
@ -40,4 +42,8 @@ router.get("/table/:tableName/columns", getTableColumns);
// 테이블 기본키 조회 // 테이블 기본키 조회
router.get("/table/:tableName/primary-keys", getTablePrimaryKeys); router.get("/table/:tableName/primary-keys", getTablePrimaryKeys);
// 위치 이력 (연속 위치 추적)
router.post("/location-history", saveLocationHistory);
router.get("/location-history/:tripId", getLocationHistory);
export default router; export default router;

View File

@ -0,0 +1,25 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import {
findMappingByColumns,
saveMappingTemplate,
getMappingTemplates,
deleteMappingTemplate,
} from "../controllers/excelMappingController";
const router = Router();
// 엑셀 컬럼 구조로 매핑 템플릿 조회
router.post("/find", authenticateToken, findMappingByColumns);
// 매핑 템플릿 저장 (UPSERT)
router.post("/save", authenticateToken, saveMappingTemplate);
// 테이블의 매핑 템플릿 목록 조회
router.get("/list/:tableName", authenticateToken, getMappingTemplates);
// 매핑 템플릿 삭제
router.delete("/:id", authenticateToken, deleteMappingTemplate);
export default router;

View File

@ -97,6 +97,8 @@ router.post(
const data: ExternalRestApiConnection = { const data: ExternalRestApiConnection = {
...req.body, ...req.body,
created_by: req.user?.userId || "system", created_by: req.user?.userId || "system",
// 로그인 사용자의 company_code 사용 (프론트에서 안 보내도 자동 설정)
company_code: req.body.company_code || req.user?.companyCode || "*",
}; };
const result = const result =

View File

@ -43,6 +43,9 @@ router.get("/:flowId/steps/counts", flowController.getAllStepCounts);
router.post("/move", flowController.moveData); router.post("/move", flowController.moveData);
router.post("/move-batch", flowController.moveBatchData); router.post("/move-batch", flowController.moveBatchData);
// ==================== 스텝 데이터 수정 (인라인 편집) ====================
router.put("/:flowId/step/:stepId/data/:recordId", flowController.updateStepData);
// ==================== 오딧 로그 ==================== // ==================== 오딧 로그 ====================
router.get("/audit/:flowId/:recordId", flowController.getAuditLogs); router.get("/audit/:flowId/:recordId", flowController.getAuditLogs);
router.get("/audit/:flowId", flowController.getFlowAuditLogs); router.get("/audit/:flowId", flowController.getFlowAuditLogs);

View File

@ -21,6 +21,20 @@ import {
getUserText, getUserText,
getLangText, getLangText,
getBatchTranslations, getBatchTranslations,
// 카테고리 관리 API
getCategories,
getCategoryById,
getCategoryPath,
// 자동 생성 및 오버라이드 API
generateKey,
previewKey,
createOverrideKey,
getOverrideKeys,
// 화면 라벨 다국어 API
generateScreenLabelKeys,
} from "../controllers/multilangController"; } from "../controllers/multilangController";
const router = express.Router(); const router = express.Router();
@ -51,4 +65,18 @@ router.post("/keys/:keyId/texts", saveLangTexts); // 다국어 텍스트 저장/
router.get("/user-text/:companyCode/:menuCode/:langKey", getUserText); // 사용자별 다국어 텍스트 조회 router.get("/user-text/:companyCode/:menuCode/:langKey", getUserText); // 사용자별 다국어 텍스트 조회
router.get("/text/:companyCode/:langKey/:langCode", getLangText); // 특정 키의 다국어 텍스트 조회 router.get("/text/:companyCode/:langKey/:langCode", getLangText); // 특정 키의 다국어 텍스트 조회
// 카테고리 관리 API
router.get("/categories", getCategories); // 카테고리 트리 조회
router.get("/categories/:categoryId", getCategoryById); // 카테고리 상세 조회
router.get("/categories/:categoryId/path", getCategoryPath); // 카테고리 경로 조회
// 자동 생성 및 오버라이드 API
router.post("/keys/generate", generateKey); // 키 자동 생성
router.post("/keys/preview", previewKey); // 키 미리보기
router.post("/keys/override", createOverrideKey); // 오버라이드 키 생성
router.get("/keys/overrides/:companyCode", getOverrideKeys); // 오버라이드 키 목록 조회
// 화면 라벨 다국어 자동 생성 API
router.post("/screen-labels", generateScreenLabelKeys); // 화면 라벨 다국어 키 자동 생성
export default router; export default router;

View File

@ -1,20 +0,0 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import { createOrder, getOrders } from "../controllers/orderController";
const router = Router();
/**
*
* POST /api/orders
*/
router.post("/", authenticateToken, createOrder);
/**
*
* GET /api/orders
*/
router.get("/", authenticateToken, getOrders);
export default router;

View File

@ -56,6 +56,11 @@ router.post("/upload-image", upload.single("image"), (req, res, next) =>
reportController.uploadImage(req, res, next) reportController.uploadImage(req, res, next)
); );
// WORD(DOCX) 내보내기
router.post("/export-word", (req, res, next) =>
reportController.exportToWord(req, res, next)
);
// 리포트 목록 // 리포트 목록
router.get("/", (req, res, next) => router.get("/", (req, res, next) =>
reportController.getReports(req, res, next) reportController.getReports(req, res, next)

View File

@ -22,6 +22,15 @@ const router = Router();
// 모든 role 라우트에 인증 미들웨어 적용 // 모든 role 라우트에 인증 미들웨어 적용
router.use(authenticateToken); router.use(authenticateToken);
/**
* (/:id )
*/
// 현재 사용자가 속한 권한 그룹 조회
router.get("/user/my-groups", getUserRoleGroups);
// 특정 사용자가 속한 권한 그룹 조회
router.get("/user/:userId/groups", requireAdmin, getUserRoleGroups);
/** /**
* CRUD * CRUD
*/ */
@ -67,13 +76,4 @@ router.get("/:id/menu-permissions", requireAdmin, getMenuPermissions);
// 메뉴 권한 설정 // 메뉴 권한 설정
router.put("/:id/menu-permissions", requireAdmin, setMenuPermissions); router.put("/:id/menu-permissions", requireAdmin, setMenuPermissions);
/**
*
*/
// 현재 사용자가 속한 권한 그룹 조회
router.get("/user/my-groups", getUserRoleGroups);
// 특정 사용자가 속한 권한 그룹 조회
router.get("/user/:userId/groups", requireAdmin, getUserRoleGroups);
export default router; export default router;

View File

@ -0,0 +1,111 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import {
// 화면 그룹
getScreenGroups,
getScreenGroup,
createScreenGroup,
updateScreenGroup,
deleteScreenGroup,
// 화면-그룹 연결
addScreenToGroup,
removeScreenFromGroup,
updateScreenInGroup,
// 필드 조인
getFieldJoins,
createFieldJoin,
updateFieldJoin,
deleteFieldJoin,
// 데이터 흐름
getDataFlows,
createDataFlow,
updateDataFlow,
deleteDataFlow,
// 화면-테이블 관계
getTableRelations,
createTableRelation,
updateTableRelation,
deleteTableRelation,
// 화면 레이아웃 요약
getScreenLayoutSummary,
getMultipleScreenLayoutSummary,
// 화면 서브 테이블 관계
getScreenSubTables,
// 메뉴-화면그룹 동기화
syncScreenGroupsToMenuController,
syncMenuToScreenGroupsController,
getSyncStatusController,
syncAllCompaniesController,
} from "../controllers/screenGroupController";
const router = Router();
// 모든 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
// ============================================================
// 화면 그룹 (screen_groups)
// ============================================================
router.get("/groups", getScreenGroups);
router.get("/groups/:id", getScreenGroup);
router.post("/groups", createScreenGroup);
router.put("/groups/:id", updateScreenGroup);
router.delete("/groups/:id", deleteScreenGroup);
// ============================================================
// 화면-그룹 연결 (screen_group_screens)
// ============================================================
router.post("/group-screens", addScreenToGroup);
router.put("/group-screens/:id", updateScreenInGroup);
router.delete("/group-screens/:id", removeScreenFromGroup);
// ============================================================
// 필드 조인 설정 (screen_field_joins)
// ============================================================
router.get("/field-joins", getFieldJoins);
router.post("/field-joins", createFieldJoin);
router.put("/field-joins/:id", updateFieldJoin);
router.delete("/field-joins/:id", deleteFieldJoin);
// ============================================================
// 데이터 흐름 (screen_data_flows)
// ============================================================
router.get("/data-flows", getDataFlows);
router.post("/data-flows", createDataFlow);
router.put("/data-flows/:id", updateDataFlow);
router.delete("/data-flows/:id", deleteDataFlow);
// ============================================================
// 화면-테이블 관계 (screen_table_relations)
// ============================================================
router.get("/table-relations", getTableRelations);
router.post("/table-relations", createTableRelation);
router.put("/table-relations/:id", updateTableRelation);
router.delete("/table-relations/:id", deleteTableRelation);
// ============================================================
// 화면 레이아웃 요약 (미리보기용)
// ============================================================
router.get("/layout-summary/:screenId", getScreenLayoutSummary);
router.post("/layout-summary/batch", getMultipleScreenLayoutSummary);
// ============================================================
// 화면 서브 테이블 관계 (조인/참조 테이블)
// ============================================================
router.post("/sub-tables/batch", getScreenSubTables);
// ============================================================
// 메뉴-화면그룹 동기화
// ============================================================
// 동기화 상태 조회
router.get("/sync/status", getSyncStatusController);
// 화면관리 → 메뉴 동기화
router.post("/sync/screen-to-menu", syncScreenGroupsToMenuController);
// 메뉴 → 화면관리 동기화
router.post("/sync/menu-to-screen", syncMenuToScreenGroupsController);
// 전체 회사 동기화 (최고 관리자만)
router.post("/sync/all", syncAllCompaniesController);
export default router;

View File

@ -8,6 +8,7 @@ import {
updateScreen, updateScreen,
updateScreenInfo, updateScreenInfo,
deleteScreen, deleteScreen,
bulkDeleteScreens,
checkScreenDependencies, checkScreenDependencies,
restoreScreen, restoreScreen,
permanentDeleteScreen, permanentDeleteScreen,
@ -44,6 +45,7 @@ router.put("/screens/:id", updateScreen);
router.put("/screens/:id/info", updateScreenInfo); // 화면 정보만 수정 router.put("/screens/:id/info", updateScreenInfo); // 화면 정보만 수정
router.get("/screens/:id/dependencies", checkScreenDependencies); // 의존성 체크 router.get("/screens/:id/dependencies", checkScreenDependencies); // 의존성 체크
router.delete("/screens/:id", deleteScreen); // 휴지통으로 이동 router.delete("/screens/:id", deleteScreen); // 휴지통으로 이동
router.delete("/screens/bulk/delete", bulkDeleteScreens); // 활성 화면 일괄 삭제 (휴지통으로 이동)
router.get("/screens/:id/linked-modals", detectLinkedScreens); // 연결된 모달 화면 감지 router.get("/screens/:id/linked-modals", detectLinkedScreens); // 연결된 모달 화면 감지
router.post("/screens/check-duplicate-name", checkDuplicateScreenName); // 화면명 중복 체크 router.post("/screens/check-duplicate-name", checkDuplicateScreenName); // 화면명 중복 체크
router.post("/screens/:id/copy", copyScreen); // 단일 화면 복사 (하위 호환용) router.post("/screens/:id/copy", copyScreen); // 단일 화면 복사 (하위 호환용)

View File

@ -1,6 +1,7 @@
import { Router } from "express"; import { Router } from "express";
import { import {
getCategoryColumns, getCategoryColumns,
getAllCategoryColumns,
getCategoryValues, getCategoryValues,
addCategoryValue, addCategoryValue,
updateCategoryValue, updateCategoryValue,
@ -13,6 +14,7 @@ import {
deleteColumnMapping, deleteColumnMapping,
deleteColumnMappingsByColumn, deleteColumnMappingsByColumn,
getSecondLevelMenus, getSecondLevelMenus,
getCategoryLabelsByCodes,
} from "../controllers/tableCategoryValueController"; } from "../controllers/tableCategoryValueController";
import { authenticateToken } from "../middleware/authMiddleware"; import { authenticateToken } from "../middleware/authMiddleware";
@ -21,6 +23,10 @@ const router = Router();
// 모든 라우트에 인증 미들웨어 적용 // 모든 라우트에 인증 미들웨어 적용
router.use(authenticateToken); router.use(authenticateToken);
// 모든 테이블의 카테고리 컬럼 목록 조회 (Select 옵션 설정용)
// 주의: 더 구체적인 라우트보다 먼저 와야 함
router.get("/all-columns", getAllCategoryColumns);
// 테이블의 카테고리 컬럼 목록 조회 // 테이블의 카테고리 컬럼 목록 조회
router.get("/:tableName/columns", getCategoryColumns); router.get("/:tableName/columns", getCategoryColumns);
@ -42,6 +48,9 @@ router.post("/values/bulk-delete", bulkDeleteCategoryValues);
// 카테고리 값 순서 변경 // 카테고리 값 순서 변경
router.post("/values/reorder", reorderCategoryValues); router.post("/values/reorder", reorderCategoryValues);
// 카테고리 코드로 라벨 조회
router.post("/labels-by-codes", getCategoryLabelsByCodes);
// ================================================ // ================================================
// 컬럼 매핑 관련 라우트 (논리명 ↔ 물리명) // 컬럼 매핑 관련 라우트 (논리명 ↔ 물리명)
// ================================================ // ================================================

View File

@ -24,6 +24,8 @@ import {
getLogData, getLogData,
toggleLogTable, toggleLogTable,
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회 getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
multiTableSave, // 🆕 범용 다중 테이블 저장
getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회
} from "../controllers/tableManagementController"; } from "../controllers/tableManagementController";
const router = express.Router(); const router = express.Router();
@ -37,6 +39,15 @@ router.use(authenticateToken);
*/ */
router.get("/tables", getTableList); router.get("/tables", getTableList);
/**
*
* GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy
*
* column_labels에서 /
* .
*/
router.get("/tables/entity-relations", getTableEntityRelations);
/** /**
* *
* GET /api/table-management/tables/:tableName/columns * GET /api/table-management/tables/:tableName/columns
@ -198,4 +209,17 @@ router.post("/tables/:tableName/log/toggle", toggleLogTable);
*/ */
router.get("/menu/:menuObjid/category-columns", getCategoryColumnsByMenu); router.get("/menu/:menuObjid/category-columns", getCategoryColumnsByMenu);
// ========================================
// 범용 다중 테이블 저장 API
// ========================================
/**
* ( + )
* POST /api/table-management/multi-table-save
*
* () .
* +, + 1:N .
*/
router.post("/multi-table-save", multiTableSave);
export default router; export default router;

View File

@ -0,0 +1,43 @@
/**
*
* /api/tax-invoice
*/
import { Router } from "express";
import { TaxInvoiceController } from "../controllers/taxInvoiceController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = Router();
// 모든 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
// 목록 조회
router.get("/", TaxInvoiceController.getList);
// 월별 통계 (상세 조회보다 먼저 정의해야 함)
router.get("/stats/monthly", TaxInvoiceController.getMonthlyStats);
// 비용 유형별 통계
router.get("/stats/cost-type", TaxInvoiceController.getCostTypeStats);
// 상세 조회
router.get("/:id", TaxInvoiceController.getById);
// 생성
router.post("/", TaxInvoiceController.create);
// 수정
router.put("/:id", TaxInvoiceController.update);
// 삭제
router.delete("/:id", TaxInvoiceController.delete);
// 발행
router.post("/:id/issue", TaxInvoiceController.issue);
// 취소
router.post("/:id/cancel", TaxInvoiceController.cancel);
export default router;

View File

@ -0,0 +1,71 @@
/**
*
*/
import { Router } from "express";
import {
startTrip,
endTrip,
addTripLocation,
getTripList,
getTripDetail,
getActiveTrip,
cancelTrip,
} from "../controllers/vehicleTripController";
import {
getDailyReport,
getWeeklyReport,
getMonthlyReport,
getSummaryReport,
getDriverReport,
getRouteReport,
} from "../controllers/vehicleReportController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = Router();
// 모든 라우트에 인증 적용
router.use(authenticateToken);
// === 운행 관리 ===
// 운행 시작
router.post("/trip/start", startTrip);
// 운행 종료
router.post("/trip/end", endTrip);
// 위치 기록 추가 (연속 추적)
router.post("/trip/location", addTripLocation);
// 활성 운행 조회 (현재 진행 중)
router.get("/trip/active", getActiveTrip);
// 운행 취소
router.post("/trip/cancel", cancelTrip);
// 운행 이력 목록 조회
router.get("/trips", getTripList);
// 운행 상세 조회 (경로 포함)
router.get("/trips/:tripId", getTripDetail);
// === 리포트 ===
// 요약 통계 (대시보드용)
router.get("/reports/summary", getSummaryReport);
// 일별 통계
router.get("/reports/daily", getDailyReport);
// 주별 통계
router.get("/reports/weekly", getWeeklyReport);
// 월별 통계
router.get("/reports/monthly", getMonthlyReport);
// 운전자별 통계
router.get("/reports/by-driver", getDriverReport);
// 구간별 통계
router.get("/reports/by-route", getRouteReport);
export default router;

View File

@ -19,15 +19,21 @@ export class AdminService {
// menuType에 따른 WHERE 조건 생성 // menuType에 따른 WHERE 조건 생성
const menuTypeCondition = const menuTypeCondition =
menuType !== undefined ? `MENU.MENU_TYPE = ${parseInt(menuType)}` : "1 = 1"; menuType !== undefined
? `MENU.MENU_TYPE = ${parseInt(menuType)}`
: "1 = 1";
// 메뉴 관리 화면인지 좌측 사이드바인지 구분 // 메뉴 관리 화면인지 좌측 사이드바인지 구분
// includeInactive가 true거나 menuType이 undefined면 메뉴 관리 화면 // includeInactive가 true거나 menuType이 undefined면 메뉴 관리 화면
const includeInactive = paramMap.includeInactive === true; const includeInactive = paramMap.includeInactive === true;
const isManagementScreen = includeInactive || menuType === undefined; const isManagementScreen = includeInactive || menuType === undefined;
// 메뉴 관리 화면: 모든 상태의 메뉴 표시, 좌측 사이드바: active만 표시 // 메뉴 관리 화면: 모든 상태의 메뉴 표시, 좌측 사이드바: active만 표시
const statusCondition = isManagementScreen ? "1 = 1" : "MENU.STATUS = 'active'"; const statusCondition = isManagementScreen
const subStatusCondition = isManagementScreen ? "1 = 1" : "MENU_SUB.STATUS = 'active'"; ? "1 = 1"
: "MENU.STATUS = 'active'";
const subStatusCondition = isManagementScreen
? "1 = 1"
: "MENU_SUB.STATUS = 'active'";
// 1. 권한 그룹 기반 필터링 (좌측 사이드바인 경우만) // 1. 권한 그룹 기반 필터링 (좌측 사이드바인 경우만)
let authFilter = ""; let authFilter = "";
@ -35,7 +41,11 @@ export class AdminService {
let queryParams: any[] = [userLang]; let queryParams: any[] = [userLang];
let paramIndex = 2; let paramIndex = 2;
if (menuType !== undefined && userType !== "SUPER_ADMIN" && !isManagementScreen) { if (
menuType !== undefined &&
userType !== "SUPER_ADMIN" &&
!isManagementScreen
) {
// 좌측 사이드바 + SUPER_ADMIN이 아닌 경우만 권한 체크 // 좌측 사이드바 + SUPER_ADMIN이 아닌 경우만 권한 체크
const userRoleGroups = await query<any>( const userRoleGroups = await query<any>(
` `
@ -55,46 +65,53 @@ export class AdminService {
} }
); );
// [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시
// TODO: 권한 체크 다시 활성화 필요
logger.info(
`⚠️ [임시 비활성화] 권한 그룹 체크 스킵 - 사용자 ${userId}(${userType})에게 전체 메뉴 표시`
);
/* [ - ]
if (userType === "COMPANY_ADMIN") { if (userType === "COMPANY_ADMIN") {
// 회사 관리자: 자기 회사 메뉴는 모두, 공통 메뉴는 권한 있는 것만 // 회사 관리자: 권한 그룹 기반 필터링 적용
if (userRoleGroups.length > 0) { if (userRoleGroups.length > 0) {
const roleObjids = userRoleGroups.map((rg: any) => rg.role_objid); const roleObjids = userRoleGroups.map((rg: any) => rg.role_objid);
// 루트 메뉴: 회사 코드만 체크 (권한 체크 X) // 회사 관리자도 권한 그룹 설정에 따라 메뉴 필터링
authFilter = `AND MENU.COMPANY_CODE IN ($${paramIndex}, '*')`; authFilter = `
AND MENU.COMPANY_CODE IN ($${paramIndex}, '*')
AND EXISTS (
SELECT 1
FROM rel_menu_auth rma
WHERE rma.menu_objid = MENU.OBJID
AND rma.auth_objid = ANY($${paramIndex + 1})
AND rma.read_yn = 'Y'
)
`;
queryParams.push(userCompanyCode); queryParams.push(userCompanyCode);
const companyParamIndex = paramIndex;
paramIndex++; paramIndex++;
// 하위 메뉴: 회사 메뉴는 모두, 공통 메뉴는 권한 체크 // 하위 메뉴 권한 체크
unionFilter = ` unionFilter = `
AND ( AND MENU_SUB.COMPANY_CODE IN ($${paramIndex - 1}, '*')
MENU_SUB.COMPANY_CODE = $${companyParamIndex} AND EXISTS (
OR ( SELECT 1
MENU_SUB.COMPANY_CODE = '*' FROM rel_menu_auth rma
AND EXISTS ( WHERE rma.menu_objid = MENU_SUB.OBJID
SELECT 1 AND rma.auth_objid = ANY($${paramIndex})
FROM rel_menu_auth rma AND rma.read_yn = 'Y'
WHERE rma.menu_objid = MENU_SUB.OBJID
AND rma.auth_objid = ANY($${paramIndex})
AND rma.read_yn = 'Y'
)
)
) )
`; `;
queryParams.push(roleObjids); queryParams.push(roleObjids);
paramIndex++; paramIndex++;
logger.info( logger.info(
`✅ 회사 관리자: 회사 ${userCompanyCode} 메뉴 전체 + 권한 있는 공통 메뉴` `✅ 회사 관리자: 권한 있는 메뉴만 (${roleObjids.length}개 그룹)`
); );
} else { } else {
// 권한 그룹이 없는 회사 관리자: 자기 회사 메뉴만 // 권한 그룹이 없는 회사 관리자: 메뉴 없음
authFilter = `AND MENU.COMPANY_CODE = $${paramIndex}`; logger.warn(
unionFilter = `AND MENU_SUB.COMPANY_CODE = $${paramIndex}`; `⚠️ 회사 관리자 ${userId}는 권한 그룹이 없어 메뉴가 표시되지 않습니다.`
queryParams.push(userCompanyCode);
paramIndex++;
logger.info(
`✅ 회사 관리자 (권한 그룹 없음): 회사 ${userCompanyCode} 메뉴만`
); );
return [];
} }
} else { } else {
// 일반 사용자: 권한 그룹 필수 // 일반 사용자: 권한 그룹 필수
@ -131,7 +148,12 @@ export class AdminService {
return []; return [];
} }
} }
} else if (menuType !== undefined && userType === "SUPER_ADMIN" && !isManagementScreen) { */
} else if (
menuType !== undefined &&
userType === "SUPER_ADMIN" &&
!isManagementScreen
) {
// 좌측 사이드바 + SUPER_ADMIN: 권한 그룹 체크 없이 모든 공통 메뉴 표시 // 좌측 사이드바 + SUPER_ADMIN: 권한 그룹 체크 없이 모든 공통 메뉴 표시
logger.info(`✅ 최고 관리자는 권한 그룹 체크 없이 모든 공통 메뉴 표시`); logger.info(`✅ 최고 관리자는 권한 그룹 체크 없이 모든 공통 메뉴 표시`);
// unionFilter는 비워둠 (하위 메뉴도 공통 메뉴만) // unionFilter는 비워둠 (하위 메뉴도 공통 메뉴만)
@ -167,7 +189,7 @@ export class AdminService {
companyFilter = `AND MENU.COMPANY_CODE = $${paramIndex}`; companyFilter = `AND MENU.COMPANY_CODE = $${paramIndex}`;
queryParams.push(userCompanyCode); queryParams.push(userCompanyCode);
paramIndex++; paramIndex++;
// 하위 메뉴에도 회사 필터링 적용 (공통 메뉴 제외) // 하위 메뉴에도 회사 필터링 적용 (공통 메뉴 제외)
if (unionFilter === "") { if (unionFilter === "") {
unionFilter = `AND MENU_SUB.COMPANY_CODE = $${paramIndex - 1}`; unionFilter = `AND MENU_SUB.COMPANY_CODE = $${paramIndex - 1}`;
@ -398,9 +420,18 @@ export class AdminService {
let queryParams: any[] = [userLang]; let queryParams: any[] = [userLang];
let paramIndex = 2; let paramIndex = 2;
if (userType === "SUPER_ADMIN" && userCompanyCode === "*") { // [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시
// SUPER_ADMIN: 권한 그룹 체크 없이 공통 메뉴만 표시 // TODO: 권한 체크 다시 활성화 필요
logger.info("✅ 좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시"); logger.info(
`⚠️ [임시 비활성화] getUserMenuList 권한 그룹 체크 스킵 - 사용자 ${userId}(${userType})에게 전체 메뉴 표시`
);
authFilter = "";
unionFilter = "";
/* [ - getUserMenuList ]
if (userType === "SUPER_ADMIN") {
// SUPER_ADMIN: 권한 그룹 체크 없이 해당 회사의 모든 메뉴 표시
logger.info(`✅ 좌측 사이드바 (SUPER_ADMIN): 회사 ${userCompanyCode}의 모든 메뉴 표시`);
authFilter = ""; authFilter = "";
unionFilter = ""; unionFilter = "";
} else { } else {
@ -457,6 +488,7 @@ export class AdminService {
return []; return [];
} }
} }
*/
// 2. 회사별 필터링 조건 생성 // 2. 회사별 필터링 조건 생성
let companyFilter = ""; let companyFilter = "";

View File

@ -342,4 +342,130 @@ export class AuthService {
); );
} }
} }
/**
*
* - user_info
* - vehicles
*/
static async signupDriver(data: {
userId: string;
password: string;
userName: string;
phoneNumber: string;
licenseNumber: string;
vehicleNumber: string;
vehicleType?: string;
}): Promise<{ success: boolean; message?: string }> {
try {
const {
userId,
password,
userName,
phoneNumber,
licenseNumber,
vehicleNumber,
vehicleType,
} = data;
// 1. 중복 사용자 확인
const existingUser = await query<any>(
`SELECT user_id FROM user_info WHERE user_id = $1`,
[userId]
);
if (existingUser.length > 0) {
return {
success: false,
message: "이미 존재하는 아이디입니다.",
};
}
// 2. 중복 차량번호 확인
const existingVehicle = await query<any>(
`SELECT vehicle_number FROM vehicles WHERE vehicle_number = $1`,
[vehicleNumber]
);
if (existingVehicle.length > 0) {
return {
success: false,
message: "이미 등록된 차량번호입니다.",
};
}
// 3. 비밀번호 암호화 (MD5 - 기존 시스템 호환)
const crypto = require("crypto");
const hashedPassword = crypto
.createHash("md5")
.update(password)
.digest("hex");
// 4. 사용자 정보 저장 (user_info)
await query(
`INSERT INTO user_info (
user_id,
user_password,
user_name,
cell_phone,
license_number,
vehicle_number,
company_code,
user_type,
signup_type,
status,
regdate
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW())`,
[
userId,
hashedPassword,
userName,
phoneNumber,
licenseNumber,
vehicleNumber,
"COMPANY_13", // 기본 회사 코드
null, // user_type: null
"DRIVER", // signup_type: 공차중계 회원가입 사용자
"active", // status: active
]
);
// 5. 차량 정보 저장 (vehicles)
await query(
`INSERT INTO vehicles (
vehicle_number,
vehicle_type,
driver_name,
driver_phone,
status,
company_code,
user_id,
created_at,
updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())`,
[
vehicleNumber,
vehicleType || null,
userName,
phoneNumber,
"off", // 초기 상태: off (대기)
"COMPANY_13", // 기본 회사 코드
userId, // 사용자 ID 연결
]
);
logger.info(`공차중계 회원가입 성공: ${userId}, 차량번호: ${vehicleNumber}`);
return {
success: true,
message: "회원가입이 완료되었습니다.",
};
} catch (error: any) {
logger.error("공차중계 회원가입 오류:", error);
return {
success: false,
message: error.message || "회원가입 중 오류가 발생했습니다.",
};
}
}
} }

View File

@ -2,6 +2,7 @@ import cron, { ScheduledTask } from "node-cron";
import { BatchService } from "./batchService"; import { BatchService } from "./batchService";
import { BatchExecutionLogService } from "./batchExecutionLogService"; import { BatchExecutionLogService } from "./batchExecutionLogService";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { query } from "../database/db";
export class BatchSchedulerService { export class BatchSchedulerService {
private static scheduledTasks: Map<number, ScheduledTask> = new Map(); private static scheduledTasks: Map<number, ScheduledTask> = new Map();
@ -64,12 +65,18 @@ export class BatchSchedulerService {
`배치 스케줄 등록: ${config.batch_name} (ID: ${config.id}, Cron: ${config.cron_schedule})` `배치 스케줄 등록: ${config.batch_name} (ID: ${config.id}, Cron: ${config.cron_schedule})`
); );
const task = cron.schedule(config.cron_schedule, async () => { const task = cron.schedule(
logger.info( config.cron_schedule,
`스케줄에 의한 배치 실행 시작: ${config.batch_name} (ID: ${config.id})` async () => {
); logger.info(
await this.executeBatchConfig(config); `스케줄에 의한 배치 실행 시작: ${config.batch_name} (ID: ${config.id})`
}); );
await this.executeBatchConfig(config);
},
{
timezone: "Asia/Seoul", // 한국 시간 기준으로 스케줄 실행
}
);
this.scheduledTasks.set(config.id, task); this.scheduledTasks.set(config.id, task);
} catch (error) { } catch (error) {
@ -214,9 +221,16 @@ export class BatchSchedulerService {
} }
// 테이블별로 매핑을 그룹화 // 테이블별로 매핑을 그룹화
// 고정값 매핑(mapping_type === 'fixed')은 별도 그룹으로 분리하지 않고 나중에 처리
const tableGroups = new Map<string, typeof config.batch_mappings>(); const tableGroups = new Map<string, typeof config.batch_mappings>();
const fixedMappingsGlobal: typeof config.batch_mappings = [];
for (const mapping of config.batch_mappings) { for (const mapping of config.batch_mappings) {
// 고정값 매핑은 별도로 모아둠 (FROM 소스가 필요 없음)
if (mapping.mapping_type === "fixed") {
fixedMappingsGlobal.push(mapping);
continue;
}
const key = `${mapping.from_connection_type}:${mapping.from_connection_id || "internal"}:${mapping.from_table_name}`; const key = `${mapping.from_connection_type}:${mapping.from_connection_id || "internal"}:${mapping.from_table_name}`;
if (!tableGroups.has(key)) { if (!tableGroups.has(key)) {
tableGroups.set(key, []); tableGroups.set(key, []);
@ -224,6 +238,14 @@ export class BatchSchedulerService {
tableGroups.get(key)!.push(mapping); tableGroups.get(key)!.push(mapping);
} }
// 고정값 매핑만 있고 일반 매핑이 없는 경우 처리
if (tableGroups.size === 0 && fixedMappingsGlobal.length > 0) {
logger.warn(
`일반 매핑이 없고 고정값 매핑만 있습니다. 고정값만으로는 배치를 실행할 수 없습니다.`
);
return { totalRecords, successRecords, failedRecords };
}
// 각 테이블 그룹별로 처리 // 각 테이블 그룹별로 처리
for (const [tableKey, mappings] of tableGroups) { for (const [tableKey, mappings] of tableGroups) {
try { try {
@ -244,10 +266,46 @@ export class BatchSchedulerService {
"./batchExternalDbService" "./batchExternalDbService"
); );
// auth_service_name이 설정된 경우 auth_tokens에서 토큰 조회 (멀티테넌시 적용)
let apiKey = firstMapping.from_api_key || "";
if (config.auth_service_name) {
let tokenQuery: string;
let tokenParams: any[];
if (config.company_code === "*") {
// 최고 관리자 배치: 모든 회사 토큰 조회 가능
tokenQuery = `SELECT access_token FROM auth_tokens
WHERE service_name = $1
ORDER BY created_date DESC LIMIT 1`;
tokenParams = [config.auth_service_name];
} else {
// 일반 회사 배치: 자신의 회사 토큰만 조회
tokenQuery = `SELECT access_token FROM auth_tokens
WHERE service_name = $1 AND company_code = $2
ORDER BY created_date DESC LIMIT 1`;
tokenParams = [config.auth_service_name, config.company_code];
}
const tokenResult = await query<{ access_token: string }>(
tokenQuery,
tokenParams
);
if (tokenResult.length > 0 && tokenResult[0].access_token) {
apiKey = tokenResult[0].access_token;
logger.info(
`auth_tokens에서 토큰 조회 성공: ${config.auth_service_name}`
);
} else {
logger.warn(
`auth_tokens에서 토큰을 찾을 수 없음: ${config.auth_service_name}`
);
}
}
// 👇 Body 파라미터 추가 (POST 요청 시) // 👇 Body 파라미터 추가 (POST 요청 시)
const apiResult = await BatchExternalDbService.getDataFromRestApi( const apiResult = await BatchExternalDbService.getDataFromRestApi(
firstMapping.from_api_url!, firstMapping.from_api_url!,
firstMapping.from_api_key!, apiKey,
firstMapping.from_table_name, firstMapping.from_table_name,
(firstMapping.from_api_method as (firstMapping.from_api_method as
| "GET" | "GET"
@ -266,7 +324,36 @@ export class BatchSchedulerService {
); );
if (apiResult.success && apiResult.data) { if (apiResult.success && apiResult.data) {
fromData = apiResult.data; // 데이터 배열 경로가 설정되어 있으면 해당 경로에서 배열 추출
if (config.data_array_path) {
const extractArrayByPath = (obj: any, path: string): any[] => {
if (!path) return Array.isArray(obj) ? obj : [obj];
const keys = path.split(".");
let current = obj;
for (const key of keys) {
if (current === null || current === undefined) return [];
current = current[key];
}
return Array.isArray(current)
? current
: current
? [current]
: [];
};
// apiResult.data가 단일 객체인 경우 (API 응답 전체)
const rawData =
Array.isArray(apiResult.data) && apiResult.data.length === 1
? apiResult.data[0]
: apiResult.data;
fromData = extractArrayByPath(rawData, config.data_array_path);
logger.info(
`데이터 배열 경로 '${config.data_array_path}'에서 ${fromData.length}개 레코드 추출`
);
} else {
fromData = apiResult.data;
}
} else { } else {
throw new Error(`REST API 데이터 조회 실패: ${apiResult.message}`); throw new Error(`REST API 데이터 조회 실패: ${apiResult.message}`);
} }
@ -298,6 +385,11 @@ export class BatchSchedulerService {
const mappedData = fromData.map((row) => { const mappedData = fromData.map((row) => {
const mappedRow: any = {}; const mappedRow: any = {};
for (const mapping of mappings) { for (const mapping of mappings) {
// 고정값 매핑은 이미 분리되어 있으므로 여기서는 처리하지 않음
if (mapping.mapping_type === "fixed") {
continue;
}
// DB → REST API 배치인지 확인 // DB → REST API 배치인지 확인
if ( if (
firstMapping.to_connection_type === "restapi" && firstMapping.to_connection_type === "restapi" &&
@ -315,6 +407,13 @@ export class BatchSchedulerService {
} }
} }
// 고정값 매핑 적용 (전역으로 분리된 fixedMappingsGlobal 사용)
for (const fixedMapping of fixedMappingsGlobal) {
// from_column_name에 고정값이 저장되어 있음
mappedRow[fixedMapping.to_column_name] =
fixedMapping.from_column_name;
}
// 멀티테넌시: TO가 DB일 때 company_code 자동 주입 // 멀티테넌시: TO가 DB일 때 company_code 자동 주입
// - 배치 설정에 company_code가 있고 // - 배치 설정에 company_code가 있고
// - 매핑에서 company_code를 명시적으로 다루지 않은 경우만 // - 매핑에서 company_code를 명시적으로 다루지 않은 경우만
@ -384,12 +483,14 @@ export class BatchSchedulerService {
insertResult = { successCount: 0, failedCount: 0 }; insertResult = { successCount: 0, failedCount: 0 };
} }
} else { } else {
// DB에 데이터 삽입 // DB에 데이터 삽입 (save_mode, conflict_key 지원)
insertResult = await BatchService.insertDataToTable( insertResult = await BatchService.insertDataToTable(
firstMapping.to_table_name, firstMapping.to_table_name,
mappedData, mappedData,
firstMapping.to_connection_type as "internal" | "external", firstMapping.to_connection_type as "internal" | "external",
firstMapping.to_connection_id || undefined firstMapping.to_connection_id || undefined,
(config.save_mode as "INSERT" | "UPSERT") || "INSERT",
config.conflict_key || undefined
); );
} }

View File

@ -176,8 +176,8 @@ export class BatchService {
// 배치 설정 생성 // 배치 설정 생성
const batchConfigResult = await client.query( const batchConfigResult = await client.query(
`INSERT INTO batch_configs `INSERT INTO batch_configs
(batch_name, description, cron_schedule, is_active, company_code, created_by, created_date, updated_date) (batch_name, description, cron_schedule, is_active, company_code, save_mode, conflict_key, auth_service_name, data_array_path, created_by, created_date, updated_date)
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW()) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
RETURNING *`, RETURNING *`,
[ [
data.batchName, data.batchName,
@ -185,6 +185,10 @@ export class BatchService {
data.cronSchedule, data.cronSchedule,
data.isActive || "Y", data.isActive || "Y",
data.companyCode, data.companyCode,
data.saveMode || "INSERT",
data.conflictKey || null,
data.authServiceName || null,
data.dataArrayPath || null,
userId, userId,
] ]
); );
@ -201,37 +205,38 @@ export class BatchService {
from_column_type, from_api_url, from_api_key, from_api_method, from_api_param_type, 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, 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_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, created_by, created_date) to_api_url, to_api_key, to_api_method, to_api_body, mapping_order, mapping_type, created_by, created_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, NOW()) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, NOW())
RETURNING *`, RETURNING *`,
[ [
batchConfig.id, batchConfig.id,
data.companyCode, // 멀티테넌시: 배치 설정과 동일한 company_code 사용 data.companyCode, // 멀티테넌시: 배치 설정과 동일한 company_code 사용
mapping.from_connection_type, mapping.from_connection_type,
mapping.from_connection_id, mapping.from_connection_id,
mapping.from_table_name, mapping.from_table_name,
mapping.from_column_name, mapping.from_column_name,
mapping.from_column_type, mapping.from_column_type,
mapping.from_api_url, mapping.from_api_url,
mapping.from_api_key, mapping.from_api_key,
mapping.from_api_method, mapping.from_api_method,
mapping.from_api_param_type, mapping.from_api_param_type,
mapping.from_api_param_name, mapping.from_api_param_name,
mapping.from_api_param_value, mapping.from_api_param_value,
mapping.from_api_param_source, mapping.from_api_param_source,
mapping.from_api_body, // FROM REST API Body mapping.from_api_body, // FROM REST API Body
mapping.to_connection_type, mapping.to_connection_type,
mapping.to_connection_id, mapping.to_connection_id,
mapping.to_table_name, mapping.to_table_name,
mapping.to_column_name, mapping.to_column_name,
mapping.to_column_type, mapping.to_column_type,
mapping.to_api_url, mapping.to_api_url,
mapping.to_api_key, mapping.to_api_key,
mapping.to_api_method, mapping.to_api_method,
mapping.to_api_body, mapping.to_api_body,
mapping.mapping_order || index + 1, mapping.mapping_order || index + 1,
userId, mapping.mapping_type || "direct", // 매핑 타입: direct 또는 fixed
] userId,
]
); );
mappings.push(mappingResult.rows[0]); mappings.push(mappingResult.rows[0]);
} }
@ -311,6 +316,22 @@ export class BatchService {
updateFields.push(`is_active = $${paramIndex++}`); updateFields.push(`is_active = $${paramIndex++}`);
updateValues.push(data.isActive); updateValues.push(data.isActive);
} }
if (data.saveMode !== undefined) {
updateFields.push(`save_mode = $${paramIndex++}`);
updateValues.push(data.saveMode);
}
if (data.conflictKey !== undefined) {
updateFields.push(`conflict_key = $${paramIndex++}`);
updateValues.push(data.conflictKey || null);
}
if (data.authServiceName !== undefined) {
updateFields.push(`auth_service_name = $${paramIndex++}`);
updateValues.push(data.authServiceName || null);
}
if (data.dataArrayPath !== undefined) {
updateFields.push(`data_array_path = $${paramIndex++}`);
updateValues.push(data.dataArrayPath || null);
}
// 배치 설정 업데이트 // 배치 설정 업데이트
const batchConfigResult = await client.query( const batchConfigResult = await client.query(
@ -339,8 +360,8 @@ export class BatchService {
from_column_type, from_api_url, from_api_key, from_api_method, from_api_param_type, 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, 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_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, created_by, created_date) to_api_url, to_api_key, to_api_method, to_api_body, mapping_order, mapping_type, created_by, created_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, NOW()) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, NOW())
RETURNING *`, RETURNING *`,
[ [
id, id,
@ -368,6 +389,7 @@ export class BatchService {
mapping.to_api_method, mapping.to_api_method,
mapping.to_api_body, mapping.to_api_body,
mapping.mapping_order || index + 1, mapping.mapping_order || index + 1,
mapping.mapping_type || "direct", // 매핑 타입: direct 또는 fixed
userId, userId,
] ]
); );
@ -554,9 +576,7 @@ export class BatchService {
try { try {
if (connectionType === "internal") { if (connectionType === "internal") {
// 내부 DB 데이터 조회 // 내부 DB 데이터 조회
const data = await query<any>( const data = await query<any>(`SELECT * FROM ${tableName} LIMIT 10`);
`SELECT * FROM ${tableName} LIMIT 10`
);
return { return {
success: true, success: true,
data, data,
@ -729,19 +749,27 @@ export class BatchService {
/** /**
* ( / DB ) * ( / DB )
* @param tableName
* @param data
* @param connectionType (internal/external)
* @param connectionId ID
* @param saveMode (INSERT/UPSERT)
* @param conflictKey UPSERT
*/ */
static async insertDataToTable( static async insertDataToTable(
tableName: string, tableName: string,
data: any[], data: any[],
connectionType: "internal" | "external" = "internal", connectionType: "internal" | "external" = "internal",
connectionId?: number connectionId?: number,
saveMode: "INSERT" | "UPSERT" = "INSERT",
conflictKey?: string
): Promise<{ ): Promise<{
successCount: number; successCount: number;
failedCount: number; failedCount: number;
}> { }> {
try { try {
console.log( console.log(
`[BatchService] 테이블에 데이터 삽입: ${tableName} (${connectionType}${connectionId ? `:${connectionId}` : ""}), ${data.length}개 레코드` `[BatchService] 테이블에 데이터 ${saveMode}: ${tableName} (${connectionType}${connectionId ? `:${connectionId}` : ""}), ${data.length}개 레코드${conflictKey ? `, 충돌키: ${conflictKey}` : ""}`
); );
if (!data || data.length === 0) { if (!data || data.length === 0) {
@ -753,24 +781,54 @@ export class BatchService {
let successCount = 0; let successCount = 0;
let failedCount = 0; let failedCount = 0;
// 각 레코드를 개별적으로 삽입 (UPSERT 방식으로 중복 처리) // 각 레코드를 개별적으로 삽입
for (const record of data) { for (const record of data) {
try { try {
const columns = Object.keys(record); const columns = Object.keys(record);
const values = Object.values(record); const values = Object.values(record);
const placeholders = values const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
.map((_, i) => `$${i + 1}`)
.join(", ");
const queryStr = `INSERT INTO ${tableName} (${columns.join( let queryStr: string;
", "
)}) VALUES (${placeholders})`; if (saveMode === "UPSERT" && conflictKey) {
// UPSERT 모드: ON CONFLICT DO UPDATE
// 충돌 키를 제외한 컬럼들만 UPDATE
const updateColumns = columns.filter(
(col) => col !== conflictKey
);
// 업데이트할 컬럼이 없으면 DO NOTHING 사용
if (updateColumns.length === 0) {
queryStr = `INSERT INTO ${tableName} (${columns.join(", ")})
VALUES (${placeholders})
ON CONFLICT (${conflictKey})
DO NOTHING`;
} else {
const updateSet = updateColumns
.map((col) => `${col} = EXCLUDED.${col}`)
.join(", ");
// updated_date 컬럼이 있으면 현재 시간으로 업데이트
const hasUpdatedDate = columns.includes("updated_date");
const finalUpdateSet = hasUpdatedDate
? `${updateSet}, updated_date = NOW()`
: updateSet;
queryStr = `INSERT INTO ${tableName} (${columns.join(", ")})
VALUES (${placeholders})
ON CONFLICT (${conflictKey})
DO UPDATE SET ${finalUpdateSet}`;
}
} else {
// INSERT 모드: 기존 방식
queryStr = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders})`;
}
await query(queryStr, values); await query(queryStr, values);
successCount++; successCount++;
} catch (insertError) { } catch (insertError) {
console.error( console.error(
`내부 DB 데이터 삽입 실패 (${tableName}):`, `내부 DB 데이터 ${saveMode} 실패 (${tableName}):`,
insertError insertError
); );
failedCount++; failedCount++;
@ -779,7 +837,13 @@ export class BatchService {
return { successCount, failedCount }; return { successCount, failedCount };
} else if (connectionType === "external" && connectionId) { } else if (connectionType === "external" && connectionId) {
// 외부 DB에 데이터 삽입 // 외부 DB에 데이터 삽입 (UPSERT는 내부 DB만 지원)
if (saveMode === "UPSERT") {
console.warn(
`[BatchService] 외부 DB는 UPSERT를 지원하지 않습니다. INSERT로 실행합니다.`
);
}
const result = await BatchExternalDbService.insertDataToTable( const result = await BatchExternalDbService.insertDataToTable(
connectionId, connectionId,
tableName, tableName,
@ -799,7 +863,7 @@ export class BatchService {
); );
} }
} catch (error) { } catch (error) {
console.error(`데이터 삽입 오류 (${tableName}):`, error); console.error(`데이터 ${saveMode} 오류 (${tableName}):`, error);
return { successCount: 0, failedCount: data ? data.length : 0 }; return { successCount: 0, failedCount: data ? data.length : 0 };
} }
} }

View File

@ -25,6 +25,8 @@ export interface CodeInfo {
is_active: string; is_active: string;
company_code: string; company_code: string;
menu_objid?: number | null; // 메뉴 기반 코드 관리용 menu_objid?: number | null; // 메뉴 기반 코드 관리용
parent_code_value?: string | null; // 계층구조: 부모 코드값
depth?: number; // 계층구조: 깊이 (1, 2, 3단계)
created_date?: Date | null; created_date?: Date | null;
created_by?: string | null; created_by?: string | null;
updated_date?: Date | null; updated_date?: Date | null;
@ -61,6 +63,8 @@ export interface CreateCodeData {
description?: string; description?: string;
sortOrder?: number; sortOrder?: number;
isActive?: string; isActive?: string;
parentCodeValue?: string; // 계층구조: 부모 코드값
depth?: number; // 계층구조: 깊이 (1, 2, 3단계)
} }
export class CommonCodeService { export class CommonCodeService {
@ -86,11 +90,12 @@ export class CommonCodeService {
} }
// 회사별 필터링 (최고 관리자가 아닌 경우) // 회사별 필터링 (최고 관리자가 아닌 경우)
// company_code = '*'인 공통 데이터도 함께 조회
if (userCompanyCode && userCompanyCode !== "*") { if (userCompanyCode && userCompanyCode !== "*") {
whereConditions.push(`company_code = $${paramIndex}`); whereConditions.push(`(company_code = $${paramIndex} OR company_code = '*')`);
values.push(userCompanyCode); values.push(userCompanyCode);
paramIndex++; paramIndex++;
logger.info(`회사별 코드 카테고리 필터링: ${userCompanyCode}`); logger.info(`회사별 코드 카테고리 필터링: ${userCompanyCode} (공통 데이터 포함)`);
} else if (userCompanyCode === "*") { } else if (userCompanyCode === "*") {
// 최고 관리자는 모든 데이터 조회 가능 // 최고 관리자는 모든 데이터 조회 가능
logger.info(`최고 관리자: 모든 코드 카테고리 조회`); logger.info(`최고 관리자: 모든 코드 카테고리 조회`);
@ -116,7 +121,7 @@ export class CommonCodeService {
const offset = (page - 1) * size; const offset = (page - 1) * size;
// 카테고리 조회 // code_category 테이블에서만 조회 (comm_code 제거)
const categories = await query<CodeCategory>( const categories = await query<CodeCategory>(
`SELECT * FROM code_category `SELECT * FROM code_category
${whereClause} ${whereClause}
@ -134,7 +139,7 @@ export class CommonCodeService {
const total = parseInt(countResult?.count || "0"); const total = parseInt(countResult?.count || "0");
logger.info( logger.info(
`카테고리 조회 완료: ${categories.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"})` `카테고리 조회 완료: code_category ${categories.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"})`
); );
return { return {
@ -224,7 +229,7 @@ export class CommonCodeService {
paramIndex, paramIndex,
}); });
// 코드 조회 // code_info 테이블에서만 코드 조회 (comm_code fallback 제거)
const codes = await query<CodeInfo>( const codes = await query<CodeInfo>(
`SELECT * FROM code_info `SELECT * FROM code_info
${whereClause} ${whereClause}
@ -242,20 +247,9 @@ export class CommonCodeService {
const total = parseInt(countResult?.count || "0"); const total = parseInt(countResult?.count || "0");
logger.info( logger.info(
`✅ [getCodes] 코드 조회 완료: ${categoryCode} - ${codes.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"}, menuObjid: ${menuObjid || "없음"})` `코드 조회 완료: ${categoryCode} - ${codes.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"}, menuObjid: ${menuObjid || "없음"})`
); );
logger.info(`📊 [getCodes] 조회된 코드 상세:`, {
categoryCode,
menuObjid,
codes: codes.map((c) => ({
code_value: c.code_value,
code_name: c.code_name,
menu_objid: c.menu_objid,
company_code: c.company_code,
})),
});
return { data: codes, total }; return { data: codes, total };
} catch (error) { } catch (error) {
logger.error(`코드 조회 중 오류 (${categoryCode}):`, error); logger.error(`코드 조회 중 오류 (${categoryCode}):`, error);
@ -415,11 +409,22 @@ export class CommonCodeService {
menuObjid: number menuObjid: number
) { ) {
try { try {
// 계층구조: depth 계산 (부모가 있으면 부모의 depth + 1, 없으면 1)
let depth = 1;
if (data.parentCodeValue) {
const parentCode = await queryOne<CodeInfo>(
`SELECT depth FROM code_info
WHERE code_category = $1 AND code_value = $2 AND company_code = $3`,
[categoryCode, data.parentCodeValue, companyCode]
);
depth = parentCode ? (parentCode.depth || 1) + 1 : 1;
}
const code = await queryOne<CodeInfo>( const code = await queryOne<CodeInfo>(
`INSERT INTO code_info `INSERT INTO code_info
(code_category, code_value, code_name, code_name_eng, description, sort_order, (code_category, code_value, code_name, code_name_eng, description, sort_order,
is_active, menu_objid, company_code, created_by, updated_by, created_date, updated_date) is_active, menu_objid, company_code, parent_code_value, depth, created_by, updated_by, created_date, updated_date)
VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7, $8, $9, $10, NOW(), NOW()) VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7, $8, $9, $10, $11, $12, NOW(), NOW())
RETURNING *`, RETURNING *`,
[ [
categoryCode, categoryCode,
@ -430,13 +435,15 @@ export class CommonCodeService {
data.sortOrder || 0, data.sortOrder || 0,
menuObjid, menuObjid,
companyCode, companyCode,
data.parentCodeValue || null,
depth,
createdBy, createdBy,
createdBy, createdBy,
] ]
); );
logger.info( logger.info(
`코드 생성 완료: ${categoryCode}.${data.codeValue} (메뉴: ${menuObjid}, 회사: ${companyCode})` `코드 생성 완료: ${categoryCode}.${data.codeValue} (메뉴: ${menuObjid}, 회사: ${companyCode}, 부모: ${data.parentCodeValue || '없음'}, 깊이: ${depth})`
); );
return code; return code;
} catch (error) { } catch (error) {
@ -501,6 +508,24 @@ export class CommonCodeService {
updateFields.push(`is_active = $${paramIndex++}`); updateFields.push(`is_active = $${paramIndex++}`);
values.push(activeValue); values.push(activeValue);
} }
// 계층구조: 부모 코드값 수정
if (data.parentCodeValue !== undefined) {
updateFields.push(`parent_code_value = $${paramIndex++}`);
values.push(data.parentCodeValue || null);
// depth도 함께 업데이트
let newDepth = 1;
if (data.parentCodeValue) {
const parentCode = await queryOne<CodeInfo>(
`SELECT depth FROM code_info
WHERE code_category = $1 AND code_value = $2`,
[categoryCode, data.parentCodeValue]
);
newDepth = parentCode ? (parentCode.depth || 1) + 1 : 1;
}
updateFields.push(`depth = $${paramIndex++}`);
values.push(newDepth);
}
// WHERE 절 구성 // WHERE 절 구성
let whereClause = `WHERE code_category = $${paramIndex++} AND code_value = $${paramIndex}`; let whereClause = `WHERE code_category = $${paramIndex++} AND code_value = $${paramIndex}`;
@ -857,4 +882,170 @@ export class CommonCodeService {
throw error; throw error;
} }
} }
/**
* ( depth )
* @param categoryCode
* @param parentCodeValue ( )
* @param depth ()
*/
async getHierarchicalCodes(
categoryCode: string,
parentCodeValue?: string | null,
depth?: number,
userCompanyCode?: string,
menuObjid?: number
) {
try {
const whereConditions: string[] = ["code_category = $1", "is_active = 'Y'"];
const values: any[] = [categoryCode];
let paramIndex = 2;
// 부모 코드값 필터링
if (parentCodeValue === null || parentCodeValue === undefined) {
// 최상위 코드 (부모가 없는 코드)
whereConditions.push("(parent_code_value IS NULL OR parent_code_value = '')");
} else if (parentCodeValue !== '') {
whereConditions.push(`parent_code_value = $${paramIndex}`);
values.push(parentCodeValue);
paramIndex++;
}
// 특정 깊이 필터링
if (depth !== undefined) {
whereConditions.push(`depth = $${paramIndex}`);
values.push(depth);
paramIndex++;
}
// 메뉴별 필터링 (형제 메뉴 포함)
if (menuObjid) {
const { getSiblingMenuObjids } = await import('./menuService');
const siblingMenuObjids = await getSiblingMenuObjids(menuObjid);
whereConditions.push(`menu_objid = ANY($${paramIndex})`);
values.push(siblingMenuObjids);
paramIndex++;
}
// 회사별 필터링
if (userCompanyCode && userCompanyCode !== "*") {
whereConditions.push(`company_code = $${paramIndex}`);
values.push(userCompanyCode);
paramIndex++;
}
const whereClause = `WHERE ${whereConditions.join(" AND ")}`;
const codes = await query<CodeInfo>(
`SELECT * FROM code_info
${whereClause}
ORDER BY sort_order ASC, code_value ASC`,
values
);
logger.info(
`계층구조 코드 조회: ${categoryCode}, 부모: ${parentCodeValue || '최상위'}, 깊이: ${depth || '전체'} - ${codes.length}`
);
return codes;
} catch (error) {
logger.error(`계층구조 코드 조회 중 오류 (${categoryCode}):`, error);
throw error;
}
}
/**
* ( )
*/
async getCodeTree(
categoryCode: string,
userCompanyCode?: string,
menuObjid?: number
) {
try {
const whereConditions: string[] = ["code_category = $1", "is_active = 'Y'"];
const values: any[] = [categoryCode];
let paramIndex = 2;
// 메뉴별 필터링 (형제 메뉴 포함)
if (menuObjid) {
const { getSiblingMenuObjids } = await import('./menuService');
const siblingMenuObjids = await getSiblingMenuObjids(menuObjid);
whereConditions.push(`menu_objid = ANY($${paramIndex})`);
values.push(siblingMenuObjids);
paramIndex++;
}
// 회사별 필터링
if (userCompanyCode && userCompanyCode !== "*") {
whereConditions.push(`company_code = $${paramIndex}`);
values.push(userCompanyCode);
paramIndex++;
}
const whereClause = `WHERE ${whereConditions.join(" AND ")}`;
const allCodes = await query<CodeInfo>(
`SELECT * FROM code_info
${whereClause}
ORDER BY depth ASC, sort_order ASC, code_value ASC`,
values
);
// 트리 구조로 변환
const buildTree = (codes: CodeInfo[], parentValue: string | null = null): any[] => {
return codes
.filter(code => {
const codeParent = code.parent_code_value || null;
return codeParent === parentValue;
})
.map(code => ({
...code,
children: buildTree(codes, code.code_value)
}));
};
const tree = buildTree(allCodes);
logger.info(
`코드 트리 조회 완료: ${categoryCode} - 전체 ${allCodes.length}`
);
return {
flat: allCodes,
tree
};
} catch (error) {
logger.error(`코드 트리 조회 중 오류 (${categoryCode}):`, error);
throw error;
}
}
/**
*
*/
async hasChildren(
categoryCode: string,
codeValue: string,
companyCode?: string
): Promise<boolean> {
try {
let sql = `SELECT COUNT(*) as count FROM code_info
WHERE code_category = $1 AND parent_code_value = $2`;
const values: any[] = [categoryCode, codeValue];
if (companyCode && companyCode !== "*") {
sql += ` AND company_code = $3`;
values.push(companyCode);
}
const result = await queryOne<{ count: string }>(sql, values);
const count = parseInt(result?.count || "0");
return count > 0;
} catch (error) {
logger.error(`자식 코드 확인 중 오류 (${categoryCode}.${codeValue}):`, error);
throw error;
}
}
} }

View File

@ -1,12 +1,12 @@
/** /**
* *
* *
* : * :
* 1. - * 1. -
* 2. - * 2. -
* 3. - company_code * 3. - company_code
* 4. SQL - / * 4. SQL - /
* *
* : * :
* - , , * - , ,
* - (pg_*, information_schema ) * - (pg_*, information_schema )
@ -70,11 +70,11 @@ class DataService {
// 그룹별로 데이터 분류 // 그룹별로 데이터 분류
const groups: Record<string, any[]> = {}; const groups: Record<string, any[]> = {};
for (const row of data) { for (const row of data) {
const groupKey = row[config.groupByColumn]; const groupKey = row[config.groupByColumn];
if (groupKey === undefined || groupKey === null) continue; if (groupKey === undefined || groupKey === null) continue;
if (!groups[groupKey]) { if (!groups[groupKey]) {
groups[groupKey] = []; groups[groupKey] = [];
} }
@ -83,12 +83,12 @@ class DataService {
// 각 그룹에서 하나의 행만 선택 // 각 그룹에서 하나의 행만 선택
const result: any[] = []; const result: any[] = [];
for (const [groupKey, rows] of Object.entries(groups)) { for (const [groupKey, rows] of Object.entries(groups)) {
if (rows.length === 0) continue; if (rows.length === 0) continue;
let selectedRow: any; let selectedRow: any;
switch (config.keepStrategy) { switch (config.keepStrategy) {
case "latest": case "latest":
// 정렬 컬럼 기준 최신 (가장 큰 값) // 정렬 컬럼 기준 최신 (가장 큰 값)
@ -103,7 +103,7 @@ class DataService {
} }
selectedRow = rows[0]; selectedRow = rows[0];
break; break;
case "earliest": case "earliest":
// 정렬 컬럼 기준 최초 (가장 작은 값) // 정렬 컬럼 기준 최초 (가장 작은 값)
if (config.sortColumn) { if (config.sortColumn) {
@ -117,38 +117,41 @@ class DataService {
} }
selectedRow = rows[0]; selectedRow = rows[0];
break; break;
case "base_price": case "base_price":
// base_price = true인 행 찾기 // base_price = true인 행 찾기
selectedRow = rows.find(row => row.base_price === true) || rows[0]; selectedRow = rows.find((row) => row.base_price === true) || rows[0];
break; break;
case "current_date": case "current_date":
// start_date <= CURRENT_DATE <= end_date 조건에 맞는 행 // start_date <= CURRENT_DATE <= end_date 조건에 맞는 행
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); // 시간 제거 today.setHours(0, 0, 0, 0); // 시간 제거
selectedRow = rows.find(row => { selectedRow =
const startDate = row.start_date ? new Date(row.start_date) : null; rows.find((row) => {
const endDate = row.end_date ? new Date(row.end_date) : null; const startDate = row.start_date
? new Date(row.start_date)
if (startDate) startDate.setHours(0, 0, 0, 0); : null;
if (endDate) endDate.setHours(0, 0, 0, 0); const endDate = row.end_date ? new Date(row.end_date) : null;
const afterStart = !startDate || today >= startDate; if (startDate) startDate.setHours(0, 0, 0, 0);
const beforeEnd = !endDate || today <= endDate; if (endDate) endDate.setHours(0, 0, 0, 0);
return afterStart && beforeEnd; const afterStart = !startDate || today >= startDate;
}) || rows[0]; // 조건에 맞는 행이 없으면 첫 번째 행 const beforeEnd = !endDate || today <= endDate;
return afterStart && beforeEnd;
}) || rows[0]; // 조건에 맞는 행이 없으면 첫 번째 행
break; break;
default: default:
selectedRow = rows[0]; selectedRow = rows[0];
} }
result.push(selectedRow); result.push(selectedRow);
} }
return result; return result;
} }
@ -230,12 +233,17 @@ class DataService {
// 4. 회사별 필터링 자동 적용 (company_code 컬럼이 있는 경우) // 4. 회사별 필터링 자동 적용 (company_code 컬럼이 있는 경우)
if (userCompany && userCompany !== "*") { if (userCompany && userCompany !== "*") {
const hasCompanyCode = await this.checkColumnExists(tableName, "company_code"); const hasCompanyCode = await this.checkColumnExists(
tableName,
"company_code"
);
if (hasCompanyCode) { if (hasCompanyCode) {
whereConditions.push(`company_code = $${paramIndex}`); whereConditions.push(`company_code = $${paramIndex}`);
queryParams.push(userCompany); queryParams.push(userCompany);
paramIndex++; paramIndex++;
console.log(`🏢 회사별 필터링 적용: ${tableName}.company_code = ${userCompany}`); console.log(
`🏢 회사별 필터링 적용: ${tableName}.company_code = ${userCompany}`
);
} }
} }
@ -246,7 +254,10 @@ class DataService {
key !== "limit" && key !== "limit" &&
key !== "offset" && key !== "offset" &&
key !== "orderBy" && key !== "orderBy" &&
key !== "userLang" key !== "userLang" &&
key !== "page" &&
key !== "pageSize" &&
key !== "size"
) { ) {
// 컬럼명 검증 (SQL 인젝션 방지) // 컬럼명 검증 (SQL 인젝션 방지)
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) { if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
@ -508,7 +519,8 @@ class DataService {
const entityJoinService = new EntityJoinService(); const entityJoinService = new EntityJoinService();
// Entity Join 구성 감지 // Entity Join 구성 감지
const joinConfigs = await entityJoinService.detectEntityJoins(tableName); const joinConfigs =
await entityJoinService.detectEntityJoins(tableName);
if (joinConfigs.length > 0) { if (joinConfigs.length > 0) {
console.log(`✅ Entity Join 감지: ${joinConfigs.length}`); console.log(`✅ Entity Join 감지: ${joinConfigs.length}`);
@ -518,7 +530,7 @@ class DataService {
tableName, tableName,
joinConfigs, joinConfigs,
["*"], ["*"],
`main."${pkColumn}" = $1` // 🔧 main. 접두사 추가하여 모호성 해결 `main."${pkColumn}" = $1` // 🔧 main. 접두사 추가하여 모호성 해결
); );
const result = await pool.query(joinQuery, [id]); const result = await pool.query(joinQuery, [id]);
@ -533,14 +545,14 @@ class DataService {
// 🔧 날짜 타입 타임존 문제 해결: Date 객체를 YYYY-MM-DD 문자열로 변환 // 🔧 날짜 타입 타임존 문제 해결: Date 객체를 YYYY-MM-DD 문자열로 변환
const normalizeDates = (rows: any[]) => { const normalizeDates = (rows: any[]) => {
return rows.map(row => { return rows.map((row) => {
const normalized: any = {}; const normalized: any = {};
for (const [key, value] of Object.entries(row)) { for (const [key, value] of Object.entries(row)) {
if (value instanceof Date) { if (value instanceof Date) {
// Date 객체를 YYYY-MM-DD 형식으로 변환 (타임존 무시) // Date 객체를 YYYY-MM-DD 형식으로 변환 (타임존 무시)
const year = value.getFullYear(); const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, '0'); const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, '0'); const day = String(value.getDate()).padStart(2, "0");
normalized[key] = `${year}-${month}-${day}`; normalized[key] = `${year}-${month}-${day}`;
} else { } else {
normalized[key] = value; normalized[key] = value;
@ -551,17 +563,20 @@ class DataService {
}; };
const normalizedRows = normalizeDates(result.rows); const normalizedRows = normalizeDates(result.rows);
console.log(`✅ Entity Join 데이터 조회 성공 (날짜 정규화됨):`, normalizedRows[0]); console.log(
`✅ Entity Join 데이터 조회 성공 (날짜 정규화됨):`,
normalizedRows[0]
);
// 🆕 groupByColumns가 있으면 그룹핑 기반 다중 레코드 조회 // 🆕 groupByColumns가 있으면 그룹핑 기반 다중 레코드 조회
if (groupByColumns.length > 0) { if (groupByColumns.length > 0) {
const baseRecord = result.rows[0]; const baseRecord = result.rows[0];
// 그룹핑 컬럼들의 값 추출 // 그룹핑 컬럼들의 값 추출
const groupConditions: string[] = []; const groupConditions: string[] = [];
const groupValues: any[] = []; const groupValues: any[] = [];
let paramIndex = 1; let paramIndex = 1;
for (const col of groupByColumns) { for (const col of groupByColumns) {
const value = normalizedRows[0][col]; const value = normalizedRows[0][col];
if (value !== undefined && value !== null) { if (value !== undefined && value !== null) {
@ -570,12 +585,15 @@ class DataService {
paramIndex++; paramIndex++;
} }
} }
if (groupConditions.length > 0) { if (groupConditions.length > 0) {
const groupWhereClause = groupConditions.join(" AND "); const groupWhereClause = groupConditions.join(" AND ");
console.log(`🔍 그룹핑 조회: ${groupByColumns.join(", ")}`, groupValues); console.log(
`🔍 그룹핑 조회: ${groupByColumns.join(", ")}`,
groupValues
);
// 그룹핑 기준으로 모든 레코드 조회 // 그룹핑 기준으로 모든 레코드 조회
const { query: groupQuery } = entityJoinService.buildJoinQuery( const { query: groupQuery } = entityJoinService.buildJoinQuery(
tableName, tableName,
@ -583,12 +601,14 @@ class DataService {
["*"], ["*"],
groupWhereClause groupWhereClause
); );
const groupResult = await pool.query(groupQuery, groupValues); const groupResult = await pool.query(groupQuery, groupValues);
const normalizedGroupRows = normalizeDates(groupResult.rows); const normalizedGroupRows = normalizeDates(groupResult.rows);
console.log(`✅ 그룹 레코드 조회 성공: ${normalizedGroupRows.length}`); console.log(
`✅ 그룹 레코드 조회 성공: ${normalizedGroupRows.length}`
);
return { return {
success: true, success: true,
data: normalizedGroupRows, // 🔧 배열로 반환! data: normalizedGroupRows, // 🔧 배열로 반환!
@ -642,7 +662,8 @@ class DataService {
dataFilter?: any, // 🆕 데이터 필터 dataFilter?: any, // 🆕 데이터 필터
enableEntityJoin?: boolean, // 🆕 Entity 조인 활성화 enableEntityJoin?: boolean, // 🆕 Entity 조인 활성화
displayColumns?: Array<{ name: string; label?: string }>, // 🆕 표시 컬럼 (item_info.item_name 등) displayColumns?: Array<{ name: string; label?: string }>, // 🆕 표시 컬럼 (item_info.item_name 등)
deduplication?: { // 🆕 중복 제거 설정 deduplication?: {
// 🆕 중복 제거 설정
enabled: boolean; enabled: boolean;
groupByColumn: string; groupByColumn: string;
keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
@ -666,36 +687,41 @@ class DataService {
if (enableEntityJoin) { if (enableEntityJoin) {
try { try {
const { entityJoinService } = await import("./entityJoinService"); const { entityJoinService } = await import("./entityJoinService");
const joinConfigs = await entityJoinService.detectEntityJoins(rightTable); const joinConfigs =
await entityJoinService.detectEntityJoins(rightTable);
// 🆕 displayColumns에서 추가 조인 필요한 컬럼 감지 (item_info.item_name 등) // 🆕 displayColumns에서 추가 조인 필요한 컬럼 감지 (item_info.item_name 등)
if (displayColumns && Array.isArray(displayColumns)) { if (displayColumns && Array.isArray(displayColumns)) {
// 테이블별로 요청된 컬럼들을 그룹핑 // 테이블별로 요청된 컬럼들을 그룹핑
const tableColumns: Record<string, Set<string>> = {}; const tableColumns: Record<string, Set<string>> = {};
for (const col of displayColumns) { for (const col of displayColumns) {
if (col.name && col.name.includes('.')) { if (col.name && col.name.includes(".")) {
const [refTable, refColumn] = col.name.split('.'); const [refTable, refColumn] = col.name.split(".");
if (!tableColumns[refTable]) { if (!tableColumns[refTable]) {
tableColumns[refTable] = new Set(); tableColumns[refTable] = new Set();
} }
tableColumns[refTable].add(refColumn); tableColumns[refTable].add(refColumn);
} }
} }
// 각 테이블별로 처리 // 각 테이블별로 처리
for (const [refTable, refColumns] of Object.entries(tableColumns)) { for (const [refTable, refColumns] of Object.entries(tableColumns)) {
// 이미 조인 설정에 있는지 확인 // 이미 조인 설정에 있는지 확인
const existingJoins = joinConfigs.filter(jc => jc.referenceTable === refTable); const existingJoins = joinConfigs.filter(
(jc) => jc.referenceTable === refTable
);
if (existingJoins.length > 0) { if (existingJoins.length > 0) {
// 기존 조인이 있으면, 각 컬럼을 개별 조인으로 분리 // 기존 조인이 있으면, 각 컬럼을 개별 조인으로 분리
for (const refColumn of refColumns) { for (const refColumn of refColumns) {
// 이미 해당 컬럼을 표시하는 조인이 있는지 확인 // 이미 해당 컬럼을 표시하는 조인이 있는지 확인
const existingJoin = existingJoins.find( const existingJoin = existingJoins.find(
jc => jc.displayColumns.length === 1 && jc.displayColumns[0] === refColumn (jc) =>
jc.displayColumns.length === 1 &&
jc.displayColumns[0] === refColumn
); );
if (!existingJoin) { if (!existingJoin) {
// 없으면 새 조인 설정 복제하여 추가 // 없으면 새 조인 설정 복제하여 추가
const baseJoin = existingJoins[0]; const baseJoin = existingJoins[0];
@ -708,7 +734,9 @@ class DataService {
referenceColumn: baseJoin.referenceColumn, // item_number 등 referenceColumn: baseJoin.referenceColumn, // item_number 등
}; };
joinConfigs.push(newJoin); joinConfigs.push(newJoin);
console.log(`📌 추가 표시 컬럼: ${refTable}.${refColumn} (새 조인 생성, alias: ${newJoin.aliasColumn})`); console.log(
`📌 추가 표시 컬럼: ${refTable}.${refColumn} (새 조인 생성, alias: ${newJoin.aliasColumn})`
);
} }
} }
} else { } else {
@ -718,7 +746,9 @@ class DataService {
} }
if (joinConfigs.length > 0) { if (joinConfigs.length > 0) {
console.log(`🔗 조인 모드에서 Entity 조인 적용: ${joinConfigs.length}개 설정`); console.log(
`🔗 조인 모드에서 Entity 조인 적용: ${joinConfigs.length}개 설정`
);
// WHERE 조건 생성 // WHERE 조건 생성
const whereConditions: string[] = []; const whereConditions: string[] = [];
@ -735,7 +765,10 @@ class DataService {
// 회사별 필터링 // 회사별 필터링
if (userCompany && userCompany !== "*") { if (userCompany && userCompany !== "*") {
const hasCompanyCode = await this.checkColumnExists(rightTable, "company_code"); const hasCompanyCode = await this.checkColumnExists(
rightTable,
"company_code"
);
if (hasCompanyCode) { if (hasCompanyCode) {
whereConditions.push(`main.company_code = $${paramIndex}`); whereConditions.push(`main.company_code = $${paramIndex}`);
values.push(userCompany); values.push(userCompany);
@ -744,48 +777,64 @@ class DataService {
} }
// 데이터 필터 적용 (buildDataFilterWhereClause 사용) // 데이터 필터 적용 (buildDataFilterWhereClause 사용)
if (dataFilter && dataFilter.enabled && dataFilter.filters && dataFilter.filters.length > 0) { if (
const { buildDataFilterWhereClause } = await import("../utils/dataFilterUtil"); dataFilter &&
const filterResult = buildDataFilterWhereClause(dataFilter, "main", paramIndex); dataFilter.enabled &&
dataFilter.filters &&
dataFilter.filters.length > 0
) {
const { buildDataFilterWhereClause } = await import(
"../utils/dataFilterUtil"
);
const filterResult = buildDataFilterWhereClause(
dataFilter,
"main",
paramIndex
);
if (filterResult.whereClause) { if (filterResult.whereClause) {
whereConditions.push(filterResult.whereClause); whereConditions.push(filterResult.whereClause);
values.push(...filterResult.params); values.push(...filterResult.params);
paramIndex += filterResult.params.length; paramIndex += filterResult.params.length;
console.log(`🔍 Entity 조인에 데이터 필터 적용 (${rightTable}):`, filterResult.whereClause); console.log(
`🔍 Entity 조인에 데이터 필터 적용 (${rightTable}):`,
filterResult.whereClause
);
console.log(`📊 필터 파라미터:`, filterResult.params); console.log(`📊 필터 파라미터:`, filterResult.params);
} }
} }
const whereClause = whereConditions.length > 0 ? whereConditions.join(" AND ") : ""; const whereClause =
whereConditions.length > 0 ? whereConditions.join(" AND ") : "";
// Entity 조인 쿼리 빌드 // Entity 조인 쿼리 빌드
// buildJoinQuery가 자동으로 main.* 처리하므로 ["*"]만 전달 // buildJoinQuery가 자동으로 main.* 처리하므로 ["*"]만 전달
const selectColumns = ["*"]; const selectColumns = ["*"];
const { query: finalQuery, aliasMap } = entityJoinService.buildJoinQuery( const { query: finalQuery, aliasMap } =
rightTable, entityJoinService.buildJoinQuery(
joinConfigs, rightTable,
selectColumns, joinConfigs,
whereClause, selectColumns,
"", whereClause,
undefined, "",
undefined undefined,
); undefined
);
console.log(`🔍 Entity 조인 쿼리 실행 (전체):`, finalQuery); console.log(`🔍 Entity 조인 쿼리 실행 (전체):`, finalQuery);
console.log(`🔍 파라미터:`, values); console.log(`🔍 파라미터:`, values);
const result = await pool.query(finalQuery, values); const result = await pool.query(finalQuery, values);
// 🔧 날짜 타입 타임존 문제 해결 // 🔧 날짜 타입 타임존 문제 해결
const normalizeDates = (rows: any[]) => { const normalizeDates = (rows: any[]) => {
return rows.map(row => { return rows.map((row) => {
const normalized: any = {}; const normalized: any = {};
for (const [key, value] of Object.entries(row)) { for (const [key, value] of Object.entries(row)) {
if (value instanceof Date) { if (value instanceof Date) {
const year = value.getFullYear(); const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, '0'); const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, '0'); const day = String(value.getDate()).padStart(2, "0");
normalized[key] = `${year}-${month}-${day}`; normalized[key] = `${year}-${month}-${day}`;
} else { } else {
normalized[key] = value; normalized[key] = value;
@ -794,18 +843,24 @@ class DataService {
return normalized; return normalized;
}); });
}; };
const normalizedRows = normalizeDates(result.rows); const normalizedRows = normalizeDates(result.rows);
console.log(`✅ Entity 조인 성공! 반환된 데이터 개수: ${normalizedRows.length}개 (날짜 정규화됨)`); console.log(
`✅ Entity 조인 성공! 반환된 데이터 개수: ${normalizedRows.length}개 (날짜 정규화됨)`
);
// 🆕 중복 제거 처리 // 🆕 중복 제거 처리
let finalData = normalizedRows; let finalData = normalizedRows;
if (deduplication?.enabled && deduplication.groupByColumn) { if (deduplication?.enabled && deduplication.groupByColumn) {
console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`); console.log(
`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`
);
finalData = this.deduplicateData(normalizedRows, deduplication); finalData = this.deduplicateData(normalizedRows, deduplication);
console.log(`✅ 중복 제거 완료: ${normalizedRows.length}개 → ${finalData.length}`); console.log(
`✅ 중복 제거 완료: ${normalizedRows.length}개 → ${finalData.length}`
);
} }
return { return {
success: true, success: true,
data: finalData, data: finalData,
@ -838,23 +893,40 @@ class DataService {
// 우측 테이블 회사별 필터링 (company_code 컬럼이 있는 경우) // 우측 테이블 회사별 필터링 (company_code 컬럼이 있는 경우)
if (userCompany && userCompany !== "*") { if (userCompany && userCompany !== "*") {
const hasCompanyCode = await this.checkColumnExists(rightTable, "company_code"); const hasCompanyCode = await this.checkColumnExists(
rightTable,
"company_code"
);
if (hasCompanyCode) { if (hasCompanyCode) {
whereConditions.push(`r.company_code = $${paramIndex}`); whereConditions.push(`r.company_code = $${paramIndex}`);
values.push(userCompany); values.push(userCompany);
paramIndex++; paramIndex++;
console.log(`🏢 우측 패널 회사별 필터링 적용: ${rightTable}.company_code = ${userCompany}`); console.log(
`🏢 우측 패널 회사별 필터링 적용: ${rightTable}.company_code = ${userCompany}`
);
} }
} }
// 🆕 데이터 필터 적용 (우측 패널에 대해, 테이블 별칭 "r" 사용) // 🆕 데이터 필터 적용 (우측 패널에 대해, 테이블 별칭 "r" 사용)
if (dataFilter && dataFilter.enabled && dataFilter.filters && dataFilter.filters.length > 0) { if (
const filterResult = buildDataFilterWhereClause(dataFilter, "r", paramIndex); dataFilter &&
dataFilter.enabled &&
dataFilter.filters &&
dataFilter.filters.length > 0
) {
const filterResult = buildDataFilterWhereClause(
dataFilter,
"r",
paramIndex
);
if (filterResult.whereClause) { if (filterResult.whereClause) {
whereConditions.push(filterResult.whereClause); whereConditions.push(filterResult.whereClause);
values.push(...filterResult.params); values.push(...filterResult.params);
paramIndex += filterResult.params.length; paramIndex += filterResult.params.length;
console.log(`🔍 데이터 필터 적용 (${rightTable}):`, filterResult.whereClause); console.log(
`🔍 데이터 필터 적용 (${rightTable}):`,
filterResult.whereClause
);
} }
} }
@ -871,9 +943,13 @@ class DataService {
// 🆕 중복 제거 처리 // 🆕 중복 제거 처리
let finalData = result; let finalData = result;
if (deduplication?.enabled && deduplication.groupByColumn) { if (deduplication?.enabled && deduplication.groupByColumn) {
console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`); console.log(
`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`
);
finalData = this.deduplicateData(result, deduplication); finalData = this.deduplicateData(result, deduplication);
console.log(`✅ 중복 제거 완료: ${result.length}개 → ${finalData.length}`); console.log(
`✅ 중복 제거 완료: ${result.length}개 → ${finalData.length}`
);
} }
return { return {
@ -907,8 +983,31 @@ class DataService {
return validation.error!; return validation.error!;
} }
const columns = Object.keys(data); // 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼 제외)
const values = Object.values(data); const tableColumns = await this.getTableColumnsSimple(tableName);
const validColumnNames = new Set(
tableColumns.map((col: any) => col.column_name)
);
const invalidColumns: string[] = [];
const filteredData = Object.fromEntries(
Object.entries(data).filter(([key]) => {
if (validColumnNames.has(key)) {
return true;
}
invalidColumns.push(key);
return false;
})
);
if (invalidColumns.length > 0) {
console.log(
`⚠️ [createRecord] 테이블에 없는 컬럼 제외: ${invalidColumns.join(", ")}`
);
}
const columns = Object.keys(filteredData);
const values = Object.values(filteredData);
const placeholders = values.map((_, index) => `$${index + 1}`).join(", "); const placeholders = values.map((_, index) => `$${index + 1}`).join(", ");
const columnNames = columns.map((col) => `"${col}"`).join(", "); const columnNames = columns.map((col) => `"${col}"`).join(", ");
@ -951,9 +1050,32 @@ class DataService {
// _relationInfo 추출 (조인 관계 업데이트용) // _relationInfo 추출 (조인 관계 업데이트용)
const relationInfo = data._relationInfo; const relationInfo = data._relationInfo;
const cleanData = { ...data }; let cleanData = { ...data };
delete cleanData._relationInfo; delete cleanData._relationInfo;
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼 제외)
const tableColumns = await this.getTableColumnsSimple(tableName);
const validColumnNames = new Set(
tableColumns.map((col: any) => col.column_name)
);
const invalidColumns: string[] = [];
cleanData = Object.fromEntries(
Object.entries(cleanData).filter(([key]) => {
if (validColumnNames.has(key)) {
return true;
}
invalidColumns.push(key);
return false;
})
);
if (invalidColumns.length > 0) {
console.log(
`⚠️ [updateRecord] 테이블에 없는 컬럼 제외: ${invalidColumns.join(", ")}`
);
}
// Primary Key 컬럼 찾기 // Primary Key 컬럼 찾기
const pkResult = await query<{ attname: string }>( const pkResult = await query<{ attname: string }>(
`SELECT a.attname `SELECT a.attname
@ -993,8 +1115,14 @@ class DataService {
} }
// 🔗 조인 관계가 있는 경우, 연결된 테이블의 FK도 업데이트 // 🔗 조인 관계가 있는 경우, 연결된 테이블의 FK도 업데이트
if (relationInfo && relationInfo.rightTable && relationInfo.leftColumn && relationInfo.rightColumn) { if (
const { rightTable, leftColumn, rightColumn, oldLeftValue } = relationInfo; relationInfo &&
relationInfo.rightTable &&
relationInfo.leftColumn &&
relationInfo.rightColumn
) {
const { rightTable, leftColumn, rightColumn, oldLeftValue } =
relationInfo;
const newLeftValue = cleanData[leftColumn]; const newLeftValue = cleanData[leftColumn];
// leftColumn 값이 변경된 경우에만 우측 테이블 업데이트 // leftColumn 값이 변경된 경우에만 우측 테이블 업데이트
@ -1012,8 +1140,13 @@ class DataService {
SET "${rightColumn}" = $1 SET "${rightColumn}" = $1
WHERE "${rightColumn}" = $2 WHERE "${rightColumn}" = $2
`; `;
const updateResult = await query(updateRelatedQuery, [newLeftValue, oldLeftValue]); const updateResult = await query(updateRelatedQuery, [
console.log(`✅ 연결된 ${rightTable} 테이블의 ${updateResult.length}개 레코드 업데이트 완료`); newLeftValue,
oldLeftValue,
]);
console.log(
`✅ 연결된 ${rightTable} 테이블의 ${updateResult.length}개 레코드 업데이트 완료`
);
} catch (relError) { } catch (relError) {
console.error("❌ 연결된 테이블 업데이트 실패:", relError); console.error("❌ 연결된 테이블 업데이트 실패:", relError);
// 연결된 테이블 업데이트 실패 시 롤백은 하지 않고 경고만 로그 // 연결된 테이블 업데이트 실패 시 롤백은 하지 않고 경고만 로그
@ -1059,14 +1192,23 @@ class DataService {
[tableName] [tableName]
); );
console.log(`🔍 테이블 ${tableName}의 Primary Key 조회 결과:`, {
pkColumns: pkResult.map((r) => r.attname),
pkCount: pkResult.length,
inputId: typeof id === "object" ? JSON.stringify(id).substring(0, 200) + "..." : id,
inputIdType: typeof id,
});
let whereClauses: string[] = []; let whereClauses: string[] = [];
let params: any[] = []; let params: any[] = [];
if (pkResult.length > 1) { if (pkResult.length > 1) {
// 복합키인 경우: id가 객체여야 함 // 복합키인 경우: id가 객체여야 함
console.log(`🔑 복합키 테이블: ${tableName}, PK: [${pkResult.map(r => r.attname).join(', ')}]`); console.log(
`🔑 복합키 테이블: ${tableName}, PK: [${pkResult.map((r) => r.attname).join(", ")}]`
if (typeof id === 'object' && !Array.isArray(id)) { );
if (typeof id === "object" && !Array.isArray(id)) {
// id가 객체인 경우: { user_id: 'xxx', dept_code: 'yyy' } // id가 객체인 경우: { user_id: 'xxx', dept_code: 'yyy' }
pkResult.forEach((pk, index) => { pkResult.forEach((pk, index) => {
whereClauses.push(`"${pk.attname}" = $${index + 1}`); whereClauses.push(`"${pk.attname}" = $${index + 1}`);
@ -1081,18 +1223,34 @@ class DataService {
// 단일키인 경우 // 단일키인 경우
const pkColumn = pkResult.length > 0 ? pkResult[0].attname : "id"; const pkColumn = pkResult.length > 0 ? pkResult[0].attname : "id";
whereClauses.push(`"${pkColumn}" = $1`); whereClauses.push(`"${pkColumn}" = $1`);
params.push(typeof id === 'object' ? id[pkColumn] : id); params.push(typeof id === "object" ? id[pkColumn] : id);
} }
const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(' AND ')}`; const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(" AND ")} RETURNING *`;
console.log(`🗑️ 삭제 쿼리:`, queryText, params); console.log(`🗑️ 삭제 쿼리:`, queryText, params);
const result = await query<any>(queryText, params); const result = await query<any>(queryText, params);
console.log(`✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}`); // 삭제된 행이 없으면 실패 처리
if (result.length === 0) {
console.warn(
`⚠️ 레코드 삭제 실패: ${tableName}, 해당 조건에 맞는 레코드가 없습니다.`,
{ whereClauses, params }
);
return {
success: false,
message: "삭제할 레코드를 찾을 수 없습니다. 이미 삭제되었거나 권한이 없습니다.",
error: "RECORD_NOT_FOUND",
};
}
console.log(
`✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}`
);
return { return {
success: true, success: true,
data: result[0], // 삭제된 레코드 정보 반환
}; };
} catch (error) { } catch (error) {
console.error(`레코드 삭제 오류 (${tableName}):`, error); console.error(`레코드 삭제 오류 (${tableName}):`, error);
@ -1106,10 +1264,14 @@ class DataService {
/** /**
* ( ) * ( )
* @param tableName
* @param filterConditions
* @param userCompany ( )
*/ */
async deleteGroupRecords( async deleteGroupRecords(
tableName: string, tableName: string,
filterConditions: Record<string, any> filterConditions: Record<string, any>,
userCompany?: string
): Promise<ServiceResponse<{ deleted: number }>> { ): Promise<ServiceResponse<{ deleted: number }>> {
try { try {
const validation = await this.validateTableAccess(tableName); const validation = await this.validateTableAccess(tableName);
@ -1121,6 +1283,7 @@ class DataService {
const whereValues: any[] = []; const whereValues: any[] = [];
let paramIndex = 1; let paramIndex = 1;
// 사용자 필터 조건 추가
for (const [key, value] of Object.entries(filterConditions)) { for (const [key, value] of Object.entries(filterConditions)) {
whereConditions.push(`"${key}" = $${paramIndex}`); whereConditions.push(`"${key}" = $${paramIndex}`);
whereValues.push(value); whereValues.push(value);
@ -1128,13 +1291,31 @@ class DataService {
} }
if (whereConditions.length === 0) { if (whereConditions.length === 0) {
return { success: false, message: "삭제 조건이 없습니다.", error: "NO_CONDITIONS" }; return {
success: false,
message: "삭제 조건이 없습니다.",
error: "NO_CONDITIONS",
};
}
// 🔒 멀티테넌시: company_code 필터링 (최고 관리자 제외)
const hasCompanyCode = await this.checkColumnExists(tableName, "company_code");
if (hasCompanyCode && userCompany && userCompany !== "*") {
whereConditions.push(`"company_code" = $${paramIndex}`);
whereValues.push(userCompany);
paramIndex++;
console.log(`🔒 멀티테넌시 필터 적용: company_code = ${userCompany}`);
} }
const whereClause = whereConditions.join(" AND "); const whereClause = whereConditions.join(" AND ");
const deleteQuery = `DELETE FROM "${tableName}" WHERE ${whereClause} RETURNING *`; const deleteQuery = `DELETE FROM "${tableName}" WHERE ${whereClause} RETURNING *`;
console.log(`🗑️ 그룹 삭제:`, { tableName, conditions: filterConditions }); console.log(`🗑️ 그룹 삭제:`, {
tableName,
conditions: filterConditions,
userCompany,
whereClause,
});
const result = await pool.query(deleteQuery, whereValues); const result = await pool.query(deleteQuery, whereValues);
@ -1163,7 +1344,9 @@ class DataService {
records: Array<Record<string, any>>, records: Array<Record<string, any>>,
userCompany?: string, userCompany?: string,
userId?: string userId?: string
): Promise<ServiceResponse<{ inserted: number; updated: number; deleted: number }>> { ): Promise<
ServiceResponse<{ inserted: number; updated: number; deleted: number }>
> {
try { try {
// 테이블 접근 권한 검증 // 테이블 접근 권한 검증
const validation = await this.validateTableAccess(tableName); const validation = await this.validateTableAccess(tableName);
@ -1201,11 +1384,14 @@ class DataService {
const whereClause = whereConditions.join(" AND "); const whereClause = whereConditions.join(" AND ");
const selectQuery = `SELECT * FROM "${tableName}" WHERE ${whereClause}`; const selectQuery = `SELECT * FROM "${tableName}" WHERE ${whereClause}`;
console.log(`📋 기존 레코드 조회:`, { query: selectQuery, values: whereValues }); console.log(`📋 기존 레코드 조회:`, {
query: selectQuery,
values: whereValues,
});
const existingRecords = await pool.query(selectQuery, whereValues); const existingRecords = await pool.query(selectQuery, whereValues);
console.log(`✅ 기존 레코드: ${existingRecords.rows.length}`); console.log(`✅ 기존 레코드: ${existingRecords.rows.length}`);
// 2. 새 레코드와 기존 레코드 비교 // 2. 새 레코드와 기존 레코드 비교
@ -1216,50 +1402,53 @@ class DataService {
// 날짜 필드를 YYYY-MM-DD 형식으로 변환하는 헬퍼 함수 // 날짜 필드를 YYYY-MM-DD 형식으로 변환하는 헬퍼 함수
const normalizeDateValue = (value: any): any => { const normalizeDateValue = (value: any): any => {
if (value == null) return value; if (value == null) return value;
// ISO 날짜 문자열 감지 (YYYY-MM-DDTHH:mm:ss.sssZ) // ISO 날짜 문자열 감지 (YYYY-MM-DDTHH:mm:ss.sssZ)
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) { if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
return value.split('T')[0]; // YYYY-MM-DD 만 추출 return value.split("T")[0]; // YYYY-MM-DD 만 추출
} }
return value; return value;
}; };
// 새 레코드 처리 (INSERT or UPDATE) // 새 레코드 처리 (INSERT or UPDATE)
for (const newRecord of records) { for (const newRecord of records) {
console.log(`🔍 처리할 새 레코드:`, newRecord); console.log(`🔍 처리할 새 레코드:`, newRecord);
// 날짜 필드 정규화 // 날짜 필드 정규화
const normalizedRecord: Record<string, any> = {}; const normalizedRecord: Record<string, any> = {};
for (const [key, value] of Object.entries(newRecord)) { for (const [key, value] of Object.entries(newRecord)) {
normalizedRecord[key] = normalizeDateValue(value); normalizedRecord[key] = normalizeDateValue(value);
} }
console.log(`🔄 정규화된 레코드:`, normalizedRecord); console.log(`🔄 정규화된 레코드:`, normalizedRecord);
// 전체 레코드 데이터 (parentKeys + normalizedRecord) // 전체 레코드 데이터 (parentKeys + normalizedRecord)
const fullRecord = { ...parentKeys, ...normalizedRecord }; const fullRecord = { ...parentKeys, ...normalizedRecord };
// 고유 키: parentKeys 제외한 나머지 필드들 // 고유 키: parentKeys 제외한 나머지 필드들
const uniqueFields = Object.keys(normalizedRecord); const uniqueFields = Object.keys(normalizedRecord);
console.log(`🔑 고유 필드들:`, uniqueFields); console.log(`🔑 고유 필드들:`, uniqueFields);
// 기존 레코드에서 일치하는 것 찾기 // 기존 레코드에서 일치하는 것 찾기
const existingRecord = existingRecords.rows.find((existing) => { const existingRecord = existingRecords.rows.find((existing) => {
return uniqueFields.every((field) => { return uniqueFields.every((field) => {
const existingValue = existing[field]; const existingValue = existing[field];
const newValue = normalizedRecord[field]; const newValue = normalizedRecord[field];
// null/undefined 처리 // null/undefined 처리
if (existingValue == null && newValue == null) return true; if (existingValue == null && newValue == null) return true;
if (existingValue == null || newValue == null) return false; if (existingValue == null || newValue == null) return false;
// Date 타입 처리 // Date 타입 처리
if (existingValue instanceof Date && typeof newValue === 'string') { if (existingValue instanceof Date && typeof newValue === "string") {
return existingValue.toISOString().split('T')[0] === newValue.split('T')[0]; return (
existingValue.toISOString().split("T")[0] ===
newValue.split("T")[0]
);
} }
// 문자열 비교 // 문자열 비교
return String(existingValue) === String(newValue); return String(existingValue) === String(newValue);
}); });
@ -1272,7 +1461,8 @@ class DataService {
let updateParamIndex = 1; let updateParamIndex = 1;
for (const [key, value] of Object.entries(fullRecord)) { for (const [key, value] of Object.entries(fullRecord)) {
if (key !== pkColumn) { // Primary Key는 업데이트하지 않음 if (key !== pkColumn) {
// Primary Key는 업데이트하지 않음
updateFields.push(`"${key}" = $${updateParamIndex}`); updateFields.push(`"${key}" = $${updateParamIndex}`);
updateValues.push(value); updateValues.push(value);
updateParamIndex++; updateParamIndex++;
@ -1288,36 +1478,42 @@ class DataService {
await pool.query(updateQuery, updateValues); await pool.query(updateQuery, updateValues);
updated++; updated++;
console.log(`✏️ UPDATE: ${pkColumn} = ${existingRecord[pkColumn]}`); console.log(`✏️ UPDATE: ${pkColumn} = ${existingRecord[pkColumn]}`);
} else { } else {
// INSERT: 기존 레코드가 없으면 삽입 // INSERT: 기존 레코드가 없으면 삽입
// 🆕 자동 필드 추가 (company_code, writer, created_date, updated_date, id) // 🆕 자동 필드 추가 (company_code, writer, created_date, updated_date, id)
// created_date는 프론트엔드에서 전달된 값 무시하고 항상 현재 시간 설정
const { created_date: _, ...recordWithoutCreatedDate } = fullRecord;
const recordWithMeta: Record<string, any> = { const recordWithMeta: Record<string, any> = {
...fullRecord, ...recordWithoutCreatedDate,
id: uuidv4(), // 새 ID 생성 id: uuidv4(), // 새 ID 생성
created_date: "NOW()", created_date: "NOW()",
updated_date: "NOW()", updated_date: "NOW()",
}; };
// company_code가 없으면 userCompany 사용 (단, userCompany가 "*"가 아닐 때만) // company_code가 없으면 userCompany 사용 (단, userCompany가 "*"가 아닐 때만)
if (!recordWithMeta.company_code && userCompany && userCompany !== "*") { if (
!recordWithMeta.company_code &&
userCompany &&
userCompany !== "*"
) {
recordWithMeta.company_code = userCompany; recordWithMeta.company_code = userCompany;
} }
// writer가 없으면 userId 사용 // writer가 없으면 userId 사용
if (!recordWithMeta.writer && userId) { if (!recordWithMeta.writer && userId) {
recordWithMeta.writer = userId; recordWithMeta.writer = userId;
} }
const insertFields = Object.keys(recordWithMeta).filter(key => const insertFields = Object.keys(recordWithMeta).filter(
recordWithMeta[key] !== "NOW()" (key) => recordWithMeta[key] !== "NOW()"
); );
const insertPlaceholders: string[] = []; const insertPlaceholders: string[] = [];
const insertValues: any[] = []; const insertValues: any[] = [];
let insertParamIndex = 1; let insertParamIndex = 1;
for (const field of Object.keys(recordWithMeta)) { for (const field of Object.keys(recordWithMeta)) {
if (recordWithMeta[field] === "NOW()") { if (recordWithMeta[field] === "NOW()") {
insertPlaceholders.push("NOW()"); insertPlaceholders.push("NOW()");
@ -1329,15 +1525,20 @@ class DataService {
} }
const insertQuery = ` const insertQuery = `
INSERT INTO "${tableName}" (${Object.keys(recordWithMeta).map(f => `"${f}"`).join(", ")}) INSERT INTO "${tableName}" (${Object.keys(recordWithMeta)
.map((f) => `"${f}"`)
.join(", ")})
VALUES (${insertPlaceholders.join(", ")}) VALUES (${insertPlaceholders.join(", ")})
`; `;
console.log(` INSERT 쿼리:`, { query: insertQuery, values: insertValues }); console.log(` INSERT 쿼리:`, {
query: insertQuery,
values: insertValues,
});
await pool.query(insertQuery, insertValues); await pool.query(insertQuery, insertValues);
inserted++; inserted++;
console.log(` INSERT: 새 레코드`); console.log(` INSERT: 새 레코드`);
} }
} }
@ -1345,19 +1546,22 @@ class DataService {
// 3. 삭제할 레코드 찾기 (기존 레코드 중 새 레코드에 없는 것) // 3. 삭제할 레코드 찾기 (기존 레코드 중 새 레코드에 없는 것)
for (const existingRecord of existingRecords.rows) { for (const existingRecord of existingRecords.rows) {
const uniqueFields = Object.keys(records[0] || {}); const uniqueFields = Object.keys(records[0] || {});
const stillExists = records.some((newRecord) => { const stillExists = records.some((newRecord) => {
return uniqueFields.every((field) => { return uniqueFields.every((field) => {
const existingValue = existingRecord[field]; const existingValue = existingRecord[field];
const newValue = newRecord[field]; const newValue = newRecord[field];
if (existingValue == null && newValue == null) return true; if (existingValue == null && newValue == null) return true;
if (existingValue == null || newValue == null) return false; if (existingValue == null || newValue == null) return false;
if (existingValue instanceof Date && typeof newValue === 'string') { if (existingValue instanceof Date && typeof newValue === "string") {
return existingValue.toISOString().split('T')[0] === newValue.split('T')[0]; return (
existingValue.toISOString().split("T")[0] ===
newValue.split("T")[0]
);
} }
return String(existingValue) === String(newValue); return String(existingValue) === String(newValue);
}); });
}); });
@ -1367,7 +1571,7 @@ class DataService {
const deleteQuery = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`; const deleteQuery = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`;
await pool.query(deleteQuery, [existingRecord[pkColumn]]); await pool.query(deleteQuery, [existingRecord[pkColumn]]);
deleted++; deleted++;
console.log(`🗑️ DELETE: ${pkColumn} = ${existingRecord[pkColumn]}`); console.log(`🗑️ DELETE: ${pkColumn} = ${existingRecord[pkColumn]}`);
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -134,8 +134,8 @@ export class EntityJoinService {
`🔧 기존 display_column 사용: ${column.column_name}${displayColumn}` `🔧 기존 display_column 사용: ${column.column_name}${displayColumn}`
); );
} else { } else {
// display_column이 "none"이거나 없는 경우 참조 테이블의 모든 컬럼 가져오기 // display_column이 "none"이거나 없는 경우 참조 테이블의 표시용 컬럼 자동 감지
logger.info(`🔍 ${referenceTable}모든 컬럼 조회 중...`); logger.info(`🔍 ${referenceTable}표시 컬럼 자동 감지 중...`);
// 참조 테이블의 모든 컬럼 이름 가져오기 // 참조 테이블의 모든 컬럼 이름 가져오기
const tableColumnsResult = await query<{ column_name: string }>( const tableColumnsResult = await query<{ column_name: string }>(
@ -148,10 +148,34 @@ export class EntityJoinService {
); );
if (tableColumnsResult.length > 0) { if (tableColumnsResult.length > 0) {
displayColumns = tableColumnsResult.map((col) => col.column_name); const allColumns = tableColumnsResult.map((col) => col.column_name);
// 🆕 표시용 컬럼 자동 감지 (우선순위 순서)
// 1. *_name 컬럼 (item_name, customer_name 등)
// 2. name 컬럼
// 3. label 컬럼
// 4. title 컬럼
// 5. 참조 컬럼 (referenceColumn)
const nameColumn = allColumns.find(
(col) => col.endsWith("_name") && col !== "company_name"
);
const simpleNameColumn = allColumns.find((col) => col === "name");
const labelColumn = allColumns.find(
(col) => col === "label" || col.endsWith("_label")
);
const titleColumn = allColumns.find((col) => col === "title");
// 우선순위에 따라 표시 컬럼 선택
const displayColumn =
nameColumn ||
simpleNameColumn ||
labelColumn ||
titleColumn ||
referenceColumn;
displayColumns = [displayColumn];
logger.info( logger.info(
`${referenceTable}의 모든 컬럼 자동 포함 (${displayColumns.length}개):`, `${referenceTable}의 표시 컬럼 자동 감지: ${displayColumn} (전체 ${allColumns.length}개 중)`
displayColumns.join(", ")
); );
} else { } else {
// 테이블 컬럼을 못 찾으면 기본값 사용 // 테이블 컬럼을 못 찾으면 기본값 사용
@ -162,8 +186,13 @@ export class EntityJoinService {
} }
} }
// 별칭 컬럼명 생성 (writer -> writer_name) // 🎯 별칭 컬럼명 생성 - 사용자가 선택한 displayColumns 기반으로 동적 생성
const aliasColumn = `${column.column_name}_name`; // 단일 컬럼: manager + user_name → manager_user_name
// 여러 컬럼: 첫 번째 컬럼 기준 (나머지는 개별 alias로 처리됨)
const firstDisplayColumn = displayColumns[0] || "name";
const aliasColumn = `${column.column_name}_${firstDisplayColumn}`;
logger.info(`🔧 별칭 컬럼명 생성: ${column.column_name} + ${firstDisplayColumn}${aliasColumn}`);
const joinConfig: EntityJoinConfig = { const joinConfig: EntityJoinConfig = {
sourceTable: tableName, sourceTable: tableName,
@ -403,18 +432,25 @@ export class EntityJoinService {
const fromClause = `FROM ${tableName} main`; const fromClause = `FROM ${tableName} main`;
// LEFT JOIN 절들 (위에서 생성한 별칭 매핑 사용, 각 sourceColumn마다 별도 JOIN) // LEFT JOIN 절들 (위에서 생성한 별칭 매핑 사용, 각 sourceColumn마다 별도 JOIN)
// 멀티테넌시: 모든 조인에 company_code 조건 추가 (다른 회사 데이터 혼합 방지)
const joinClauses = uniqueReferenceTableConfigs const joinClauses = uniqueReferenceTableConfigs
.map((config) => { .map((config) => {
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`; const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
const alias = aliasMap.get(aliasKey); const alias = aliasMap.get(aliasKey);
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링) // table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링)
if (config.referenceTable === "table_column_category_values") { if (config.referenceTable === "table_column_category_values") {
// 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외)
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`; return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
} }
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`; // user_info는 전역 테이블이므로 company_code 조건 없이 조인
if (config.referenceTable === "user_info") {
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
}
// 일반 테이블: company_code가 있으면 같은 회사 데이터만 조인 (멀티테넌시)
// supplier_mng, customer_mng, item_info 등 회사별 데이터 테이블
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.company_code = main.company_code`;
}) })
.join("\n"); .join("\n");

View File

@ -0,0 +1,283 @@
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
import crypto from "crypto";
export interface ExcelMappingTemplate {
id?: number;
tableName: string;
excelColumns: string[];
excelColumnsHash: string;
columnMappings: Record<string, string | null>; // { "엑셀컬럼": "시스템컬럼" }
companyCode: string;
createdDate?: Date;
updatedDate?: Date;
}
class ExcelMappingService {
/**
*
* MD5
*/
generateColumnsHash(columns: string[]): string {
// 컬럼 목록을 정렬하여 순서와 무관하게 동일한 해시 생성
const sortedColumns = [...columns].sort();
const columnsString = sortedColumns.join("|");
return crypto.createHash("md5").update(columnsString).digest("hex");
}
/**
* 릿
*
*/
async findMappingByColumns(
tableName: string,
excelColumns: string[],
companyCode: string
): Promise<ExcelMappingTemplate | null> {
try {
const hash = this.generateColumnsHash(excelColumns);
logger.info("엑셀 매핑 템플릿 조회", {
tableName,
excelColumns,
hash,
companyCode,
});
const pool = getPool();
// 회사별 매핑 먼저 조회, 없으면 공통(*) 매핑 조회
let query: string;
let params: any[];
if (companyCode === "*") {
query = `
SELECT
id,
table_name as "tableName",
excel_columns as "excelColumns",
excel_columns_hash as "excelColumnsHash",
column_mappings as "columnMappings",
company_code as "companyCode",
created_date as "createdDate",
updated_date as "updatedDate"
FROM excel_mapping_template
WHERE table_name = $1
AND excel_columns_hash = $2
ORDER BY updated_date DESC
LIMIT 1
`;
params = [tableName, hash];
} else {
query = `
SELECT
id,
table_name as "tableName",
excel_columns as "excelColumns",
excel_columns_hash as "excelColumnsHash",
column_mappings as "columnMappings",
company_code as "companyCode",
created_date as "createdDate",
updated_date as "updatedDate"
FROM excel_mapping_template
WHERE table_name = $1
AND excel_columns_hash = $2
AND (company_code = $3 OR company_code = '*')
ORDER BY
CASE WHEN company_code = $3 THEN 0 ELSE 1 END,
updated_date DESC
LIMIT 1
`;
params = [tableName, hash, companyCode];
}
const result = await pool.query(query, params);
if (result.rows.length > 0) {
logger.info("기존 매핑 템플릿 발견", {
id: result.rows[0].id,
tableName,
});
return result.rows[0];
}
logger.info("매핑 템플릿 없음 - 새 구조", { tableName, hash });
return null;
} catch (error: any) {
logger.error(`매핑 템플릿 조회 실패: ${error.message}`, { error });
throw error;
}
}
/**
* 릿 (UPSERT)
* ++ ,
*/
async saveMappingTemplate(
tableName: string,
excelColumns: string[],
columnMappings: Record<string, string | null>,
companyCode: string,
userId?: string
): Promise<ExcelMappingTemplate> {
try {
const hash = this.generateColumnsHash(excelColumns);
logger.info("엑셀 매핑 템플릿 저장 (UPSERT)", {
tableName,
excelColumns,
hash,
columnMappings,
companyCode,
});
const pool = getPool();
const query = `
INSERT INTO excel_mapping_template (
table_name,
excel_columns,
excel_columns_hash,
column_mappings,
company_code,
created_date,
updated_date
) VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
ON CONFLICT (table_name, excel_columns_hash, company_code)
DO UPDATE SET
column_mappings = EXCLUDED.column_mappings,
updated_date = NOW()
RETURNING
id,
table_name as "tableName",
excel_columns as "excelColumns",
excel_columns_hash as "excelColumnsHash",
column_mappings as "columnMappings",
company_code as "companyCode",
created_date as "createdDate",
updated_date as "updatedDate"
`;
const result = await pool.query(query, [
tableName,
excelColumns,
hash,
JSON.stringify(columnMappings),
companyCode,
]);
logger.info("매핑 템플릿 저장 완료", {
id: result.rows[0].id,
tableName,
hash,
});
return result.rows[0];
} catch (error: any) {
logger.error(`매핑 템플릿 저장 실패: ${error.message}`, { error });
throw error;
}
}
/**
* 릿
*/
async getMappingTemplates(
tableName: string,
companyCode: string
): Promise<ExcelMappingTemplate[]> {
try {
logger.info("테이블 매핑 템플릿 목록 조회", { tableName, companyCode });
const pool = getPool();
let query: string;
let params: any[];
if (companyCode === "*") {
query = `
SELECT
id,
table_name as "tableName",
excel_columns as "excelColumns",
excel_columns_hash as "excelColumnsHash",
column_mappings as "columnMappings",
company_code as "companyCode",
created_date as "createdDate",
updated_date as "updatedDate"
FROM excel_mapping_template
WHERE table_name = $1
ORDER BY updated_date DESC
`;
params = [tableName];
} else {
query = `
SELECT
id,
table_name as "tableName",
excel_columns as "excelColumns",
excel_columns_hash as "excelColumnsHash",
column_mappings as "columnMappings",
company_code as "companyCode",
created_date as "createdDate",
updated_date as "updatedDate"
FROM excel_mapping_template
WHERE table_name = $1
AND (company_code = $2 OR company_code = '*')
ORDER BY updated_date DESC
`;
params = [tableName, companyCode];
}
const result = await pool.query(query, params);
logger.info(`매핑 템플릿 ${result.rows.length}개 조회`, { tableName });
return result.rows;
} catch (error: any) {
logger.error(`매핑 템플릿 목록 조회 실패: ${error.message}`, { error });
throw error;
}
}
/**
* 릿
*/
async deleteMappingTemplate(
id: number,
companyCode: string
): Promise<boolean> {
try {
logger.info("매핑 템플릿 삭제", { id, companyCode });
const pool = getPool();
let query: string;
let params: any[];
if (companyCode === "*") {
query = `DELETE FROM excel_mapping_template WHERE id = $1`;
params = [id];
} else {
query = `DELETE FROM excel_mapping_template WHERE id = $1 AND company_code = $2`;
params = [id, companyCode];
}
const result = await pool.query(query, params);
if (result.rowCount && result.rowCount > 0) {
logger.info("매핑 템플릿 삭제 완료", { id });
return true;
}
logger.warn("삭제할 매핑 템플릿 없음", { id, companyCode });
return false;
} catch (error: any) {
logger.error(`매핑 템플릿 삭제 실패: ${error.message}`, { error });
throw error;
}
}
}
export default new ExcelMappingService();

View File

@ -113,6 +113,7 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper {
lastUsedAt: Date; lastUsedAt: Date;
activeConnections = 0; activeConnections = 0;
maxConnections: number; maxConnections: number;
private isPoolClosed = false;
constructor(config: ExternalDbConnection) { constructor(config: ExternalDbConnection) {
this.connectionId = config.id!; this.connectionId = config.id!;
@ -131,6 +132,9 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper {
waitForConnections: true, waitForConnections: true,
queueLimit: 0, queueLimit: 0,
connectTimeout: (config.connection_timeout || 30) * 1000, connectTimeout: (config.connection_timeout || 30) * 1000,
// 연결 유지 및 자동 재연결 설정
enableKeepAlive: true,
keepAliveInitialDelay: 10000, // 10초마다 keep-alive 패킷 전송
ssl: ssl:
config.ssl_enabled === "Y" ? { rejectUnauthorized: false } : undefined, config.ssl_enabled === "Y" ? { rejectUnauthorized: false } : undefined,
}); });
@ -153,11 +157,33 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper {
async query(sql: string, params?: any[]): Promise<any> { async query(sql: string, params?: any[]): Promise<any> {
this.lastUsedAt = new Date(); this.lastUsedAt = new Date();
// 연결 풀이 닫힌 상태인지 확인
if (this.isPoolClosed) {
throw new Error("연결 풀이 닫힌 상태입니다. 재연결이 필요합니다.");
}
try {
const [rows] = await this.pool.execute(sql, params); const [rows] = await this.pool.execute(sql, params);
return rows; return rows;
} catch (error: any) {
// 연결 닫힘 오류 감지
if (
error.message.includes("closed state") ||
error.code === "PROTOCOL_CONNECTION_LOST" ||
error.code === "ECONNRESET"
) {
this.isPoolClosed = true;
logger.warn(
`[${this.dbType.toUpperCase()}] 연결 끊김 감지 (ID: ${this.connectionId})`
);
}
throw error;
}
} }
async disconnect(): Promise<void> { async disconnect(): Promise<void> {
this.isPoolClosed = true;
await this.pool.end(); await this.pool.end();
logger.info( logger.info(
`[${this.dbType.toUpperCase()}] 연결 풀 종료 (ID: ${this.connectionId})` `[${this.dbType.toUpperCase()}] 연결 풀 종료 (ID: ${this.connectionId})`
@ -165,6 +191,10 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper {
} }
isHealthy(): boolean { isHealthy(): boolean {
// 연결 풀이 닫혔으면 비정상
if (this.isPoolClosed) {
return false;
}
return this.activeConnections < this.maxConnections; return this.activeConnections < this.maxConnections;
} }
} }
@ -230,9 +260,11 @@ export class ExternalDbConnectionPoolService {
): Promise<ConnectionPoolWrapper> { ): Promise<ConnectionPoolWrapper> {
logger.info(`🔧 새 연결 풀 생성 중 (ID: ${connectionId})...`); logger.info(`🔧 새 연결 풀 생성 중 (ID: ${connectionId})...`);
// DB 연결 정보 조회 // DB 연결 정보 조회 (실제 비밀번호 포함)
const connectionResult = const connectionResult =
await ExternalDbConnectionService.getConnectionById(connectionId); await ExternalDbConnectionService.getConnectionByIdWithPassword(
connectionId
);
if (!connectionResult.success || !connectionResult.data) { if (!connectionResult.success || !connectionResult.data) {
throw new Error(`연결 정보를 찾을 수 없습니다 (ID: ${connectionId})`); throw new Error(`연결 정보를 찾을 수 없습니다 (ID: ${connectionId})`);
@ -296,16 +328,19 @@ export class ExternalDbConnectionPoolService {
} }
/** /**
* ( ) * ( + )
*/ */
async executeQuery( async executeQuery(
connectionId: number, connectionId: number,
sql: string, sql: string,
params?: any[] params?: any[],
retryCount = 0
): Promise<any> { ): Promise<any> {
const pool = await this.getPool(connectionId); const MAX_RETRIES = 2;
try { try {
const pool = await this.getPool(connectionId);
logger.debug( logger.debug(
`📊 쿼리 실행 (ID: ${connectionId}): ${sql.substring(0, 100)}...` `📊 쿼리 실행 (ID: ${connectionId}): ${sql.substring(0, 100)}...`
); );
@ -314,7 +349,29 @@ export class ExternalDbConnectionPoolService {
`✅ 쿼리 완료 (ID: ${connectionId}), 결과: ${result.length}` `✅ 쿼리 완료 (ID: ${connectionId}), 결과: ${result.length}`
); );
return result; return result;
} catch (error) { } catch (error: any) {
// 연결 끊김 오류인 경우 재시도
const isConnectionError =
error.message?.includes("closed state") ||
error.message?.includes("연결 풀이 닫힌 상태") ||
error.code === "PROTOCOL_CONNECTION_LOST" ||
error.code === "ECONNRESET" ||
error.code === "ETIMEDOUT";
if (isConnectionError && retryCount < MAX_RETRIES) {
logger.warn(
`🔄 연결 오류 감지, 재시도 중... (${retryCount + 1}/${MAX_RETRIES}) (ID: ${connectionId})`
);
// 기존 풀 제거 후 새로 생성
await this.removePool(connectionId);
// 잠시 대기 후 재시도
await new Promise((resolve) => setTimeout(resolve, 500));
return this.executeQuery(connectionId, sql, params, retryCount + 1);
}
logger.error(`❌ 쿼리 실행 실패 (ID: ${connectionId}):`, error); logger.error(`❌ 쿼리 실행 실패 (ID: ${connectionId}):`, error);
throw error; throw error;
} }

View File

@ -28,39 +28,39 @@ export class ExternalDbConnectionService {
// 회사별 필터링 (최고 관리자가 아닌 경우 필수) // 회사별 필터링 (최고 관리자가 아닌 경우 필수)
if (userCompanyCode && userCompanyCode !== "*") { if (userCompanyCode && userCompanyCode !== "*") {
whereConditions.push(`company_code = $${paramIndex++}`); whereConditions.push(`e.company_code = $${paramIndex++}`);
params.push(userCompanyCode); params.push(userCompanyCode);
logger.info(`회사별 외부 DB 연결 필터링: ${userCompanyCode}`); logger.info(`회사별 외부 DB 연결 필터링: ${userCompanyCode}`);
} else if (userCompanyCode === "*") { } else if (userCompanyCode === "*") {
logger.info(`최고 관리자: 모든 외부 DB 연결 조회`); logger.info(`최고 관리자: 모든 외부 DB 연결 조회`);
// 필터가 있으면 적용 // 필터가 있으면 적용
if (filter.company_code) { if (filter.company_code) {
whereConditions.push(`company_code = $${paramIndex++}`); whereConditions.push(`e.company_code = $${paramIndex++}`);
params.push(filter.company_code); params.push(filter.company_code);
} }
} else { } else {
// userCompanyCode가 없는 경우 (하위 호환성) // userCompanyCode가 없는 경우 (하위 호환성)
if (filter.company_code) { if (filter.company_code) {
whereConditions.push(`company_code = $${paramIndex++}`); whereConditions.push(`e.company_code = $${paramIndex++}`);
params.push(filter.company_code); params.push(filter.company_code);
} }
} }
// 필터 조건 적용 // 필터 조건 적용
if (filter.db_type) { if (filter.db_type) {
whereConditions.push(`db_type = $${paramIndex++}`); whereConditions.push(`e.db_type = $${paramIndex++}`);
params.push(filter.db_type); params.push(filter.db_type);
} }
if (filter.is_active) { if (filter.is_active) {
whereConditions.push(`is_active = $${paramIndex++}`); whereConditions.push(`e.is_active = $${paramIndex++}`);
params.push(filter.is_active); params.push(filter.is_active);
} }
// 검색 조건 적용 (연결명 또는 설명에서 검색) // 검색 조건 적용 (연결명 또는 설명에서 검색)
if (filter.search && filter.search.trim()) { if (filter.search && filter.search.trim()) {
whereConditions.push( whereConditions.push(
`(connection_name ILIKE $${paramIndex} OR description ILIKE $${paramIndex})` `(e.connection_name ILIKE $${paramIndex} OR e.description ILIKE $${paramIndex})`
); );
params.push(`%${filter.search.trim()}%`); params.push(`%${filter.search.trim()}%`);
paramIndex++; paramIndex++;
@ -72,9 +72,12 @@ export class ExternalDbConnectionService {
: ""; : "";
const connections = await query<any>( const connections = await query<any>(
`SELECT * FROM external_db_connections `SELECT e.*,
COALESCE(c.company_name, CASE WHEN e.company_code = '*' THEN '전체' ELSE e.company_code END) AS company_name
FROM external_db_connections e
LEFT JOIN company_mng c ON e.company_code = c.company_code
${whereClause} ${whereClause}
ORDER BY is_active DESC, connection_name ASC`, ORDER BY e.is_active DESC, e.connection_name ASC`,
params params
); );

View File

@ -31,15 +31,17 @@ export class ExternalRestApiConnectionService {
try { try {
let query = ` let query = `
SELECT SELECT
id, connection_name, description, base_url, endpoint_path, default_headers, e.id, e.connection_name, e.description, e.base_url, e.endpoint_path, e.default_headers,
default_method, e.default_method,
-- DB default_request_body -- DB default_request_body
-- default_body alias -- default_body alias
default_request_body AS default_body, e.default_request_body AS default_body,
auth_type, auth_config, timeout, retry_count, retry_delay, e.auth_type, e.auth_config, e.timeout, e.retry_count, e.retry_delay,
company_code, is_active, created_date, created_by, e.company_code, e.is_active, e.created_date, e.created_by,
updated_date, updated_by, last_test_date, last_test_result, last_test_message e.updated_date, e.updated_by, e.last_test_date, e.last_test_result, e.last_test_message,
FROM external_rest_api_connections COALESCE(c.company_name, CASE WHEN e.company_code = '*' THEN '전체' ELSE e.company_code END) AS company_name
FROM external_rest_api_connections e
LEFT JOIN company_mng c ON e.company_code = c.company_code
WHERE 1=1 WHERE 1=1
`; `;
@ -48,7 +50,7 @@ export class ExternalRestApiConnectionService {
// 회사별 필터링 (최고 관리자가 아닌 경우 필수) // 회사별 필터링 (최고 관리자가 아닌 경우 필수)
if (userCompanyCode && userCompanyCode !== "*") { if (userCompanyCode && userCompanyCode !== "*") {
query += ` AND company_code = $${paramIndex}`; query += ` AND e.company_code = $${paramIndex}`;
params.push(userCompanyCode); params.push(userCompanyCode);
paramIndex++; paramIndex++;
logger.info(`회사별 REST API 연결 필터링: ${userCompanyCode}`); logger.info(`회사별 REST API 연결 필터링: ${userCompanyCode}`);
@ -56,14 +58,14 @@ export class ExternalRestApiConnectionService {
logger.info(`최고 관리자: 모든 REST API 연결 조회`); logger.info(`최고 관리자: 모든 REST API 연결 조회`);
// 필터가 있으면 적용 // 필터가 있으면 적용
if (filter.company_code) { if (filter.company_code) {
query += ` AND company_code = $${paramIndex}`; query += ` AND e.company_code = $${paramIndex}`;
params.push(filter.company_code); params.push(filter.company_code);
paramIndex++; paramIndex++;
} }
} else { } else {
// userCompanyCode가 없는 경우 (하위 호환성) // userCompanyCode가 없는 경우 (하위 호환성)
if (filter.company_code) { if (filter.company_code) {
query += ` AND company_code = $${paramIndex}`; query += ` AND e.company_code = $${paramIndex}`;
params.push(filter.company_code); params.push(filter.company_code);
paramIndex++; paramIndex++;
} }
@ -71,14 +73,14 @@ export class ExternalRestApiConnectionService {
// 활성 상태 필터 // 활성 상태 필터
if (filter.is_active) { if (filter.is_active) {
query += ` AND is_active = $${paramIndex}`; query += ` AND e.is_active = $${paramIndex}`;
params.push(filter.is_active); params.push(filter.is_active);
paramIndex++; paramIndex++;
} }
// 인증 타입 필터 // 인증 타입 필터
if (filter.auth_type) { if (filter.auth_type) {
query += ` AND auth_type = $${paramIndex}`; query += ` AND e.auth_type = $${paramIndex}`;
params.push(filter.auth_type); params.push(filter.auth_type);
paramIndex++; paramIndex++;
} }
@ -86,9 +88,9 @@ export class ExternalRestApiConnectionService {
// 검색어 필터 (연결명, 설명, URL) // 검색어 필터 (연결명, 설명, URL)
if (filter.search) { if (filter.search) {
query += ` AND ( query += ` AND (
connection_name ILIKE $${paramIndex} OR e.connection_name ILIKE $${paramIndex} OR
description ILIKE $${paramIndex} OR e.description ILIKE $${paramIndex} OR
base_url ILIKE $${paramIndex} e.base_url ILIKE $${paramIndex}
)`; )`;
params.push(`%${filter.search}%`); params.push(`%${filter.search}%`);
paramIndex++; paramIndex++;
@ -207,8 +209,8 @@ export class ExternalRestApiConnectionService {
connection_name, description, base_url, endpoint_path, default_headers, connection_name, description, base_url, endpoint_path, default_headers,
default_method, default_request_body, default_method, default_request_body,
auth_type, auth_config, timeout, retry_count, retry_delay, auth_type, auth_config, timeout, retry_count, retry_delay,
company_code, is_active, created_by company_code, is_active, created_by, save_to_history
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
RETURNING * RETURNING *
`; `;
@ -228,11 +230,13 @@ export class ExternalRestApiConnectionService {
data.company_code || "*", data.company_code || "*",
data.is_active || "Y", data.is_active || "Y",
data.created_by || "system", data.created_by || "system",
data.save_to_history || "N",
]; ];
// 디버깅: 저장하려는 데이터 로깅 // 디버깅: 저장하려는 데이터 로깅
logger.info(`REST API 연결 생성 요청 데이터:`, { logger.info(`REST API 연결 생성 요청 데이터:`, {
connection_name: data.connection_name, connection_name: data.connection_name,
company_code: data.company_code,
default_method: data.default_method, default_method: data.default_method,
endpoint_path: data.endpoint_path, endpoint_path: data.endpoint_path,
base_url: data.base_url, base_url: data.base_url,
@ -374,6 +378,12 @@ export class ExternalRestApiConnectionService {
paramIndex++; paramIndex++;
} }
if (data.save_to_history !== undefined) {
updateFields.push(`save_to_history = $${paramIndex}`);
params.push(data.save_to_history);
paramIndex++;
}
if (data.updated_by !== undefined) { if (data.updated_by !== undefined) {
updateFields.push(`updated_by = $${paramIndex}`); updateFields.push(`updated_by = $${paramIndex}`);
params.push(data.updated_by); params.push(data.updated_by);
@ -1091,4 +1101,150 @@ export class ExternalRestApiConnectionService {
throw new Error("올바르지 않은 인증 타입입니다."); throw new Error("올바르지 않은 인증 타입입니다.");
} }
} }
/**
* REST API
* REST API의
*/
static async fetchMultipleData(
configs: Array<{
connectionId: number;
endpoint: string;
jsonPath: string;
alias: string;
}>,
userCompanyCode?: string
): Promise<ApiResponse<{
rows: any[];
columns: Array<{ columnName: string; columnLabel: string; dataType: string; sourceApi: string }>;
total: number;
sources: Array<{ connectionId: number; connectionName: string; rowCount: number }>;
}>> {
try {
logger.info(`다중 REST API 데이터 조회 시작: ${configs.length}개 API`);
// 각 API에서 데이터 조회
const results = await Promise.all(
configs.map(async (config) => {
try {
const result = await this.fetchData(
config.connectionId,
config.endpoint,
config.jsonPath,
userCompanyCode
);
if (result.success && result.data) {
return {
success: true,
connectionId: config.connectionId,
connectionName: result.data.connectionInfo.connectionName,
alias: config.alias,
rows: result.data.rows,
columns: result.data.columns,
};
} else {
logger.warn(`API ${config.connectionId} 조회 실패:`, result.message);
return {
success: false,
connectionId: config.connectionId,
connectionName: "",
alias: config.alias,
rows: [],
columns: [],
error: result.message,
};
}
} catch (error) {
logger.error(`API ${config.connectionId} 조회 오류:`, error);
return {
success: false,
connectionId: config.connectionId,
connectionName: "",
alias: config.alias,
rows: [],
columns: [],
error: error instanceof Error ? error.message : "알 수 없는 오류",
};
}
})
);
// 성공한 결과만 필터링
const successfulResults = results.filter(r => r.success);
if (successfulResults.length === 0) {
return {
success: false,
message: "모든 REST API 조회에 실패했습니다.",
error: {
code: "ALL_APIS_FAILED",
details: results.map(r => ({ connectionId: r.connectionId, error: r.error })),
},
};
}
// 컬럼 병합 (별칭 적용)
const mergedColumns: Array<{ columnName: string; columnLabel: string; dataType: string; sourceApi: string }> = [];
for (const result of successfulResults) {
for (const col of result.columns) {
const prefixedColumnName = result.alias ? `${result.alias}${col.columnName}` : col.columnName;
mergedColumns.push({
columnName: prefixedColumnName,
columnLabel: `${col.columnLabel} (${result.connectionName})`,
dataType: col.dataType,
sourceApi: result.connectionName,
});
}
}
// 데이터 병합 (가로 병합: 각 API의 첫 번째 행끼리 병합)
// 참고: 실제 사용 시에는 조인 키가 필요할 수 있음
const maxRows = Math.max(...successfulResults.map(r => r.rows.length));
const mergedRows: any[] = [];
for (let i = 0; i < maxRows; i++) {
const mergedRow: any = {};
for (const result of successfulResults) {
const row = result.rows[i] || {};
for (const [key, value] of Object.entries(row)) {
const prefixedKey = result.alias ? `${result.alias}${key}` : key;
mergedRow[prefixedKey] = value;
}
}
mergedRows.push(mergedRow);
}
logger.info(`다중 REST API 데이터 병합 완료: ${mergedRows.length}개 행, ${mergedColumns.length}개 컬럼`);
return {
success: true,
data: {
rows: mergedRows,
columns: mergedColumns,
total: mergedRows.length,
sources: successfulResults.map(r => ({
connectionId: r.connectionId,
connectionName: r.connectionName,
rowCount: r.rows.length,
})),
},
message: `${successfulResults.length}개 API에서 총 ${mergedRows.length}개 데이터를 조회했습니다.`,
};
} catch (error) {
logger.error("다중 REST API 데이터 조회 오류:", error);
return {
success: false,
message: "다중 REST API 데이터 조회에 실패했습니다.",
error: {
code: "MULTI_FETCH_ERROR",
details: error instanceof Error ? error.message : "알 수 없는 오류",
},
};
}
}
} }

View File

@ -72,6 +72,11 @@ export class FlowDataMoveService {
// 내부 DB 처리 (기존 로직) // 내부 DB 처리 (기존 로직)
return await db.transaction(async (client) => { return await db.transaction(async (client) => {
try { try {
// 트랜잭션 세션 변수 설정 (트리거에서 changed_by 기록용)
await client.query("SELECT set_config('app.user_id', $1, true)", [
userId || "system",
]);
// 1. 단계 정보 조회 // 1. 단계 정보 조회
const fromStep = await this.flowStepService.findById(fromStepId); const fromStep = await this.flowStepService.findById(fromStepId);
const toStep = await this.flowStepService.findById(toStepId); const toStep = await this.flowStepService.findById(toStepId);
@ -684,6 +689,14 @@ export class FlowDataMoveService {
dbConnectionId, dbConnectionId,
async (externalClient, dbType) => { async (externalClient, dbType) => {
try { try {
// 외부 DB가 PostgreSQL인 경우에만 세션 변수 설정 시도
if (dbType.toLowerCase() === "postgresql") {
await externalClient.query(
"SELECT set_config('app.user_id', $1, true)",
[userId || "system"]
);
}
// 1. 단계 정보 조회 (내부 DB에서) // 1. 단계 정보 조회 (내부 DB에서)
const fromStep = await this.flowStepService.findById(fromStepId); const fromStep = await this.flowStepService.findById(fromStepId);
const toStep = await this.flowStepService.findById(toStepId); const toStep = await this.flowStepService.findById(toStepId);

View File

@ -27,13 +27,21 @@ export class FlowDefinitionService {
tableName: request.tableName, tableName: request.tableName,
dbSourceType: request.dbSourceType, dbSourceType: request.dbSourceType,
dbConnectionId: request.dbConnectionId, dbConnectionId: request.dbConnectionId,
restApiConnectionId: request.restApiConnectionId,
restApiEndpoint: request.restApiEndpoint,
restApiJsonPath: request.restApiJsonPath,
restApiConnections: request.restApiConnections,
companyCode, companyCode,
userId, userId,
}); });
const query = ` const query = `
INSERT INTO flow_definition (name, description, table_name, db_source_type, db_connection_id, company_code, created_by) INSERT INTO flow_definition (
VALUES ($1, $2, $3, $4, $5, $6, $7) name, description, table_name, db_source_type, db_connection_id,
rest_api_connection_id, rest_api_endpoint, rest_api_json_path,
rest_api_connections, company_code, created_by
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING * RETURNING *
`; `;
@ -43,6 +51,10 @@ export class FlowDefinitionService {
request.tableName || null, request.tableName || null,
request.dbSourceType || "internal", request.dbSourceType || "internal",
request.dbConnectionId || null, request.dbConnectionId || null,
request.restApiConnectionId || null,
request.restApiEndpoint || null,
request.restApiJsonPath || "response",
request.restApiConnections ? JSON.stringify(request.restApiConnections) : null,
companyCode, companyCode,
userId, userId,
]; ];
@ -199,6 +211,19 @@ export class FlowDefinitionService {
* DB FlowDefinition * DB FlowDefinition
*/ */
private mapToFlowDefinition(row: any): FlowDefinition { private mapToFlowDefinition(row: any): FlowDefinition {
// rest_api_connections 파싱 (JSONB → 배열)
let restApiConnections = undefined;
if (row.rest_api_connections) {
try {
restApiConnections = typeof row.rest_api_connections === 'string'
? JSON.parse(row.rest_api_connections)
: row.rest_api_connections;
} catch (e) {
console.warn("Failed to parse rest_api_connections:", e);
restApiConnections = [];
}
}
return { return {
id: row.id, id: row.id,
name: row.name, name: row.name,
@ -206,6 +231,12 @@ export class FlowDefinitionService {
tableName: row.table_name, tableName: row.table_name,
dbSourceType: row.db_source_type || "internal", dbSourceType: row.db_source_type || "internal",
dbConnectionId: row.db_connection_id, dbConnectionId: row.db_connection_id,
// REST API 관련 필드 (단일)
restApiConnectionId: row.rest_api_connection_id,
restApiEndpoint: row.rest_api_endpoint,
restApiJsonPath: row.rest_api_json_path,
// 다중 REST API 관련 필드
restApiConnections: restApiConnections,
companyCode: row.company_code || "*", companyCode: row.company_code || "*",
isActive: row.is_active, isActive: row.is_active,
createdBy: row.created_by, createdBy: row.created_by,

View File

@ -263,4 +263,139 @@ export class FlowExecutionService {
tableName: result[0].table_name, tableName: result[0].table_name,
}; };
} }
/**
* ( )
* .
*/
async updateStepData(
flowId: number,
stepId: number,
recordId: string,
updateData: Record<string, any>,
userId: string,
companyCode?: string
): Promise<{ success: boolean }> {
try {
// 1. 플로우 정의 조회
const flowDef = await this.flowDefinitionService.findById(flowId);
if (!flowDef) {
throw new Error(`Flow definition not found: ${flowId}`);
}
// 2. 스텝 조회
const step = await this.flowStepService.findById(stepId);
if (!step) {
throw new Error(`Flow step not found: ${stepId}`);
}
// 3. 테이블명 결정
const tableName = step.tableName || flowDef.tableName;
if (!tableName) {
throw new Error("Table name not found");
}
// 4. Primary Key 컬럼 결정 (기본값: id)
const primaryKeyColumn = flowDef.primaryKey || "id";
console.log(
`🔍 [updateStepData] Updating table: ${tableName}, PK: ${primaryKeyColumn}=${recordId}`
);
// 5. SET 절 생성
const updateColumns = Object.keys(updateData);
if (updateColumns.length === 0) {
throw new Error("No columns to update");
}
// 6. 외부 DB vs 내부 DB 구분
if (flowDef.dbSourceType === "external" && flowDef.dbConnectionId) {
// 외부 DB 업데이트
console.log(
"✅ [updateStepData] Using EXTERNAL DB:",
flowDef.dbConnectionId
);
// 외부 DB 연결 정보 조회
const connectionResult = await db.query(
"SELECT * FROM external_db_connection WHERE id = $1",
[flowDef.dbConnectionId]
);
if (connectionResult.length === 0) {
throw new Error(
`External DB connection not found: ${flowDef.dbConnectionId}`
);
}
const connection = connectionResult[0];
const dbType = connection.db_type?.toLowerCase();
// DB 타입에 따른 placeholder 및 쿼리 생성
let setClause: string;
let params: any[];
if (dbType === "mysql" || dbType === "mariadb") {
// MySQL/MariaDB: ? placeholder
setClause = updateColumns.map((col) => `\`${col}\` = ?`).join(", ");
params = [...Object.values(updateData), recordId];
} else if (dbType === "mssql") {
// MSSQL: @p1, @p2 placeholder
setClause = updateColumns
.map((col, idx) => `[${col}] = @p${idx + 1}`)
.join(", ");
params = [...Object.values(updateData), recordId];
} else {
// PostgreSQL: $1, $2 placeholder
setClause = updateColumns
.map((col, idx) => `"${col}" = $${idx + 1}`)
.join(", ");
params = [...Object.values(updateData), recordId];
}
const updateQuery = `UPDATE ${tableName} SET ${setClause} WHERE ${primaryKeyColumn} = ${dbType === "mysql" || dbType === "mariadb" ? "?" : dbType === "mssql" ? `@p${params.length}` : `$${params.length}`}`;
console.log(`📝 [updateStepData] Query: ${updateQuery}`);
console.log(`📝 [updateStepData] Params:`, params);
await executeExternalQuery(flowDef.dbConnectionId, updateQuery, params);
} else {
// 내부 DB 업데이트
console.log("✅ [updateStepData] Using INTERNAL DB");
const setClause = updateColumns
.map((col, idx) => `"${col}" = $${idx + 1}`)
.join(", ");
const params = [...Object.values(updateData), recordId];
const updateQuery = `UPDATE "${tableName}" SET ${setClause} WHERE "${primaryKeyColumn}" = $${params.length}`;
console.log(`📝 [updateStepData] Query: ${updateQuery}`);
console.log(`📝 [updateStepData] Params:`, params);
// 트랜잭션으로 감싸서 사용자 ID 세션 변수 설정 후 업데이트 실행
// (트리거에서 changed_by를 기록하기 위함)
await db.transaction(async (client) => {
// 안전한 파라미터 바인딩 방식 사용
await client.query("SELECT set_config('app.user_id', $1, true)", [
userId,
]);
await client.query(updateQuery, params);
});
}
console.log(
`✅ [updateStepData] Data updated successfully: ${tableName}.${primaryKeyColumn}=${recordId}`,
{
updatedFields: updateColumns,
userId,
}
);
return { success: true };
} catch (error: any) {
console.error("❌ [updateStepData] Error:", error);
throw error;
}
}
} }

View File

@ -0,0 +1,908 @@
/**
* -
*
* -
* / JOIN .
*/
import { query, queryOne, transaction, getPool } from "../database/db";
import { logger } from "../utils/logger";
// ================================
// 인터페이스 정의
// ================================
/**
* -
*/
export interface MasterDetailRelation {
masterTable: string;
detailTable: string;
masterKeyColumn: string; // 마스터 테이블의 키 컬럼 (예: order_no)
detailFkColumn: string; // 디테일 테이블의 FK 컬럼 (예: order_no)
masterColumns: ColumnInfo[];
detailColumns: ColumnInfo[];
}
/**
*
*/
export interface ColumnInfo {
name: string;
label: string;
inputType: string;
isFromMaster: boolean;
}
/**
*
*/
export interface SplitPanelConfig {
leftPanel: {
tableName: string;
columns: Array<{ name: string; label: string; width?: number }>;
};
rightPanel: {
tableName: string;
columns: Array<{ name: string; label: string; width?: number }>;
relation?: {
type: string;
foreignKey?: string;
leftColumn?: string;
// 복합키 지원 (새로운 방식)
keys?: Array<{
leftColumn: string;
rightColumn: string;
}>;
};
};
}
/**
*
*/
export interface ExcelDownloadData {
headers: string[]; // 컬럼 라벨들
columns: string[]; // 컬럼명들
data: Record<string, any>[];
masterColumns: string[]; // 마스터 컬럼 목록
detailColumns: string[]; // 디테일 컬럼 목록
joinKey: string; // 조인 키
}
/**
*
*/
export interface ExcelUploadResult {
success: boolean;
masterInserted: number;
masterUpdated: number;
detailInserted: number;
detailDeleted: number;
errors: string[];
}
// ================================
// 서비스 클래스
// ================================
class MasterDetailExcelService {
/**
* ID로
*/
async getSplitPanelConfig(screenId: number): Promise<SplitPanelConfig | null> {
try {
logger.info(`분할 패널 설정 조회: screenId=${screenId}`);
// screen_layouts에서 split-panel-layout 컴포넌트 찾기
const result = await queryOne<any>(
`SELECT properties->>'componentConfig' as config
FROM screen_layouts
WHERE screen_id = $1
AND component_type = 'component'
AND properties->>'componentType' = 'split-panel-layout'
LIMIT 1`,
[screenId]
);
if (!result || !result.config) {
logger.info(`분할 패널 없음: screenId=${screenId}`);
return null;
}
const config = typeof result.config === "string"
? JSON.parse(result.config)
: result.config;
logger.info(`분할 패널 설정 발견:`, {
leftTable: config.leftPanel?.tableName,
rightTable: config.rightPanel?.tableName,
relation: config.rightPanel?.relation,
});
return {
leftPanel: config.leftPanel,
rightPanel: config.rightPanel,
};
} catch (error: any) {
logger.error(`분할 패널 설정 조회 실패: ${error.message}`);
return null;
}
}
/**
* column_labels에서 Entity
*
*/
async getEntityRelation(
detailTable: string,
masterTable: string
): Promise<{ detailFkColumn: string; masterKeyColumn: string } | null> {
try {
logger.info(`Entity 관계 조회: ${detailTable} -> ${masterTable}`);
const result = await queryOne<any>(
`SELECT column_name, reference_column
FROM column_labels
WHERE table_name = $1
AND input_type = 'entity'
AND reference_table = $2
LIMIT 1`,
[detailTable, masterTable]
);
if (!result) {
logger.warn(`Entity 관계 없음: ${detailTable} -> ${masterTable}`);
return null;
}
logger.info(`Entity 관계 발견: ${detailTable}.${result.column_name} -> ${masterTable}.${result.reference_column}`);
return {
detailFkColumn: result.column_name,
masterKeyColumn: result.reference_column,
};
} catch (error: any) {
logger.error(`Entity 관계 조회 실패: ${error.message}`);
return null;
}
}
/**
*
*/
async getColumnLabels(tableName: string): Promise<Map<string, string>> {
try {
const result = await query<any>(
`SELECT column_name, column_label
FROM column_labels
WHERE table_name = $1`,
[tableName]
);
const labelMap = new Map<string, string>();
for (const row of result) {
labelMap.set(row.column_name, row.column_label || row.column_name);
}
return labelMap;
} catch (error: any) {
logger.error(`컬럼 라벨 조회 실패: ${error.message}`);
return new Map();
}
}
/**
* -
*/
async getMasterDetailRelation(
screenId: number
): Promise<MasterDetailRelation | null> {
try {
// 1. 분할 패널 설정 조회
const splitPanel = await this.getSplitPanelConfig(screenId);
if (!splitPanel) {
return null;
}
const masterTable = splitPanel.leftPanel.tableName;
const detailTable = splitPanel.rightPanel.tableName;
if (!masterTable || !detailTable) {
logger.warn("마스터 또는 디테일 테이블명 없음");
return null;
}
// 2. 분할 패널의 relation 정보가 있으면 우선 사용
// 🔥 keys 배열을 우선 사용 (새로운 복합키 지원 방식)
let masterKeyColumn: string | undefined;
let detailFkColumn: string | undefined;
const relationKeys = splitPanel.rightPanel.relation?.keys;
if (relationKeys && relationKeys.length > 0) {
// keys 배열에서 첫 번째 키 사용
masterKeyColumn = relationKeys[0].leftColumn;
detailFkColumn = relationKeys[0].rightColumn;
logger.info(`keys 배열에서 관계 정보 사용: ${masterKeyColumn} -> ${detailFkColumn}`);
} else {
// 하위 호환성: 기존 leftColumn/foreignKey 사용
masterKeyColumn = splitPanel.rightPanel.relation?.leftColumn;
detailFkColumn = splitPanel.rightPanel.relation?.foreignKey;
}
// 3. relation 정보가 없으면 column_labels에서 Entity 관계 조회
if (!masterKeyColumn || !detailFkColumn) {
const entityRelation = await this.getEntityRelation(detailTable, masterTable);
if (entityRelation) {
masterKeyColumn = entityRelation.masterKeyColumn;
detailFkColumn = entityRelation.detailFkColumn;
}
}
if (!masterKeyColumn || !detailFkColumn) {
logger.warn("조인 키 정보를 찾을 수 없음");
return null;
}
// 4. 컬럼 라벨 정보 조회
const masterLabels = await this.getColumnLabels(masterTable);
const detailLabels = await this.getColumnLabels(detailTable);
// 5. 마스터 컬럼 정보 구성
const masterColumns: ColumnInfo[] = splitPanel.leftPanel.columns.map(col => ({
name: col.name,
label: masterLabels.get(col.name) || col.label || col.name,
inputType: "text",
isFromMaster: true,
}));
// 6. 디테일 컬럼 정보 구성 (FK 컬럼 제외)
const detailColumns: ColumnInfo[] = splitPanel.rightPanel.columns
.filter(col => col.name !== detailFkColumn) // FK 컬럼 제외
.map(col => ({
name: col.name,
label: detailLabels.get(col.name) || col.label || col.name,
inputType: "text",
isFromMaster: false,
}));
logger.info(`마스터-디테일 관계 구성 완료:`, {
masterTable,
detailTable,
masterKeyColumn,
detailFkColumn,
masterColumnCount: masterColumns.length,
detailColumnCount: detailColumns.length,
});
return {
masterTable,
detailTable,
masterKeyColumn,
detailFkColumn,
masterColumns,
detailColumns,
};
} catch (error: any) {
logger.error(`마스터-디테일 관계 조회 실패: ${error.message}`);
return null;
}
}
/**
* - JOIN ( )
*/
async getJoinedData(
relation: MasterDetailRelation,
companyCode: string,
filters?: Record<string, any>
): Promise<ExcelDownloadData> {
try {
const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation;
// 조인 컬럼과 일반 컬럼 분리
// 조인 컬럼 형식: "테이블명.컬럼명" (예: customer_mng.customer_name)
const entityJoins: Array<{
refTable: string;
refColumn: string;
sourceColumn: string;
alias: string;
displayColumn: string;
}> = [];
// SELECT 절 구성
const selectParts: string[] = [];
let aliasIndex = 0;
// 마스터 컬럼 처리
for (const col of masterColumns) {
if (col.name.includes(".")) {
// 조인 컬럼: 테이블명.컬럼명
const [refTable, displayColumn] = col.name.split(".");
const alias = `ej${aliasIndex++}`;
// column_labels에서 FK 컬럼 찾기
const fkColumn = await this.findForeignKeyColumn(masterTable, refTable);
if (fkColumn) {
entityJoins.push({
refTable,
refColumn: fkColumn.referenceColumn,
sourceColumn: fkColumn.sourceColumn,
alias,
displayColumn,
});
selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`);
} else {
// FK를 못 찾으면 NULL로 처리
selectParts.push(`NULL AS "${col.name}"`);
}
} else {
// 일반 컬럼
selectParts.push(`m."${col.name}"`);
}
}
// 디테일 컬럼 처리
for (const col of detailColumns) {
if (col.name.includes(".")) {
// 조인 컬럼: 테이블명.컬럼명
const [refTable, displayColumn] = col.name.split(".");
const alias = `ej${aliasIndex++}`;
// column_labels에서 FK 컬럼 찾기
const fkColumn = await this.findForeignKeyColumn(detailTable, refTable);
if (fkColumn) {
entityJoins.push({
refTable,
refColumn: fkColumn.referenceColumn,
sourceColumn: fkColumn.sourceColumn,
alias,
displayColumn,
});
selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`);
} else {
selectParts.push(`NULL AS "${col.name}"`);
}
} else {
// 일반 컬럼
selectParts.push(`d."${col.name}"`);
}
}
const selectClause = selectParts.join(", ");
// 엔티티 조인 절 구성
const entityJoinClauses = entityJoins.map(ej =>
`LEFT JOIN "${ej.refTable}" ${ej.alias} ON m."${ej.sourceColumn}" = ${ej.alias}."${ej.refColumn}"`
).join("\n ");
// WHERE 절 구성
const whereConditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
// 회사 코드 필터 (최고 관리자 제외)
if (companyCode && companyCode !== "*") {
whereConditions.push(`m.company_code = $${paramIndex}`);
params.push(companyCode);
paramIndex++;
}
// 추가 필터 적용
if (filters) {
for (const [key, value] of Object.entries(filters)) {
if (value !== undefined && value !== null && value !== "") {
// 조인 컬럼인지 확인
if (key.includes(".")) continue;
// 마스터 테이블 컬럼인지 확인
const isMasterCol = masterColumns.some(c => c.name === key);
const tableAlias = isMasterCol ? "m" : "d";
whereConditions.push(`${tableAlias}."${key}" = $${paramIndex}`);
params.push(value);
paramIndex++;
}
}
}
const whereClause = whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
// JOIN 쿼리 실행
const sql = `
SELECT ${selectClause}
FROM "${masterTable}" m
LEFT JOIN "${detailTable}" d
ON m."${masterKeyColumn}" = d."${detailFkColumn}"
AND m.company_code = d.company_code
${entityJoinClauses}
${whereClause}
ORDER BY m."${masterKeyColumn}", d.id
`;
logger.info(`마스터-디테일 JOIN 쿼리:`, { sql, params });
const data = await query<any>(sql, params);
// 헤더 및 컬럼 정보 구성
const headers = [...masterColumns.map(c => c.label), ...detailColumns.map(c => c.label)];
const columns = [...masterColumns.map(c => c.name), ...detailColumns.map(c => c.name)];
logger.info(`마스터-디테일 데이터 조회 완료: ${data.length}`);
return {
headers,
columns,
data,
masterColumns: masterColumns.map(c => c.name),
detailColumns: detailColumns.map(c => c.name),
joinKey: masterKeyColumn,
};
} catch (error: any) {
logger.error(`마스터-디테일 데이터 조회 실패: ${error.message}`);
throw error;
}
}
/**
* FK
*/
private async findForeignKeyColumn(
sourceTable: string,
referenceTable: string
): Promise<{ sourceColumn: string; referenceColumn: string } | null> {
try {
const result = await query<{ column_name: string; reference_column: string }>(
`SELECT column_name, reference_column
FROM column_labels
WHERE table_name = $1
AND reference_table = $2
AND input_type = 'entity'
LIMIT 1`,
[sourceTable, referenceTable]
);
if (result.length > 0) {
return {
sourceColumn: result[0].column_name,
referenceColumn: result[0].reference_column,
};
}
return null;
} catch (error) {
logger.error(`FK 컬럼 조회 실패: ${sourceTable} -> ${referenceTable}`, error);
return null;
}
}
/**
* - ( )
*
* :
* 1.
* 2. UPSERT
* 3.
* 4. INSERT
*/
async uploadJoinedData(
relation: MasterDetailRelation,
data: Record<string, any>[],
companyCode: string,
userId?: string
): Promise<ExcelUploadResult> {
const result: ExcelUploadResult = {
success: false,
masterInserted: 0,
masterUpdated: 0,
detailInserted: 0,
detailDeleted: 0,
errors: [],
};
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation;
// 1. 데이터를 마스터 키로 그룹화
const groupedData = new Map<string, Record<string, any>[]>();
for (const row of data) {
const masterKey = row[masterKeyColumn];
if (!masterKey) {
result.errors.push(`마스터 키(${masterKeyColumn}) 값이 없는 행이 있습니다.`);
continue;
}
if (!groupedData.has(masterKey)) {
groupedData.set(masterKey, []);
}
groupedData.get(masterKey)!.push(row);
}
logger.info(`데이터 그룹화 완료: ${groupedData.size}개 마스터 그룹`);
// 2. 각 그룹 처리
for (const [masterKey, rows] of groupedData.entries()) {
try {
// 2a. 마스터 데이터 추출 (첫 번째 행에서)
const masterData: Record<string, any> = {};
for (const col of masterColumns) {
if (rows[0][col.name] !== undefined) {
masterData[col.name] = rows[0][col.name];
}
}
// 회사 코드, 작성자 추가
masterData.company_code = companyCode;
if (userId) {
masterData.writer = userId;
}
// 2b. 마스터 UPSERT
const existingMaster = await client.query(
`SELECT id FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`,
[masterKey, companyCode]
);
if (existingMaster.rows.length > 0) {
// UPDATE
const updateCols = Object.keys(masterData)
.filter(k => k !== masterKeyColumn && k !== "id")
.map((k, i) => `"${k}" = $${i + 1}`);
const updateValues = Object.keys(masterData)
.filter(k => k !== masterKeyColumn && k !== "id")
.map(k => masterData[k]);
if (updateCols.length > 0) {
await client.query(
`UPDATE "${masterTable}"
SET ${updateCols.join(", ")}, updated_date = NOW()
WHERE "${masterKeyColumn}" = $${updateValues.length + 1} AND company_code = $${updateValues.length + 2}`,
[...updateValues, masterKey, companyCode]
);
}
result.masterUpdated++;
} else {
// INSERT
const insertCols = Object.keys(masterData);
const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`);
const insertValues = insertCols.map(k => masterData[k]);
await client.query(
`INSERT INTO "${masterTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date)
VALUES (${insertPlaceholders.join(", ")}, NOW())`,
insertValues
);
result.masterInserted++;
}
// 2c. 기존 디테일 삭제
const deleteResult = await client.query(
`DELETE FROM "${detailTable}" WHERE "${detailFkColumn}" = $1 AND company_code = $2`,
[masterKey, companyCode]
);
result.detailDeleted += deleteResult.rowCount || 0;
// 2d. 새 디테일 INSERT
for (const row of rows) {
const detailData: Record<string, any> = {};
// FK 컬럼 추가
detailData[detailFkColumn] = masterKey;
detailData.company_code = companyCode;
if (userId) {
detailData.writer = userId;
}
// 디테일 컬럼 데이터 추출
for (const col of detailColumns) {
if (row[col.name] !== undefined) {
detailData[col.name] = row[col.name];
}
}
const insertCols = Object.keys(detailData);
const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`);
const insertValues = insertCols.map(k => detailData[k]);
await client.query(
`INSERT INTO "${detailTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date)
VALUES (${insertPlaceholders.join(", ")}, NOW())`,
insertValues
);
result.detailInserted++;
}
} catch (error: any) {
result.errors.push(`마스터 키 ${masterKey} 처리 실패: ${error.message}`);
logger.error(`마스터 키 ${masterKey} 처리 실패:`, error);
}
}
await client.query("COMMIT");
result.success = result.errors.length === 0 || result.masterInserted + result.masterUpdated > 0;
logger.info(`마스터-디테일 업로드 완료:`, {
masterInserted: result.masterInserted,
masterUpdated: result.masterUpdated,
detailInserted: result.detailInserted,
detailDeleted: result.detailDeleted,
errors: result.errors.length,
});
} catch (error: any) {
await client.query("ROLLBACK");
result.errors.push(`트랜잭션 실패: ${error.message}`);
logger.error(`마스터-디테일 업로드 트랜잭션 실패:`, error);
} finally {
client.release();
}
return result;
}
/**
* -
*
* UI에서 ,
*
*
* @param screenId ID
* @param detailData
* @param masterFieldValues UI에서
* @param numberingRuleId ID (optional)
* @param companyCode
* @param userId ID
* @param afterUploadFlowId ID (optional, )
* @param afterUploadFlows (optional)
*/
async uploadSimple(
screenId: number,
detailData: Record<string, any>[],
masterFieldValues: Record<string, any>,
numberingRuleId: string | undefined,
companyCode: string,
userId: string,
afterUploadFlowId?: string,
afterUploadFlows?: Array<{ flowId: string; order: number }>
): Promise<{
success: boolean;
masterInserted: number;
detailInserted: number;
generatedKey: string;
errors: string[];
controlResult?: any;
}> {
const result: {
success: boolean;
masterInserted: number;
detailInserted: number;
generatedKey: string;
errors: string[];
controlResult?: any;
} = {
success: false,
masterInserted: 0,
detailInserted: 0,
generatedKey: "",
errors: [] as string[],
};
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
// 1. 마스터-디테일 관계 정보 조회
const relation = await this.getMasterDetailRelation(screenId);
if (!relation) {
throw new Error("마스터-디테일 관계 정보를 찾을 수 없습니다.");
}
const { masterTable, detailTable, masterKeyColumn, detailFkColumn } = relation;
// 2. 채번 처리
let generatedKey: string;
if (numberingRuleId) {
// 채번 규칙으로 키 생성
generatedKey = await this.generateNumberWithRule(client, numberingRuleId, companyCode);
} else {
// 채번 규칙 없으면 마스터 필드에서 키 값 사용
generatedKey = masterFieldValues[masterKeyColumn];
if (!generatedKey) {
throw new Error(`마스터 키(${masterKeyColumn}) 값이 필요합니다.`);
}
}
result.generatedKey = generatedKey;
logger.info(`채번 결과: ${generatedKey}`);
// 3. 마스터 레코드 생성
const masterData: Record<string, any> = {
...masterFieldValues,
[masterKeyColumn]: generatedKey,
company_code: companyCode,
writer: userId,
};
// 마스터 컬럼명 목록 구성
const masterCols = Object.keys(masterData).filter(k => masterData[k] !== undefined);
const masterPlaceholders = masterCols.map((_, i) => `$${i + 1}`);
const masterValues = masterCols.map(k => masterData[k]);
await client.query(
`INSERT INTO "${masterTable}" (${masterCols.map(c => `"${c}"`).join(", ")}, created_date)
VALUES (${masterPlaceholders.join(", ")}, NOW())`,
masterValues
);
result.masterInserted = 1;
logger.info(`마스터 레코드 생성: ${masterTable}, key=${generatedKey}`);
// 4. 디테일 레코드들 생성 (삽입된 데이터 수집)
const insertedDetailRows: Record<string, any>[] = [];
for (const row of detailData) {
try {
const detailRowData: Record<string, any> = {
...row,
[detailFkColumn]: generatedKey,
company_code: companyCode,
writer: userId,
};
// 빈 값 필터링 및 id 제외
const detailCols = Object.keys(detailRowData).filter(k =>
k !== "id" &&
detailRowData[k] !== undefined &&
detailRowData[k] !== null &&
detailRowData[k] !== ""
);
const detailPlaceholders = detailCols.map((_, i) => `$${i + 1}`);
const detailValues = detailCols.map(k => detailRowData[k]);
// RETURNING *로 삽입된 데이터 반환받기
const insertResult = await client.query(
`INSERT INTO "${detailTable}" (${detailCols.map(c => `"${c}"`).join(", ")}, created_date)
VALUES (${detailPlaceholders.join(", ")}, NOW())
RETURNING *`,
detailValues
);
if (insertResult.rows && insertResult.rows[0]) {
insertedDetailRows.push(insertResult.rows[0]);
}
result.detailInserted++;
} catch (error: any) {
result.errors.push(`디테일 행 처리 실패: ${error.message}`);
logger.error(`디테일 행 처리 실패:`, error);
}
}
logger.info(`디테일 레코드 ${insertedDetailRows.length}건 삽입 완료`);
await client.query("COMMIT");
result.success = result.errors.length === 0 || result.detailInserted > 0;
logger.info(`마스터-디테일 간단 모드 업로드 완료:`, {
masterInserted: result.masterInserted,
detailInserted: result.detailInserted,
generatedKey: result.generatedKey,
errors: result.errors.length,
});
// 업로드 후 제어 실행 (단일 또는 다중)
const flowsToExecute = afterUploadFlows && afterUploadFlows.length > 0
? afterUploadFlows // 다중 제어
: afterUploadFlowId
? [{ flowId: afterUploadFlowId, order: 1 }] // 단일 (하위 호환성)
: [];
if (flowsToExecute.length > 0 && result.success) {
try {
const { NodeFlowExecutionService } = await import("./nodeFlowExecutionService");
// 마스터 데이터 구성
const masterData = {
...masterFieldValues,
[relation!.masterKeyColumn]: result.generatedKey,
company_code: companyCode,
};
const controlResults: any[] = [];
// 순서대로 제어 실행
for (const flow of flowsToExecute.sort((a, b) => a.order - b.order)) {
logger.info(`업로드 후 제어 실행: flowId=${flow.flowId}, order=${flow.order}`);
logger.info(` 전달 데이터: 마스터 1건, 디테일 ${insertedDetailRows.length}`);
// 🆕 삽입된 디테일 데이터를 sourceData로 전달 (성능 최적화)
// - 전체 테이블 조회 대신 방금 INSERT한 데이터만 처리
// - tableSource 노드가 context-data 모드일 때 이 데이터를 사용
const controlResult = await NodeFlowExecutionService.executeFlow(
parseInt(flow.flowId),
{
sourceData: insertedDetailRows.length > 0 ? insertedDetailRows : [masterData],
dataSourceType: "excelUpload", // 엑셀 업로드 데이터임을 명시
buttonId: "excel-upload-button",
screenId: screenId,
userId: userId,
companyCode: companyCode,
formData: masterData,
// 추가 컨텍스트: 마스터/디테일 정보
masterData: masterData,
detailData: insertedDetailRows,
masterTable: relation!.masterTable,
detailTable: relation!.detailTable,
masterKeyColumn: relation!.masterKeyColumn,
detailFkColumn: relation!.detailFkColumn,
}
);
controlResults.push({
flowId: flow.flowId,
order: flow.order,
success: controlResult.success,
message: controlResult.message,
executedNodes: controlResult.nodes?.length || 0,
});
}
result.controlResult = {
success: controlResults.every(r => r.success),
executedFlows: controlResults.length,
results: controlResults,
};
logger.info(`업로드 후 제어 실행 완료: ${controlResults.length}개 실행`, result.controlResult);
} catch (controlError: any) {
logger.error(`업로드 후 제어 실행 실패:`, controlError);
result.controlResult = {
success: false,
message: `제어 실행 실패: ${controlError.message}`,
};
}
}
} catch (error: any) {
await client.query("ROLLBACK");
result.errors.push(`트랜잭션 실패: ${error.message}`);
logger.error(`마스터-디테일 간단 모드 업로드 실패:`, error);
} finally {
client.release();
}
return result;
}
/**
* ( numberingRuleService )
*/
private async generateNumberWithRule(
client: any,
ruleId: string,
companyCode: string
): Promise<string> {
try {
// 기존 numberingRuleService를 사용하여 코드 할당
const { numberingRuleService } = await import("./numberingRuleService");
const generatedCode = await numberingRuleService.allocateCode(ruleId, companyCode);
logger.info(`채번 생성 (numberingRuleService): rule=${ruleId}, result=${generatedCode}`);
return generatedCode;
} catch (error: any) {
logger.error(`채번 생성 실패: rule=${ruleId}, error=${error.message}`);
throw error;
}
}
}
export const masterDetailExcelService = new MasterDetailExcelService();

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,969 @@
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
const pool = getPool();
/**
* -
*
* :
* 1. screen_groups menu_info: 화면관리
* 2. menu_info screen_groups: 사용자
*/
// ============================================================
// 타입 정의
// ============================================================
interface SyncResult {
success: boolean;
created: number;
linked: number;
skipped: number;
errors: string[];
details: SyncDetail[];
}
interface SyncDetail {
action: 'created' | 'linked' | 'skipped' | 'error';
sourceName: string;
sourceId: number | string;
targetId?: number | string;
reason?: string;
}
// ============================================================
// 화면관리 → 메뉴 동기화
// ============================================================
/**
* screen_groups를 menu_info로
*
* :
* 1. screen_groups ( )
* 2. menu_objid가
* 3. menu_info
* - 매칭되면: 양쪽에 ID
* - 안되면: menu_info에
* 4. (parent)
*/
export async function syncScreenGroupsToMenu(
companyCode: string,
userId: string
): Promise<SyncResult> {
const result: SyncResult = {
success: true,
created: 0,
linked: 0,
skipped: 0,
errors: [],
details: [],
};
const client = await pool.connect();
try {
await client.query('BEGIN');
logger.info("화면관리 → 메뉴 동기화 시작", { companyCode, userId });
// 1. 해당 회사의 screen_groups 조회 (아직 menu_objid가 없는 것)
const screenGroupsQuery = `
SELECT
sg.id,
sg.group_name,
sg.group_code,
sg.parent_group_id,
sg.group_level,
sg.display_order,
sg.description,
sg.icon,
sg.menu_objid,
-- menu_objid도 ( )
parent.menu_objid as parent_menu_objid
FROM screen_groups sg
LEFT JOIN screen_groups parent ON sg.parent_group_id = parent.id
WHERE sg.company_code = $1
ORDER BY sg.group_level ASC, sg.display_order ASC
`;
const screenGroupsResult = await client.query(screenGroupsQuery, [companyCode]);
// 2. 해당 회사의 기존 menu_info 조회 (사용자 메뉴, menu_type=1)
// 경로 기반 매칭을 위해 부모 이름도 조회
const existingMenusQuery = `
SELECT
m.objid,
m.menu_name_kor,
m.parent_obj_id,
m.screen_group_id,
p.menu_name_kor as parent_name
FROM menu_info m
LEFT JOIN menu_info p ON m.parent_obj_id = p.objid
WHERE m.company_code = $1 AND m.menu_type = 1
`;
const existingMenusResult = await client.query(existingMenusQuery, [companyCode]);
// 경로(부모이름 > 이름) → 메뉴 매핑 (screen_group_id가 없는 것만)
// 단순 이름 매칭도 유지 (하위 호환)
const menuByPath: Map<string, any> = new Map();
const menuByName: Map<string, any> = new Map();
existingMenusResult.rows.forEach((menu: any) => {
if (!menu.screen_group_id) {
const menuName = menu.menu_name_kor?.trim().toLowerCase() || '';
const parentName = menu.parent_name?.trim().toLowerCase() || '';
const pathKey = parentName ? `${parentName}>${menuName}` : menuName;
menuByPath.set(pathKey, menu);
// 단순 이름 매핑은 첫 번째 것만 (중복 방지)
if (!menuByName.has(menuName)) {
menuByName.set(menuName, menu);
}
}
});
// 모든 메뉴의 objid 집합 (삭제 확인용)
const existingMenuObjids = new Set(existingMenusResult.rows.map((m: any) => Number(m.objid)));
// 3. 사용자 메뉴의 루트 찾기 (parent_obj_id = 0인 사용자 메뉴)
// 없으면 생성
let userMenuRootObjid: number | null = null;
const rootMenuQuery = `
SELECT objid FROM menu_info
WHERE company_code = $1 AND menu_type = 1 AND parent_obj_id = 0
ORDER BY seq ASC
LIMIT 1
`;
const rootMenuResult = await client.query(rootMenuQuery, [companyCode]);
if (rootMenuResult.rows.length > 0) {
userMenuRootObjid = Number(rootMenuResult.rows[0].objid);
} else {
// 루트 메뉴가 없으면 생성
const newObjid = Date.now();
const createRootQuery = `
INSERT INTO menu_info (objid, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_type, company_code, writer, regdate, status)
VALUES ($1, 0, '사용자', 'User', 1, 1, $2, $3, NOW(), 'active')
RETURNING objid
`;
const createRootResult = await client.query(createRootQuery, [newObjid, companyCode, userId]);
userMenuRootObjid = Number(createRootResult.rows[0].objid);
logger.info("사용자 메뉴 루트 생성", { companyCode, objid: userMenuRootObjid });
}
// 4. screen_groups ID → menu_objid 매핑 (순차 처리를 위해)
const groupToMenuMap: Map<number, number> = new Map();
// screen_groups의 부모 이름 조회를 위한 매핑
const groupIdToName: Map<number, string> = new Map();
screenGroupsResult.rows.forEach((g: any) => {
groupIdToName.set(g.id, g.group_name?.trim().toLowerCase() || '');
});
// 5. 최상위 회사 폴더 ID 찾기 (level 0, parent_group_id IS NULL)
// 이 폴더는 메뉴로 생성하지 않고, 하위 폴더들을 사용자 루트 바로 아래에 배치
const topLevelCompanyFolderIds = new Set<number>();
for (const group of screenGroupsResult.rows) {
if (group.group_level === 0 && group.parent_group_id === null) {
topLevelCompanyFolderIds.add(group.id);
// 최상위 폴더 → 사용자 루트에 매핑 (하위 폴더의 부모로 사용)
groupToMenuMap.set(group.id, userMenuRootObjid!);
logger.info("최상위 회사 폴더 스킵", { groupId: group.id, groupName: group.group_name });
}
}
// 6. 각 screen_group 처리
for (const group of screenGroupsResult.rows) {
const groupId = group.id;
const groupName = group.group_name?.trim();
const groupNameLower = groupName?.toLowerCase() || '';
// 최상위 회사 폴더는 메뉴로 생성하지 않고 스킵
if (topLevelCompanyFolderIds.has(groupId)) {
result.skipped++;
result.details.push({
action: 'skipped',
sourceName: groupName,
sourceId: groupId,
reason: '최상위 회사 폴더 (메뉴 생성 스킵)',
});
continue;
}
// 이미 연결된 경우 - 실제로 메뉴가 존재하는지 확인
if (group.menu_objid) {
const menuExists = existingMenuObjids.has(Number(group.menu_objid));
if (menuExists) {
// 메뉴가 존재하면 스킵
result.skipped++;
result.details.push({
action: 'skipped',
sourceName: groupName,
sourceId: groupId,
targetId: group.menu_objid,
reason: '이미 메뉴와 연결됨',
});
groupToMenuMap.set(groupId, Number(group.menu_objid));
continue;
} else {
// 메뉴가 삭제되었으면 연결 해제하고 재생성
logger.info("삭제된 메뉴 연결 해제", { groupId, deletedMenuObjid: group.menu_objid });
await client.query(
`UPDATE screen_groups SET menu_objid = NULL, updated_date = NOW() WHERE id = $1`,
[groupId]
);
// 계속 진행하여 재생성 또는 재연결
}
}
// 부모 그룹 이름 조회 (경로 기반 매칭용)
const parentGroupName = group.parent_group_id ? groupIdToName.get(group.parent_group_id) : '';
const pathKey = parentGroupName ? `${parentGroupName}>${groupNameLower}` : groupNameLower;
// 경로로 기존 메뉴 매칭 시도 (우선순위: 경로 매칭 > 이름 매칭)
let matchedMenu = menuByPath.get(pathKey);
if (!matchedMenu) {
// 경로 매칭 실패시 이름으로 시도 (하위 호환)
matchedMenu = menuByName.get(groupNameLower);
}
if (matchedMenu) {
// 매칭된 메뉴와 연결
const menuObjid = Number(matchedMenu.objid);
// screen_groups에 menu_objid 업데이트
await client.query(
`UPDATE screen_groups SET menu_objid = $1, updated_date = NOW() WHERE id = $2`,
[menuObjid, groupId]
);
// menu_info에 screen_group_id 업데이트
await client.query(
`UPDATE menu_info SET screen_group_id = $1 WHERE objid = $2`,
[groupId, menuObjid]
);
groupToMenuMap.set(groupId, menuObjid);
result.linked++;
result.details.push({
action: 'linked',
sourceName: groupName,
sourceId: groupId,
targetId: menuObjid,
});
// 매칭된 메뉴는 Map에서 제거 (중복 매칭 방지)
menuByPath.delete(pathKey);
menuByName.delete(groupNameLower);
} else {
// 새 메뉴 생성
const newObjid = Date.now() + groupId; // 고유 ID 보장
// 부모 메뉴 objid 결정
// 우선순위: groupToMenuMap > parent_menu_objid (존재 확인 필수)
let parentMenuObjid = userMenuRootObjid;
if (group.parent_group_id && groupToMenuMap.has(group.parent_group_id)) {
// 현재 트랜잭션에서 생성된 부모 메뉴 사용
parentMenuObjid = groupToMenuMap.get(group.parent_group_id)!;
} else if (group.parent_group_id && group.parent_menu_objid) {
// 기존 parent_menu_objid가 실제로 존재하는지 확인
const parentMenuExists = existingMenuObjids.has(Number(group.parent_menu_objid));
if (parentMenuExists) {
parentMenuObjid = Number(group.parent_menu_objid);
}
}
// 같은 부모 아래에서 가장 높은 seq 조회 후 +1
let nextSeq = 1;
const maxSeqQuery = `
SELECT COALESCE(MAX(seq), 0) + 1 as next_seq
FROM menu_info
WHERE parent_obj_id = $1 AND company_code = $2 AND menu_type = 1
`;
const maxSeqResult = await client.query(maxSeqQuery, [parentMenuObjid, companyCode]);
if (maxSeqResult.rows.length > 0) {
nextSeq = parseInt(maxSeqResult.rows[0].next_seq) || 1;
}
// menu_info에 삽입
const insertMenuQuery = `
INSERT INTO menu_info (
objid, parent_obj_id, menu_name_kor, menu_name_eng,
seq, menu_type, company_code, writer, regdate, status, screen_group_id, menu_desc
) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, NOW(), 'active', $8, $9)
RETURNING objid
`;
await client.query(insertMenuQuery, [
newObjid,
parentMenuObjid,
groupName,
group.group_code || groupName,
nextSeq,
companyCode,
userId,
groupId,
group.description || null,
]);
// screen_groups에 menu_objid 업데이트
await client.query(
`UPDATE screen_groups SET menu_objid = $1, updated_date = NOW() WHERE id = $2`,
[newObjid, groupId]
);
groupToMenuMap.set(groupId, newObjid);
result.created++;
result.details.push({
action: 'created',
sourceName: groupName,
sourceId: groupId,
targetId: newObjid,
});
}
}
await client.query('COMMIT');
logger.info("화면관리 → 메뉴 동기화 완료", {
companyCode,
created: result.created,
linked: result.linked,
skipped: result.skipped
});
return result;
} catch (error: any) {
await client.query('ROLLBACK');
logger.error("화면관리 → 메뉴 동기화 실패", { companyCode, error: error.message });
result.success = false;
result.errors.push(error.message);
return result;
} finally {
client.release();
}
}
// ============================================================
// 메뉴 → 화면관리 동기화
// ============================================================
/**
* menu_info를 screen_groups로
*
* :
* 1. (menu_type=1)
* 2. screen_group_id가
* 3. screen_groups
* - 매칭되면: 양쪽에 ID
* - 안되면: screen_groups에 ()
* 4. (parent)
*/
export async function syncMenuToScreenGroups(
companyCode: string,
userId: string
): Promise<SyncResult> {
const result: SyncResult = {
success: true,
created: 0,
linked: 0,
skipped: 0,
errors: [],
details: [],
};
const client = await pool.connect();
try {
await client.query('BEGIN');
logger.info("메뉴 → 화면관리 동기화 시작", { companyCode, userId });
// 0. 회사 이름 조회 (회사 폴더 찾기/생성용)
const companyNameQuery = `SELECT company_name FROM company_mng WHERE company_code = $1`;
const companyNameResult = await client.query(companyNameQuery, [companyCode]);
const companyName = companyNameResult.rows[0]?.company_name || companyCode;
// 1. 해당 회사의 사용자 메뉴 조회 (menu_type=1)
const menusQuery = `
SELECT
m.objid,
m.menu_name_kor,
m.menu_name_eng,
m.parent_obj_id,
m.seq,
m.menu_url,
m.menu_desc,
m.screen_group_id,
-- screen_group_id도 ( )
parent.screen_group_id as parent_screen_group_id
FROM menu_info m
LEFT JOIN menu_info parent ON m.parent_obj_id = parent.objid
WHERE m.company_code = $1 AND m.menu_type = 1
ORDER BY
CASE WHEN m.parent_obj_id = 0 THEN 0 ELSE 1 END,
m.parent_obj_id,
m.seq
`;
const menusResult = await client.query(menusQuery, [companyCode]);
// 2. 해당 회사의 기존 screen_groups 조회 (경로 기반 매칭을 위해 부모 이름도 조회)
const existingGroupsQuery = `
SELECT
g.id,
g.group_name,
g.menu_objid,
g.parent_group_id,
p.group_name as parent_name
FROM screen_groups g
LEFT JOIN screen_groups p ON g.parent_group_id = p.id
WHERE g.company_code = $1
`;
const existingGroupsResult = await client.query(existingGroupsQuery, [companyCode]);
// 경로(부모이름 > 이름) → 그룹 매핑 (menu_objid가 없는 것만)
// 단순 이름 매칭도 유지 (하위 호환)
const groupByPath: Map<string, any> = new Map();
const groupByName: Map<string, any> = new Map();
existingGroupsResult.rows.forEach((group: any) => {
if (!group.menu_objid) {
const groupName = group.group_name?.trim().toLowerCase() || '';
const parentName = group.parent_name?.trim().toLowerCase() || '';
const pathKey = parentName ? `${parentName}>${groupName}` : groupName;
groupByPath.set(pathKey, group);
// 단순 이름 매핑은 첫 번째 것만 (중복 방지)
if (!groupByName.has(groupName)) {
groupByName.set(groupName, group);
}
}
});
// 모든 그룹의 id 집합 (삭제 확인용)
const existingGroupIds = new Set(existingGroupsResult.rows.map((g: any) => Number(g.id)));
// 3. 회사 폴더 찾기 또는 생성 (루트 레벨에 회사명으로 된 폴더)
let companyFolderId: number | null = null;
const companyFolderQuery = `
SELECT id FROM screen_groups
WHERE company_code = $1 AND parent_group_id IS NULL AND group_level = 0
ORDER BY id ASC
LIMIT 1
`;
const companyFolderResult = await client.query(companyFolderQuery, [companyCode]);
if (companyFolderResult.rows.length > 0) {
companyFolderId = companyFolderResult.rows[0].id;
logger.info("회사 폴더 발견", { companyCode, companyFolderId, companyName });
} else {
// 회사 폴더가 없으면 생성
// 루트 레벨에서 가장 높은 display_order 조회 후 +1
let nextRootOrder = 1;
const maxRootOrderQuery = `
SELECT COALESCE(MAX(display_order), 0) + 1 as next_order
FROM screen_groups
WHERE parent_group_id IS NULL
`;
const maxRootOrderResult = await client.query(maxRootOrderQuery);
if (maxRootOrderResult.rows.length > 0) {
nextRootOrder = parseInt(maxRootOrderResult.rows[0].next_order) || 1;
}
const createFolderQuery = `
INSERT INTO screen_groups (
group_name, group_code, parent_group_id, group_level,
display_order, company_code, writer, hierarchy_path
) VALUES ($1, $2, NULL, 0, $3, $4, $5, '/')
RETURNING id
`;
const createFolderResult = await client.query(createFolderQuery, [
companyName,
companyCode.toLowerCase(),
nextRootOrder,
companyCode,
userId,
]);
companyFolderId = createFolderResult.rows[0].id;
// hierarchy_path 업데이트
await client.query(
`UPDATE screen_groups SET hierarchy_path = $1 WHERE id = $2`,
[`/${companyFolderId}/`, companyFolderId]
);
logger.info("회사 폴더 생성", { companyCode, companyFolderId, companyName });
}
// 4. menu_objid → screen_group_id 매핑 (순차 처리를 위해)
const menuToGroupMap: Map<number, number> = new Map();
// 부모 메뉴 중 이미 screen_group_id가 있는 것 등록
menusResult.rows.forEach((menu: any) => {
if (menu.screen_group_id) {
menuToGroupMap.set(Number(menu.objid), Number(menu.screen_group_id));
}
});
// 루트 메뉴(parent_obj_id = 0)의 objid 찾기 → 회사 폴더와 매핑
let rootMenuObjid: number | null = null;
for (const menu of menusResult.rows) {
if (Number(menu.parent_obj_id) === 0) {
rootMenuObjid = Number(menu.objid);
// 루트 메뉴는 회사 폴더와 연결
if (companyFolderId) {
menuToGroupMap.set(rootMenuObjid, companyFolderId);
}
break;
}
}
// 5. 각 메뉴 처리
for (const menu of menusResult.rows) {
const menuObjid = Number(menu.objid);
const menuName = menu.menu_name_kor?.trim();
// 루트 메뉴(parent_obj_id = 0)는 스킵 (이미 회사 폴더와 매핑됨)
if (Number(menu.parent_obj_id) === 0) {
result.skipped++;
result.details.push({
action: 'skipped',
sourceName: menuName,
sourceId: menuObjid,
targetId: companyFolderId || undefined,
reason: '루트 메뉴 → 회사 폴더와 매핑됨',
});
continue;
}
// 이미 연결된 경우 - 실제로 그룹이 존재하는지 확인
if (menu.screen_group_id) {
const groupExists = existingGroupIds.has(Number(menu.screen_group_id));
if (groupExists) {
// 그룹이 존재하면 스킵
result.skipped++;
result.details.push({
action: 'skipped',
sourceName: menuName,
sourceId: menuObjid,
targetId: menu.screen_group_id,
reason: '이미 화면그룹과 연결됨',
});
menuToGroupMap.set(menuObjid, Number(menu.screen_group_id));
continue;
} else {
// 그룹이 삭제되었으면 연결 해제하고 재생성
logger.info("삭제된 그룹 연결 해제", { menuObjid, deletedGroupId: menu.screen_group_id });
await client.query(
`UPDATE menu_info SET screen_group_id = NULL WHERE objid = $1`,
[menuObjid]
);
// 계속 진행하여 재생성 또는 재연결
}
}
const menuNameLower = menuName?.toLowerCase() || '';
// 부모 메뉴 이름 조회 (경로 기반 매칭용)
const parentMenu = menusResult.rows.find((m: any) => Number(m.objid) === Number(menu.parent_obj_id));
const parentMenuName = parentMenu?.menu_name_kor?.trim().toLowerCase() || '';
const pathKey = parentMenuName ? `${parentMenuName}>${menuNameLower}` : menuNameLower;
// 경로로 기존 그룹 매칭 시도 (우선순위: 경로 매칭 > 이름 매칭)
let matchedGroup = groupByPath.get(pathKey);
if (!matchedGroup) {
// 경로 매칭 실패시 이름으로 시도 (하위 호환)
matchedGroup = groupByName.get(menuNameLower);
}
if (matchedGroup) {
// 매칭된 그룹과 연결
const groupId = Number(matchedGroup.id);
try {
// menu_info에 screen_group_id 업데이트
await client.query(
`UPDATE menu_info SET screen_group_id = $1 WHERE objid = $2`,
[groupId, menuObjid]
);
// screen_groups에 menu_objid 업데이트
await client.query(
`UPDATE screen_groups SET menu_objid = $1, updated_date = NOW() WHERE id = $2`,
[menuObjid, groupId]
);
menuToGroupMap.set(menuObjid, groupId);
result.linked++;
result.details.push({
action: 'linked',
sourceName: menuName,
sourceId: menuObjid,
targetId: groupId,
});
// 매칭된 그룹은 Map에서 제거 (중복 매칭 방지)
groupByPath.delete(pathKey);
groupByName.delete(menuNameLower);
} catch (linkError: any) {
logger.error("그룹 연결 중 에러", { menuName, menuObjid, groupId, error: linkError.message, stack: linkError.stack });
throw linkError;
}
} else {
// 새 screen_group 생성
// 부모 그룹 ID 결정
let parentGroupId: number | null = null;
let groupLevel = 1; // 기본값은 1 (회사 폴더 아래)
// 우선순위 1: menuToGroupMap에서 부모 메뉴의 새 그룹 ID 조회 (같은 트랜잭션에서 생성된 것)
if (menuToGroupMap.has(Number(menu.parent_obj_id))) {
parentGroupId = menuToGroupMap.get(Number(menu.parent_obj_id))!;
}
// 우선순위 2: 부모 메뉴가 루트 메뉴면 회사 폴더 사용
else if (Number(menu.parent_obj_id) === rootMenuObjid) {
parentGroupId = companyFolderId;
}
// 우선순위 3: 부모 메뉴의 screen_group_id가 있고, 해당 그룹이 실제로 존재하면 사용
else if (menu.parent_screen_group_id && existingGroupIds.has(Number(menu.parent_screen_group_id))) {
parentGroupId = Number(menu.parent_screen_group_id);
}
// 부모 그룹의 레벨 조회
if (parentGroupId) {
const parentLevelQuery = `SELECT group_level FROM screen_groups WHERE id = $1`;
const parentLevelResult = await client.query(parentLevelQuery, [parentGroupId]);
if (parentLevelResult.rows.length > 0) {
groupLevel = (parentLevelResult.rows[0].group_level || 0) + 1;
}
}
// 같은 부모 아래에서 가장 높은 display_order 조회 후 +1
let nextDisplayOrder = 1;
const maxOrderQuery = parentGroupId
? `SELECT COALESCE(MAX(display_order), 0) + 1 as next_order FROM screen_groups WHERE parent_group_id = $1 AND company_code = $2`
: `SELECT COALESCE(MAX(display_order), 0) + 1 as next_order FROM screen_groups WHERE parent_group_id IS NULL AND company_code = $1`;
const maxOrderParams = parentGroupId ? [parentGroupId, companyCode] : [companyCode];
const maxOrderResult = await client.query(maxOrderQuery, maxOrderParams);
if (maxOrderResult.rows.length > 0) {
nextDisplayOrder = parseInt(maxOrderResult.rows[0].next_order) || 1;
}
// group_code 생성 (영문명 또는 이름 기반)
const groupCode = (menu.menu_name_eng || menuName || 'group')
.replace(/\s+/g, '_')
.toLowerCase()
.substring(0, 50);
// screen_groups에 삽입
const insertGroupQuery = `
INSERT INTO screen_groups (
group_name, group_code, parent_group_id, group_level,
display_order, company_code, writer, menu_objid, description
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id
`;
let newGroupId: number;
try {
logger.info("새 그룹 생성 시도", {
menuName,
menuObjid,
groupCode: groupCode + '_' + menuObjid,
parentGroupId,
groupLevel,
nextDisplayOrder,
companyCode,
});
const insertResult = await client.query(insertGroupQuery, [
menuName,
groupCode + '_' + menuObjid, // 고유성 보장
parentGroupId,
groupLevel,
nextDisplayOrder,
companyCode,
userId,
menuObjid,
menu.menu_desc || null,
]);
newGroupId = insertResult.rows[0].id;
} catch (insertError: any) {
logger.error("그룹 생성 중 에러", {
menuName,
menuObjid,
parentGroupId,
groupLevel,
error: insertError.message,
stack: insertError.stack,
code: insertError.code,
detail: insertError.detail,
});
throw insertError;
}
// hierarchy_path 업데이트
let hierarchyPath = `/${newGroupId}/`;
if (parentGroupId) {
const parentPathQuery = `SELECT hierarchy_path FROM screen_groups WHERE id = $1`;
const parentPathResult = await client.query(parentPathQuery, [parentGroupId]);
if (parentPathResult.rows.length > 0 && parentPathResult.rows[0].hierarchy_path) {
hierarchyPath = `${parentPathResult.rows[0].hierarchy_path}${newGroupId}/`.replace('//', '/');
}
}
await client.query(
`UPDATE screen_groups SET hierarchy_path = $1 WHERE id = $2`,
[hierarchyPath, newGroupId]
);
// menu_info에 screen_group_id 업데이트
await client.query(
`UPDATE menu_info SET screen_group_id = $1 WHERE objid = $2`,
[newGroupId, menuObjid]
);
menuToGroupMap.set(menuObjid, newGroupId);
result.created++;
result.details.push({
action: 'created',
sourceName: menuName,
sourceId: menuObjid,
targetId: newGroupId,
});
}
}
await client.query('COMMIT');
logger.info("메뉴 → 화면관리 동기화 완료", {
companyCode,
created: result.created,
linked: result.linked,
skipped: result.skipped
});
return result;
} catch (error: any) {
await client.query('ROLLBACK');
logger.error("메뉴 → 화면관리 동기화 실패", {
companyCode,
error: error.message,
stack: error.stack,
code: error.code,
detail: error.detail,
});
result.success = false;
result.errors.push(error.message);
return result;
} finally {
client.release();
}
}
// ============================================================
// 동기화 상태 조회
// ============================================================
/**
*
*
* -
* -
* -
*/
export async function getSyncStatus(companyCode: string): Promise<{
screenGroups: { total: number; linked: number; unlinked: number };
menuItems: { total: number; linked: number; unlinked: number };
potentialMatches: Array<{ menuName: string; groupName: string; similarity: string }>;
}> {
// screen_groups 상태
const sgQuery = `
SELECT
COUNT(*) as total,
COUNT(menu_objid) as linked
FROM screen_groups
WHERE company_code = $1
`;
const sgResult = await pool.query(sgQuery, [companyCode]);
// menu_info 상태 (사용자 메뉴만, 루트 제외)
const menuQuery = `
SELECT
COUNT(*) as total,
COUNT(screen_group_id) as linked
FROM menu_info
WHERE company_code = $1 AND menu_type = 1 AND parent_obj_id != 0
`;
const menuResult = await pool.query(menuQuery, [companyCode]);
// 이름이 같은 잠재적 매칭 후보 조회
const matchQuery = `
SELECT
m.menu_name_kor as menu_name,
sg.group_name
FROM menu_info m
JOIN screen_groups sg ON LOWER(TRIM(m.menu_name_kor)) = LOWER(TRIM(sg.group_name))
WHERE m.company_code = $1
AND sg.company_code = $1
AND m.menu_type = 1
AND m.screen_group_id IS NULL
AND sg.menu_objid IS NULL
LIMIT 10
`;
const matchResult = await pool.query(matchQuery, [companyCode]);
const sgTotal = parseInt(sgResult.rows[0].total);
const sgLinked = parseInt(sgResult.rows[0].linked);
const menuTotal = parseInt(menuResult.rows[0].total);
const menuLinked = parseInt(menuResult.rows[0].linked);
return {
screenGroups: {
total: sgTotal,
linked: sgLinked,
unlinked: sgTotal - sgLinked,
},
menuItems: {
total: menuTotal,
linked: menuLinked,
unlinked: menuTotal - menuLinked,
},
potentialMatches: matchResult.rows.map((row: any) => ({
menuName: row.menu_name,
groupName: row.group_name,
similarity: 'exact',
})),
};
}
// ============================================================
// 전체 동기화 (모든 회사)
// ============================================================
interface AllCompaniesSyncResult {
success: boolean;
totalCompanies: number;
successCount: number;
failedCount: number;
results: Array<{
companyCode: string;
companyName: string;
direction: 'screens-to-menus' | 'menus-to-screens';
created: number;
linked: number;
skipped: number;
success: boolean;
error?: string;
}>;
}
/**
*
*
* :
* 1.
* 2.
* -
* -
* 3.
*/
export async function syncAllCompanies(
userId: string
): Promise<AllCompaniesSyncResult> {
const result: AllCompaniesSyncResult = {
success: true,
totalCompanies: 0,
successCount: 0,
failedCount: 0,
results: [],
};
try {
logger.info("전체 동기화 시작", { userId });
// 모든 회사 조회 (최고 관리자 전용 회사 제외)
const companiesQuery = `
SELECT company_code, company_name
FROM company_mng
WHERE company_code != '*'
ORDER BY company_name
`;
const companiesResult = await pool.query(companiesQuery);
result.totalCompanies = companiesResult.rows.length;
// 각 회사별로 양방향 동기화
for (const company of companiesResult.rows) {
const companyCode = company.company_code;
const companyName = company.company_name;
try {
// 1. 화면관리 → 메뉴 동기화
const screensToMenusResult = await syncScreenGroupsToMenu(companyCode, userId);
result.results.push({
companyCode,
companyName,
direction: 'screens-to-menus',
created: screensToMenusResult.created,
linked: screensToMenusResult.linked,
skipped: screensToMenusResult.skipped,
success: screensToMenusResult.success,
error: screensToMenusResult.errors.length > 0 ? screensToMenusResult.errors.join(', ') : undefined,
});
// 2. 메뉴 → 화면관리 동기화
const menusToScreensResult = await syncMenuToScreenGroups(companyCode, userId);
result.results.push({
companyCode,
companyName,
direction: 'menus-to-screens',
created: menusToScreensResult.created,
linked: menusToScreensResult.linked,
skipped: menusToScreensResult.skipped,
success: menusToScreensResult.success,
error: menusToScreensResult.errors.length > 0 ? menusToScreensResult.errors.join(', ') : undefined,
});
if (screensToMenusResult.success && menusToScreensResult.success) {
result.successCount++;
} else {
result.failedCount++;
}
} catch (error: any) {
logger.error("회사 동기화 실패", { companyCode, companyName, error: error.message });
result.results.push({
companyCode,
companyName,
direction: 'screens-to-menus',
created: 0,
linked: 0,
skipped: 0,
success: false,
error: error.message,
});
result.failedCount++;
}
}
logger.info("전체 동기화 완료", {
totalCompanies: result.totalCompanies,
successCount: result.successCount,
failedCount: result.failedCount,
});
return result;
} catch (error: any) {
logger.error("전체 동기화 실패", { error: error.message });
result.success = false;
return result;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -607,7 +607,9 @@ class NumberingRuleService {
} }
const result = await pool.query(query, params); const result = await pool.query(query, params);
if (result.rowCount === 0) return null; if (result.rowCount === 0) {
return null;
}
const rule = result.rows[0]; const rule = result.rows[0];
@ -896,14 +898,15 @@ class NumberingRuleService {
switch (part.partType) { switch (part.partType) {
case "sequence": { case "sequence": {
// 순번 (현재 순번으로 미리보기, 증가 안 함) // 순번 (다음 할당될 순번으로 미리보기, 실제 증가는 allocate 시)
const length = autoConfig.sequenceLength || 4; const length = autoConfig.sequenceLength || 3;
return String(rule.currentSequence || 1).padStart(length, "0"); const nextSequence = (rule.currentSequence || 0) + 1;
return String(nextSequence).padStart(length, "0");
} }
case "number": { case "number": {
// 숫자 (고정 자릿수) // 숫자 (고정 자릿수)
const length = autoConfig.numberLength || 4; const length = autoConfig.numberLength || 3;
const value = autoConfig.numberValue || 1; const value = autoConfig.numberValue || 1;
return String(value).padStart(length, "0"); return String(value).padStart(length, "0");
} }
@ -956,14 +959,15 @@ class NumberingRuleService {
switch (part.partType) { switch (part.partType) {
case "sequence": { case "sequence": {
// 순번 (자동 증가 숫자) // 순번 (자동 증가 숫자 - 다음 번호 사용)
const length = autoConfig.sequenceLength || 4; const length = autoConfig.sequenceLength || 3;
return String(rule.currentSequence || 1).padStart(length, "0"); const nextSequence = (rule.currentSequence || 0) + 1;
return String(nextSequence).padStart(length, "0");
} }
case "number": { case "number": {
// 숫자 (고정 자릿수) // 숫자 (고정 자릿수)
const length = autoConfig.numberLength || 4; const length = autoConfig.numberLength || 3;
const value = autoConfig.numberValue || 1; const value = autoConfig.numberValue || 1;
return String(value).padStart(length, "0"); return String(value).padStart(length, "0");
} }

View File

@ -234,10 +234,23 @@ export class ReportService {
`; `;
const queries = await query<ReportQuery>(queriesQuery, [reportId]); const queries = await query<ReportQuery>(queriesQuery, [reportId]);
// 메뉴 매핑 조회
const menuMappingQuery = `
SELECT menu_objid
FROM report_menu_mapping
WHERE report_id = $1
ORDER BY created_at
`;
const menuMappings = await query<{ menu_objid: number }>(menuMappingQuery, [
reportId,
]);
const menuObjids = menuMappings?.map((m) => Number(m.menu_objid)) || [];
return { return {
report, report,
layout, layout,
queries: queries || [], queries: queries || [],
menuObjids,
}; };
} }
@ -477,6 +490,12 @@ export class ReportService {
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`; `;
// components가 이미 문자열이면 그대로, 객체면 JSON.stringify
const componentsData =
typeof originalLayout.components === "string"
? originalLayout.components
: JSON.stringify(originalLayout.components);
await client.query(copyLayoutQuery, [ await client.query(copyLayoutQuery, [
newLayoutId, newLayoutId,
newReportId, newReportId,
@ -487,7 +506,7 @@ export class ReportService {
originalLayout.margin_bottom, originalLayout.margin_bottom,
originalLayout.margin_left, originalLayout.margin_left,
originalLayout.margin_right, originalLayout.margin_right,
JSON.stringify(originalLayout.components), componentsData,
userId, userId,
]); ]);
} }
@ -561,7 +580,7 @@ export class ReportService {
} }
/** /**
* ( ) * ( ) -
*/ */
async saveLayout( async saveLayout(
reportId: string, reportId: string,
@ -569,6 +588,19 @@ export class ReportService {
userId: string userId: string
): Promise<boolean> { ): Promise<boolean> {
return transaction(async (client) => { return transaction(async (client) => {
// 첫 번째 페이지 정보를 기본 레이아웃으로 사용
const firstPage = data.layoutConfig.pages[0];
const canvasWidth = firstPage?.width || 210;
const canvasHeight = firstPage?.height || 297;
const pageOrientation =
canvasWidth > canvasHeight ? "landscape" : "portrait";
const margins = firstPage?.margins || {
top: 20,
bottom: 20,
left: 20,
right: 20,
};
// 1. 레이아웃 저장 // 1. 레이아웃 저장
const existingQuery = ` const existingQuery = `
SELECT layout_id FROM report_layout WHERE report_id = $1 SELECT layout_id FROM report_layout WHERE report_id = $1
@ -576,7 +608,7 @@ export class ReportService {
const existing = await client.query(existingQuery, [reportId]); const existing = await client.query(existingQuery, [reportId]);
if (existing.rows.length > 0) { if (existing.rows.length > 0) {
// 업데이트 // 업데이트 - components 컬럼에 전체 layoutConfig 저장
const updateQuery = ` const updateQuery = `
UPDATE report_layout UPDATE report_layout
SET SET
@ -594,14 +626,14 @@ export class ReportService {
`; `;
await client.query(updateQuery, [ await client.query(updateQuery, [
data.canvasWidth, canvasWidth,
data.canvasHeight, canvasHeight,
data.pageOrientation, pageOrientation,
data.marginTop, margins.top,
data.marginBottom, margins.bottom,
data.marginLeft, margins.left,
data.marginRight, margins.right,
JSON.stringify(data.components), JSON.stringify(data.layoutConfig), // 전체 layoutConfig 저장
userId, userId,
reportId, reportId,
]); ]);
@ -627,14 +659,14 @@ export class ReportService {
await client.query(insertQuery, [ await client.query(insertQuery, [
layoutId, layoutId,
reportId, reportId,
data.canvasWidth, canvasWidth,
data.canvasHeight, canvasHeight,
data.pageOrientation, pageOrientation,
data.marginTop, margins.top,
data.marginBottom, margins.bottom,
data.marginLeft, margins.left,
data.marginRight, margins.right,
JSON.stringify(data.components), JSON.stringify(data.layoutConfig), // 전체 layoutConfig 저장
userId, userId,
]); ]);
} }
@ -677,6 +709,43 @@ export class ReportService {
} }
} }
// 3. 메뉴 매핑 저장 (있는 경우)
if (data.menuObjids !== undefined) {
// 기존 메뉴 매핑 모두 삭제
await client.query(
`DELETE FROM report_menu_mapping WHERE report_id = $1`,
[reportId]
);
// 새 메뉴 매핑 삽입
if (data.menuObjids.length > 0) {
// 리포트의 company_code 조회
const reportResult = await client.query(
`SELECT company_code FROM report_master WHERE report_id = $1`,
[reportId]
);
const companyCode = reportResult.rows[0]?.company_code || "*";
const insertMappingSql = `
INSERT INTO report_menu_mapping (
report_id,
menu_objid,
company_code,
created_by
) VALUES ($1, $2, $3, $4)
`;
for (const menuObjid of data.menuObjids) {
await client.query(insertMappingSql, [
reportId,
menuObjid,
companyCode,
userId,
]);
}
}
}
return true; return true;
}); });
} }

View File

@ -47,9 +47,24 @@ export class RiskAlertService {
console.log('✅ 기상청 특보 현황 API 응답 수신 완료'); console.log('✅ 기상청 특보 현황 API 응답 수신 완료');
// 텍스트 응답 파싱 (EUC-KR 인코딩) // 텍스트 응답 파싱 (인코딩 자동 감지)
const iconv = require('iconv-lite'); const iconv = require('iconv-lite');
const responseText = iconv.decode(Buffer.from(warningResponse.data), 'EUC-KR'); const buffer = Buffer.from(warningResponse.data);
// UTF-8 먼저 시도, 실패하면 EUC-KR 시도
let responseText: string;
const utf8Text = buffer.toString('utf-8');
// UTF-8로 정상 디코딩되었는지 확인 (한글이 깨지지 않았는지)
if (utf8Text.includes('특보') || utf8Text.includes('경보') || utf8Text.includes('주의보') ||
(utf8Text.includes('#START7777') && !utf8Text.includes('<27>'))) {
responseText = utf8Text;
console.log('📝 UTF-8 인코딩으로 디코딩');
} else {
// EUC-KR로 디코딩
responseText = iconv.decode(buffer, 'EUC-KR');
console.log('📝 EUC-KR 인코딩으로 디코딩');
}
if (typeof responseText === 'string' && responseText.includes('#START7777')) { if (typeof responseText === 'string' && responseText.includes('#START7777')) {
const lines = responseText.split('\n'); const lines = responseText.split('\n');

View File

@ -326,7 +326,19 @@ export class ScreenManagementService {
*/ */
async updateScreenInfo( async updateScreenInfo(
screenId: number, screenId: number,
updateData: { screenName: string; tableName?: string; description?: string; isActive: string }, updateData: {
screenName: string;
tableName?: string;
description?: string;
isActive: string;
// REST API 관련 필드 추가
dataSourceType?: string;
dbSourceType?: string;
dbConnectionId?: number;
restApiConnectionId?: number;
restApiEndpoint?: string;
restApiJsonPath?: string;
},
userCompanyCode: string userCompanyCode: string
): Promise<void> { ): Promise<void> {
// 권한 확인 // 권한 확인
@ -348,24 +360,43 @@ export class ScreenManagementService {
throw new Error("이 화면을 수정할 권한이 없습니다."); throw new Error("이 화면을 수정할 권한이 없습니다.");
} }
// 화면 정보 업데이트 (tableName 포함) // 화면 정보 업데이트 (REST API 필드 포함)
await query( await query(
`UPDATE screen_definitions `UPDATE screen_definitions
SET screen_name = $1, SET screen_name = $1,
table_name = $2, table_name = $2,
description = $3, description = $3,
is_active = $4, is_active = $4,
updated_date = $5 updated_date = $5,
WHERE screen_id = $6`, data_source_type = $6,
db_source_type = $7,
db_connection_id = $8,
rest_api_connection_id = $9,
rest_api_endpoint = $10,
rest_api_json_path = $11
WHERE screen_id = $12`,
[ [
updateData.screenName, updateData.screenName,
updateData.tableName || null, updateData.tableName || null,
updateData.description || null, updateData.description || null,
updateData.isActive, updateData.isActive,
new Date(), new Date(),
updateData.dataSourceType || "database",
updateData.dbSourceType || "internal",
updateData.dbConnectionId || null,
updateData.restApiConnectionId || null,
updateData.restApiEndpoint || null,
updateData.restApiJsonPath || null,
screenId, screenId,
] ]
); );
console.log(`화면 정보 업데이트 완료: screenId=${screenId}`, {
dataSourceType: updateData.dataSourceType,
restApiConnectionId: updateData.restApiConnectionId,
restApiEndpoint: updateData.restApiEndpoint,
restApiJsonPath: updateData.restApiJsonPath,
});
} }
/** /**
@ -861,6 +892,134 @@ export class ScreenManagementService {
}; };
} }
/**
* ( )
*/
async bulkDeleteScreens(
screenIds: number[],
userCompanyCode: string,
deletedBy: string,
deleteReason?: string,
force: boolean = false
): Promise<{
deletedCount: number;
skippedCount: number;
errors: Array<{ screenId: number; error: string }>;
}> {
if (screenIds.length === 0) {
throw new Error("삭제할 화면을 선택해주세요.");
}
let deletedCount = 0;
let skippedCount = 0;
const errors: Array<{ screenId: number; error: string }> = [];
// 각 화면을 개별적으로 삭제 처리
for (const screenId of screenIds) {
try {
// 권한 확인 (Raw Query)
const existingResult = await query<{
company_code: string | null;
is_active: string;
screen_name: string;
}>(
`SELECT company_code, is_active, screen_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
[screenId]
);
if (existingResult.length === 0) {
skippedCount++;
errors.push({
screenId,
error: "화면을 찾을 수 없습니다.",
});
continue;
}
const existingScreen = existingResult[0];
// 권한 확인
if (
userCompanyCode !== "*" &&
existingScreen.company_code !== userCompanyCode
) {
skippedCount++;
errors.push({
screenId,
error: "이 화면을 삭제할 권한이 없습니다.",
});
continue;
}
// 이미 삭제된 화면인지 확인
if (existingScreen.is_active === "D") {
skippedCount++;
errors.push({
screenId,
error: "이미 삭제된 화면입니다.",
});
continue;
}
// 강제 삭제가 아닌 경우 의존성 체크
if (!force) {
const dependencyCheck = await this.checkScreenDependencies(
screenId,
userCompanyCode
);
if (dependencyCheck.hasDependencies) {
skippedCount++;
errors.push({
screenId,
error: `다른 화면에서 사용 중 (${dependencyCheck.dependencies.length}개 참조)`,
});
continue;
}
}
// 트랜잭션으로 화면 삭제와 메뉴 할당 정리를 함께 처리
await transaction(async (client) => {
const now = new Date();
// 소프트 삭제 (휴지통으로 이동)
await client.query(
`UPDATE screen_definitions
SET is_active = 'D',
deleted_date = $1,
deleted_by = $2,
delete_reason = $3,
updated_date = $4,
updated_by = $5
WHERE screen_id = $6`,
[now, deletedBy, deleteReason || null, now, deletedBy, screenId]
);
// 메뉴 할당 정리 (삭제된 화면의 메뉴 할당 제거)
await client.query(
`DELETE FROM screen_menu_assignments WHERE screen_id = $1`,
[screenId]
);
});
deletedCount++;
logger.info(`화면 삭제 완료: ${screenId} (${existingScreen.screen_name})`);
} catch (error) {
skippedCount++;
errors.push({
screenId,
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
logger.error(`화면 삭제 실패: ${screenId}`, error);
}
}
logger.info(
`일괄 삭제 완료: 성공 ${deletedCount}개, 실패 ${skippedCount}`
);
return { deletedCount, skippedCount, errors };
}
/** /**
* *
*/ */
@ -1486,11 +1645,23 @@ export class ScreenManagementService {
}; };
} }
// 🔥 최신 inputType 정보 조회 (table_type_columns에서)
const inputTypeMap = await this.getLatestInputTypes(componentLayouts, companyCode);
const components: ComponentData[] = componentLayouts.map((layout) => { const components: ComponentData[] = componentLayouts.map((layout) => {
const properties = layout.properties as any; const properties = layout.properties as any;
// 🔥 최신 inputType으로 widgetType 및 componentType 업데이트
const tableName = properties?.tableName;
const columnName = properties?.columnName;
const latestTypeInfo = tableName && columnName
? inputTypeMap.get(`${tableName}.${columnName}`)
: null;
const component = { const component = {
id: layout.component_id, id: layout.component_id,
type: layout.component_type as any, // 🔥 최신 componentType이 있으면 type 덮어쓰기
type: latestTypeInfo?.componentType || layout.component_type as any,
position: { position: {
x: layout.position_x, x: layout.position_x,
y: layout.position_y, y: layout.position_y,
@ -1499,6 +1670,17 @@ export class ScreenManagementService {
size: { width: layout.width, height: layout.height }, size: { width: layout.width, height: layout.height },
parentId: layout.parent_id, parentId: layout.parent_id,
...properties, ...properties,
// 🔥 최신 inputType이 있으면 widgetType, componentType 덮어쓰기
...(latestTypeInfo && {
widgetType: latestTypeInfo.inputType,
inputType: latestTypeInfo.inputType,
componentType: latestTypeInfo.componentType,
componentConfig: {
...properties?.componentConfig,
type: latestTypeInfo.componentType,
inputType: latestTypeInfo.inputType,
},
}),
}; };
console.log(`로드된 컴포넌트:`, { console.log(`로드된 컴포넌트:`, {
@ -1508,6 +1690,9 @@ export class ScreenManagementService {
size: component.size, size: component.size,
parentId: component.parentId, parentId: component.parentId,
title: (component as any).title, title: (component as any).title,
widgetType: (component as any).widgetType,
componentType: (component as any).componentType,
latestTypeInfo,
}); });
return component; return component;
@ -1527,6 +1712,112 @@ export class ScreenManagementService {
}; };
} }
/**
* ID
* ( webTypeMapping.ts와 )
*/
private getComponentIdFromInputType(inputType: string): string {
const mapping: Record<string, string> = {
// 텍스트 입력
text: "text-input",
email: "text-input",
password: "text-input",
tel: "text-input",
// 숫자 입력
number: "number-input",
decimal: "number-input",
// 날짜/시간
date: "date-input",
datetime: "date-input",
time: "date-input",
// 텍스트 영역
textarea: "textarea-basic",
// 선택
select: "select-basic",
dropdown: "select-basic",
// 체크박스/라디오
checkbox: "checkbox-basic",
radio: "radio-basic",
boolean: "toggle-switch",
// 파일
file: "file-upload",
// 이미지
image: "image-widget",
img: "image-widget",
picture: "image-widget",
photo: "image-widget",
// 버튼
button: "button-primary",
// 기타
label: "text-display",
code: "select-basic",
entity: "entity-search-input", // 엔티티는 entity-search-input 사용
category: "select-basic",
};
return mapping[inputType] || "text-input";
}
/**
* inputType
* @param layouts -
* @param companyCode -
* @returns Map<"tableName.columnName", { inputType, componentType }>
*/
private async getLatestInputTypes(
layouts: any[],
companyCode: string
): Promise<Map<string, { inputType: string; componentType: string }>> {
const inputTypeMap = new Map<string, { inputType: string; componentType: string }>();
// tableName과 columnName이 있는 컴포넌트들의 고유 조합 추출
const tableColumnPairs = new Set<string>();
for (const layout of layouts) {
const properties = layout.properties as any;
if (properties?.tableName && properties?.columnName) {
tableColumnPairs.add(`${properties.tableName}|${properties.columnName}`);
}
}
if (tableColumnPairs.size === 0) {
return inputTypeMap;
}
// 각 테이블-컬럼 조합에 대해 최신 inputType 조회
const pairs = Array.from(tableColumnPairs).map(pair => {
const [tableName, columnName] = pair.split('|');
return { tableName, columnName };
});
// 배치 쿼리로 한 번에 조회
const placeholders = pairs.map((_, i) => `($${i * 2 + 1}, $${i * 2 + 2})`).join(', ');
const params = pairs.flatMap(p => [p.tableName, p.columnName]);
try {
const results = await query<{ table_name: string; column_name: string; input_type: string }>(
`SELECT table_name, column_name, input_type
FROM table_type_columns
WHERE (table_name, column_name) IN (${placeholders})
AND company_code = $${params.length + 1}`,
[...params, companyCode]
);
for (const row of results) {
const componentType = this.getComponentIdFromInputType(row.input_type);
inputTypeMap.set(`${row.table_name}.${row.column_name}`, {
inputType: row.input_type,
componentType: componentType,
});
}
console.log(`최신 inputType 조회 완료: ${results.length}`);
} catch (error) {
console.warn(`최신 inputType 조회 실패 (무시됨):`, error);
}
return inputTypeMap;
}
// ======================================== // ========================================
// 템플릿 관리 // 템플릿 관리
// ======================================== // ========================================
@ -2016,37 +2307,40 @@ export class ScreenManagementService {
// Advisory lock 획득 (다른 트랜잭션이 같은 회사 코드를 생성하는 동안 대기) // Advisory lock 획득 (다른 트랜잭션이 같은 회사 코드를 생성하는 동안 대기)
await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]); await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]);
// 해당 회사의 기존 화면 코드들 조회 // 해당 회사의 기존 화면 코드들 조회 (모든 화면 - 삭제된 코드도 재사용 방지)
// LIMIT 제거하고 숫자 추출하여 최대값 찾기
const existingScreens = await client.query<{ screen_code: string }>( const existingScreens = await client.query<{ screen_code: string }>(
`SELECT screen_code FROM screen_definitions `SELECT screen_code FROM screen_definitions
WHERE company_code = $1 AND screen_code LIKE $2 WHERE screen_code LIKE $1
ORDER BY screen_code DESC ORDER BY screen_code DESC`,
LIMIT 10`, [`${companyCode}_%`]
[companyCode, `${companyCode}%`]
); );
// 회사 코드 뒤의 숫자 부분 추출하여 최대값 찾기 // 회사 코드 뒤의 숫자 부분 추출하여 최대값 찾기
let maxNumber = 0; let maxNumber = 0;
const pattern = new RegExp( const pattern = new RegExp(
`^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:_)?(\\d+)$` `^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}_(\\d+)$`
); );
console.log(`🔍 화면 코드 생성 - 조회된 화면 수: ${existingScreens.rows.length}`);
console.log(`🔍 패턴: ${pattern}`);
for (const screen of existingScreens.rows) { for (const screen of existingScreens.rows) {
const match = screen.screen_code.match(pattern); const match = screen.screen_code.match(pattern);
if (match) { if (match) {
const number = parseInt(match[1], 10); const number = parseInt(match[1], 10);
console.log(`🔍 매칭: ${screen.screen_code} → 숫자: ${number}`);
if (number > maxNumber) { if (number > maxNumber) {
maxNumber = number; maxNumber = number;
} }
} }
} }
// 다음 순번으로 화면 코드 생성 (3자리 패딩) // 다음 순번으로 화면 코드 생성
const nextNumber = maxNumber + 1; const nextNumber = maxNumber + 1;
const paddedNumber = nextNumber.toString().padStart(3, "0"); // 숫자가 3자리 이상이면 패딩 없이, 아니면 3자리 패딩
const newCode = `${companyCode}_${nextNumber}`;
const newCode = `${companyCode}_${paddedNumber}`; console.log(`🔢 화면 코드 생성: ${companyCode}${newCode} (maxNumber: ${maxNumber}, nextNumber: ${nextNumber})`);
console.log(`🔢 화면 코드 생성: ${companyCode}${newCode} (maxNumber: ${maxNumber})`);
return newCode; return newCode;
// Advisory lock은 트랜잭션 종료 시 자동으로 해제됨 // Advisory lock은 트랜잭션 종료 시 자동으로 해제됨
@ -2066,30 +2360,33 @@ export class ScreenManagementService {
const lockId = Buffer.from(companyCode).reduce((acc, byte) => acc + byte, 0); const lockId = Buffer.from(companyCode).reduce((acc, byte) => acc + byte, 0);
await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]); await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]);
// 현재 최대 번호 조회 // 현재 최대 번호 조회 (숫자 추출 후 정렬)
const existingScreens = await client.query<{ screen_code: string }>( // 패턴: COMPANY_CODE_XXX 또는 COMPANY_CODEXXX
`SELECT screen_code FROM screen_definitions const existingScreens = await client.query<{ screen_code: string; num: number }>(
WHERE company_code = $1 AND screen_code LIKE $2 `SELECT screen_code,
ORDER BY screen_code DESC COALESCE(
LIMIT 10`, NULLIF(
[companyCode, `${companyCode}%`] regexp_replace(screen_code, $2, '\\1'),
screen_code
)::integer,
0
) as num
FROM screen_definitions
WHERE company_code = $1
AND screen_code ~ $2
AND deleted_date IS NULL
ORDER BY num DESC
LIMIT 1`,
[companyCode, `^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[_]?(\\d+)$`]
); );
let maxNumber = 0; let maxNumber = 0;
const pattern = new RegExp( if (existingScreens.rows.length > 0 && existingScreens.rows[0].num) {
`^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:_)?(\\d+)$` maxNumber = existingScreens.rows[0].num;
);
for (const screen of existingScreens.rows) {
const match = screen.screen_code.match(pattern);
if (match) {
const number = parseInt(match[1], 10);
if (number > maxNumber) {
maxNumber = number;
}
}
} }
console.log(`🔢 현재 최대 화면 코드 번호: ${companyCode}${maxNumber}`);
// count개의 코드를 순차적으로 생성 // count개의 코드를 순차적으로 생성
const codes: string[] = []; const codes: string[] = [];
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
@ -2300,10 +2597,10 @@ export class ScreenManagementService {
// 없으면 원본과 같은 회사에 복사 // 없으면 원본과 같은 회사에 복사
const targetCompanyCode = copyData.targetCompanyCode || sourceScreen.company_code; const targetCompanyCode = copyData.targetCompanyCode || sourceScreen.company_code;
// 3. 화면 코드 중복 체크 (대상 회사 기준) // 3. 화면 코드 중복 체크 (대상 회사 기준, 삭제되지 않은 화면만)
const existingScreens = await client.query<any>( const existingScreens = await client.query<any>(
`SELECT screen_id FROM screen_definitions `SELECT screen_id FROM screen_definitions
WHERE screen_code = $1 AND company_code = $2 WHERE screen_code = $1 AND company_code = $2 AND deleted_date IS NULL
LIMIT 1`, LIMIT 1`,
[copyData.screenCode, targetCompanyCode] [copyData.screenCode, targetCompanyCode]
); );

View File

@ -79,6 +79,82 @@ class TableCategoryValueService {
} }
} }
/**
* (Select )
* .
*/
async getAllCategoryColumns(
companyCode: string
): Promise<CategoryColumn[]> {
try {
logger.info("전체 카테고리 컬럼 목록 조회", { companyCode });
const pool = getPool();
let query: string;
let params: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 카테고리 컬럼 조회 (중복 제거)
query = `
SELECT
tc.table_name AS "tableName",
tc.column_name AS "columnName",
tc.column_name AS "columnLabel",
COALESCE(cv_count.cnt, 0) AS "valueCount"
FROM (
SELECT DISTINCT table_name, column_name, MIN(display_order) as display_order
FROM table_type_columns
WHERE input_type = 'category'
GROUP BY table_name, column_name
) tc
LEFT JOIN (
SELECT table_name, column_name, COUNT(*) as cnt
FROM table_column_category_values
WHERE is_active = true
GROUP BY table_name, column_name
) cv_count ON tc.table_name = cv_count.table_name AND tc.column_name = cv_count.column_name
ORDER BY tc.table_name, tc.display_order, tc.column_name
`;
params = [];
} else {
// 일반 회사: 자신의 카테고리 값만 카운트 (중복 제거)
query = `
SELECT
tc.table_name AS "tableName",
tc.column_name AS "columnName",
tc.column_name AS "columnLabel",
COALESCE(cv_count.cnt, 0) AS "valueCount"
FROM (
SELECT DISTINCT table_name, column_name, MIN(display_order) as display_order
FROM table_type_columns
WHERE input_type = 'category'
GROUP BY table_name, column_name
) tc
LEFT JOIN (
SELECT table_name, column_name, COUNT(*) as cnt
FROM table_column_category_values
WHERE is_active = true AND company_code = $1
GROUP BY table_name, column_name
) cv_count ON tc.table_name = cv_count.table_name AND tc.column_name = cv_count.column_name
ORDER BY tc.table_name, tc.display_order, tc.column_name
`;
params = [companyCode];
}
const result = await pool.query(query, params);
logger.info(`전체 카테고리 컬럼 ${result.rows.length}개 조회 완료`, {
companyCode,
});
return result.rows;
} catch (error: any) {
logger.error(`전체 카테고리 컬럼 조회 실패: ${error.message}`);
throw error;
}
}
/** /**
* ( ) * ( )
* *
@ -111,71 +187,68 @@ class TableCategoryValueService {
logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids }); logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids });
} }
// 2. 카테고리 값 조회 (형제 메뉴 포함) // 2. 카테고리 값 조회 (메뉴 스코프 또는 형제 메뉴 포함)
let query: string; let query: string;
let params: any[]; let params: any[];
const baseSelect = `
SELECT
value_id AS "valueId",
table_name AS "tableName",
column_name AS "columnName",
value_code AS "valueCode",
value_label AS "valueLabel",
value_order AS "valueOrder",
parent_value_id AS "parentValueId",
depth,
description,
color,
icon,
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
menu_objid AS "menuObjid",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
`;
if (companyCode === "*") { if (companyCode === "*") {
// 최고 관리자: 모든 카테고리 값 조회 // 최고 관리자: menuObjid가 있으면 해당 메뉴(및 형제 메뉴)의 값만 조회
// 메뉴 스코프 제거: 같은 테이블.컬럼 조합은 모든 메뉴에서 공유 if (menuObjid && siblingObjids.length > 0) {
query = ` query = baseSelect + ` AND menu_objid = ANY($3::numeric[])`;
SELECT params = [tableName, columnName, siblingObjids];
value_id AS "valueId", logger.info("최고 관리자 메뉴 스코프 카테고리 값 조회", { menuObjid, siblingObjids });
table_name AS "tableName", } else if (menuObjid) {
column_name AS "columnName", query = baseSelect + ` AND menu_objid = $3`;
value_code AS "valueCode", params = [tableName, columnName, menuObjid];
value_label AS "valueLabel", logger.info("최고 관리자 단일 메뉴 카테고리 값 조회", { menuObjid });
value_order AS "valueOrder", } else {
parent_value_id AS "parentValueId", // menuObjid 없으면 모든 값 조회 (중복 가능)
depth, query = baseSelect;
description, params = [tableName, columnName];
color, logger.info("최고 관리자 전체 카테고리 값 조회 (menuObjid 없음)");
icon, }
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
menu_objid AS "menuObjid",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
`;
params = [tableName, columnName];
logger.info("최고 관리자 카테고리 값 조회");
} else { } else {
// 일반 회사: 자신의 카테고리 값만 조회 // 일반 회사: 자신의 회사 + menuObjid로 필터링
// 메뉴 스코프 제거: 같은 테이블.컬럼 조합은 모든 메뉴에서 공유 if (menuObjid && siblingObjids.length > 0) {
query = ` query = baseSelect + ` AND company_code = $3 AND menu_objid = ANY($4::numeric[])`;
SELECT params = [tableName, columnName, companyCode, siblingObjids];
value_id AS "valueId", logger.info("회사별 메뉴 스코프 카테고리 값 조회", { companyCode, menuObjid, siblingObjids });
table_name AS "tableName", } else if (menuObjid) {
column_name AS "columnName", query = baseSelect + ` AND company_code = $3 AND menu_objid = $4`;
value_code AS "valueCode", params = [tableName, columnName, companyCode, menuObjid];
value_label AS "valueLabel", logger.info("회사별 단일 메뉴 카테고리 값 조회", { companyCode, menuObjid });
value_order AS "valueOrder", } else {
parent_value_id AS "parentValueId", // menuObjid 없으면 회사 전체 조회 (중복 가능하지만 회사별로 제한)
depth, query = baseSelect + ` AND company_code = $3`;
description, params = [tableName, columnName, companyCode];
color, logger.info("회사별 카테고리 값 조회 (menuObjid 없음)", { companyCode });
icon, }
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
menu_objid AS "menuObjid",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
AND company_code = $3
`;
params = [tableName, columnName, companyCode];
logger.info("회사별 카테고리 값 조회", { companyCode });
} }
if (!includeInactive) { if (!includeInactive) {
@ -1258,6 +1331,284 @@ class TableCategoryValueService {
throw error; throw error;
} }
} }
/**
*
*
* @param valueCodes -
* @param companyCode -
* @returns { [code]: label }
*/
async getCategoryLabelsByCodes(
valueCodes: string[],
companyCode: string
): Promise<Record<string, string>> {
try {
if (!valueCodes || valueCodes.length === 0) {
return {};
}
logger.info("카테고리 코드로 라벨 조회", { valueCodes, companyCode });
const pool = getPool();
// 동적으로 파라미터 플레이스홀더 생성
const placeholders = valueCodes.map((_, i) => `$${i + 1}`).join(", ");
let query: string;
let params: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 카테고리 값 조회
query = `
SELECT value_code, value_label
FROM table_column_category_values
WHERE value_code IN (${placeholders})
AND is_active = true
`;
params = valueCodes;
} else {
// 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회
query = `
SELECT value_code, value_label
FROM table_column_category_values
WHERE value_code IN (${placeholders})
AND is_active = true
AND (company_code = $${valueCodes.length + 1} OR company_code = '*')
`;
params = [...valueCodes, companyCode];
}
const result = await pool.query(query, params);
// { [code]: label } 형태로 변환
const labels: Record<string, string> = {};
for (const row of result.rows) {
labels[row.value_code] = row.value_label;
}
logger.info(`카테고리 라벨 ${Object.keys(labels).length}개 조회 완료`, { companyCode });
return labels;
} catch (error: any) {
logger.error(`카테고리 코드로 라벨 조회 실패: ${error.message}`, { error });
throw error;
}
}
/**
* ( )
*
*
*
* @param tableName -
* @param companyCode -
* @returns { [columnName]: { [label]: code } }
*/
async getCategoryLabelToCodeMapping(
tableName: string,
companyCode: string
): Promise<Record<string, Record<string, string>>> {
try {
logger.info("카테고리 라벨→코드 매핑 조회", { tableName, companyCode });
const pool = getPool();
// 1. 해당 테이블의 카테고리 타입 컬럼 조회
const categoryColumnsQuery = `
SELECT column_name
FROM table_type_columns
WHERE table_name = $1
AND input_type = 'category'
`;
const categoryColumnsResult = await pool.query(categoryColumnsQuery, [tableName]);
if (categoryColumnsResult.rows.length === 0) {
logger.info("카테고리 타입 컬럼 없음", { tableName });
return {};
}
const categoryColumns = categoryColumnsResult.rows.map(row => row.column_name);
logger.info(`카테고리 컬럼 ${categoryColumns.length}개 발견`, { categoryColumns });
// 2. 각 카테고리 컬럼의 라벨→코드 매핑 조회
const result: Record<string, Record<string, string>> = {};
for (const columnName of categoryColumns) {
let query: string;
let params: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 카테고리 값 조회
query = `
SELECT value_code, value_label
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
AND is_active = true
`;
params = [tableName, columnName];
} else {
// 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회
query = `
SELECT value_code, value_label
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
AND is_active = true
AND (company_code = $3 OR company_code = '*')
`;
params = [tableName, columnName, companyCode];
}
const valuesResult = await pool.query(query, params);
// { [label]: code } 형태로 변환
const labelToCodeMap: Record<string, string> = {};
for (const row of valuesResult.rows) {
// 라벨을 소문자로 변환하여 대소문자 구분 없이 매핑
labelToCodeMap[row.value_label] = row.value_code;
// 소문자 키도 추가 (대소문자 무시 검색용)
labelToCodeMap[row.value_label.toLowerCase()] = row.value_code;
}
if (Object.keys(labelToCodeMap).length > 0) {
result[columnName] = labelToCodeMap;
logger.info(`컬럼 ${columnName}의 라벨→코드 매핑 ${valuesResult.rows.length}개 조회`);
}
}
logger.info(`카테고리 라벨→코드 매핑 조회 완료`, {
tableName,
columnCount: Object.keys(result).length
});
return result;
} catch (error: any) {
logger.error(`카테고리 라벨→코드 매핑 조회 실패: ${error.message}`, { error });
throw error;
}
}
/**
*
*
* DB
*
* @param tableName -
* @param companyCode -
* @param data -
* @returns
*/
async convertCategoryLabelsToCodesForData(
tableName: string,
companyCode: string,
data: Record<string, any>
): Promise<{ convertedData: Record<string, any>; conversions: Array<{ column: string; label: string; code: string }> }> {
try {
// 라벨→코드 매핑 조회
const labelToCodeMapping = await this.getCategoryLabelToCodeMapping(tableName, companyCode);
if (Object.keys(labelToCodeMapping).length === 0) {
// 카테고리 컬럼 없음
return { convertedData: data, conversions: [] };
}
const convertedData = { ...data };
const conversions: Array<{ column: string; label: string; code: string }> = [];
for (const [columnName, labelCodeMap] of Object.entries(labelToCodeMapping)) {
const value = data[columnName];
if (value !== undefined && value !== null && value !== "") {
const stringValue = String(value).trim();
// 다중 값 확인 (쉼표로 구분된 경우)
if (stringValue.includes(",")) {
// 다중 카테고리 값 처리
const labels = stringValue.split(",").map(s => s.trim()).filter(s => s !== "");
const convertedCodes: string[] = [];
let allConverted = true;
for (const label of labels) {
// 정확한 라벨 매칭 시도
let matchedCode = labelCodeMap[label];
// 대소문자 무시 매칭
if (!matchedCode) {
matchedCode = labelCodeMap[label.toLowerCase()];
}
if (matchedCode) {
convertedCodes.push(matchedCode);
conversions.push({
column: columnName,
label: label,
code: matchedCode,
});
logger.info(`카테고리 라벨→코드 변환 (다중): ${columnName} "${label}" → "${matchedCode}"`);
} else {
// 이미 코드값인지 확인
const isAlreadyCode = Object.values(labelCodeMap).includes(label);
if (isAlreadyCode) {
// 이미 코드값이면 그대로 사용
convertedCodes.push(label);
} else {
// 라벨도 코드도 아니면 원래 값 유지
convertedCodes.push(label);
allConverted = false;
logger.warn(`카테고리 값 매핑 없음 (다중): ${columnName} = "${label}" (라벨도 코드도 아님)`);
}
}
}
// 변환된 코드들을 쉼표로 합쳐서 저장
convertedData[columnName] = convertedCodes.join(",");
logger.info(`다중 카테고리 변환 완료: ${columnName} "${stringValue}" → "${convertedData[columnName]}"`);
} else {
// 단일 값 처리
// 정확한 라벨 매칭 시도
let matchedCode = labelCodeMap[stringValue];
// 대소문자 무시 매칭
if (!matchedCode) {
matchedCode = labelCodeMap[stringValue.toLowerCase()];
}
if (matchedCode) {
// 라벨 값을 코드 값으로 변환
convertedData[columnName] = matchedCode;
conversions.push({
column: columnName,
label: stringValue,
code: matchedCode,
});
logger.info(`카테고리 라벨→코드 변환: ${columnName} "${stringValue}" → "${matchedCode}"`);
} else {
// 이미 코드값인지 확인 (역방향 확인)
const isAlreadyCode = Object.values(labelCodeMap).includes(stringValue);
if (!isAlreadyCode) {
logger.warn(`카테고리 값 매핑 없음: ${columnName} = "${stringValue}" (라벨도 코드도 아님)`);
}
// 변환 없이 원래 값 유지
}
}
}
}
logger.info(`카테고리 라벨→코드 변환 완료`, {
tableName,
conversionCount: conversions.length,
conversions,
});
return { convertedData, conversions };
} catch (error: any) {
logger.error(`카테고리 라벨→코드 변환 실패: ${error.message}`, { error });
// 실패 시 원본 데이터 반환
return { convertedData: data, conversions: [] };
}
}
} }
export default new TableCategoryValueService(); export default new TableCategoryValueService();

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,784 @@
/**
*
* CRUD
*/
import { query, transaction } from "../database/db";
import { logger } from "../utils/logger";
// 비용 유형 타입
export type CostType = "purchase" | "installation" | "repair" | "maintenance" | "disposal" | "other";
// 세금계산서 타입 정의
export interface TaxInvoice {
id: string;
company_code: string;
invoice_number: string;
invoice_type: "sales" | "purchase"; // 매출/매입
invoice_status: "draft" | "issued" | "sent" | "cancelled";
// 공급자 정보
supplier_business_no: string;
supplier_name: string;
supplier_ceo_name: string;
supplier_address: string;
supplier_business_type: string;
supplier_business_item: string;
// 공급받는자 정보
buyer_business_no: string;
buyer_name: string;
buyer_ceo_name: string;
buyer_address: string;
buyer_email: string;
// 금액 정보
supply_amount: number;
tax_amount: number;
total_amount: number;
// 날짜 정보
invoice_date: string;
issue_date: string | null;
// 기타
remarks: string;
order_id: string | null;
customer_id: string | null;
// 첨부파일 (JSON 배열로 저장)
attachments: TaxInvoiceAttachment[] | null;
// 비용 유형 (구매/설치/수리/유지보수/폐기/기타)
cost_type: CostType | null;
created_date: string;
updated_date: string;
writer: string;
}
// 첨부파일 타입
export interface TaxInvoiceAttachment {
id: string;
file_name: string;
file_path: string;
file_size: number;
file_type: string;
uploaded_at: string;
uploaded_by: string;
}
export interface TaxInvoiceItem {
id: string;
tax_invoice_id: string;
company_code: string;
item_seq: number;
item_date: string;
item_name: string;
item_spec: string;
quantity: number;
unit_price: number;
supply_amount: number;
tax_amount: number;
remarks: string;
}
export interface CreateTaxInvoiceDto {
invoice_type: "sales" | "purchase";
supplier_business_no?: string;
supplier_name?: string;
supplier_ceo_name?: string;
supplier_address?: string;
supplier_business_type?: string;
supplier_business_item?: string;
buyer_business_no?: string;
buyer_name?: string;
buyer_ceo_name?: string;
buyer_address?: string;
buyer_email?: string;
supply_amount: number;
tax_amount: number;
total_amount: number;
invoice_date: string;
remarks?: string;
order_id?: string;
customer_id?: string;
items?: CreateTaxInvoiceItemDto[];
attachments?: TaxInvoiceAttachment[]; // 첨부파일
cost_type?: CostType; // 비용 유형
}
export interface CreateTaxInvoiceItemDto {
item_date?: string;
item_name: string;
item_spec?: string;
quantity: number;
unit_price: number;
supply_amount: number;
tax_amount: number;
remarks?: string;
}
export interface TaxInvoiceListParams {
page?: number;
pageSize?: number;
invoice_type?: "sales" | "purchase";
invoice_status?: string;
start_date?: string;
end_date?: string;
search?: string;
buyer_name?: string;
cost_type?: CostType; // 비용 유형 필터
}
export class TaxInvoiceService {
/**
*
* 형식: YYYYMM-NNNNN (: 202512-00001)
*/
static async generateInvoiceNumber(companyCode: string): Promise<string> {
const now = new Date();
const yearMonth = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}`;
const prefix = `${yearMonth}-`;
// 해당 월의 마지막 번호 조회
const result = await query<{ max_num: string }>(
`SELECT invoice_number as max_num
FROM tax_invoice
WHERE company_code = $1
AND invoice_number LIKE $2
ORDER BY invoice_number DESC
LIMIT 1`,
[companyCode, `${prefix}%`]
);
let nextNum = 1;
if (result.length > 0 && result[0].max_num) {
const lastNum = parseInt(result[0].max_num.split("-")[1], 10);
nextNum = lastNum + 1;
}
return `${prefix}${String(nextNum).padStart(5, "0")}`;
}
/**
*
*/
static async getList(
companyCode: string,
params: TaxInvoiceListParams
): Promise<{ data: TaxInvoice[]; total: number; page: number; pageSize: number }> {
const {
page = 1,
pageSize = 20,
invoice_type,
invoice_status,
start_date,
end_date,
search,
buyer_name,
cost_type,
} = params;
const offset = (page - 1) * pageSize;
const conditions: string[] = ["company_code = $1"];
const values: any[] = [companyCode];
let paramIndex = 2;
if (invoice_type) {
conditions.push(`invoice_type = $${paramIndex}`);
values.push(invoice_type);
paramIndex++;
}
if (invoice_status) {
conditions.push(`invoice_status = $${paramIndex}`);
values.push(invoice_status);
paramIndex++;
}
if (start_date) {
conditions.push(`invoice_date >= $${paramIndex}`);
values.push(start_date);
paramIndex++;
}
if (end_date) {
conditions.push(`invoice_date <= $${paramIndex}`);
values.push(end_date);
paramIndex++;
}
if (search) {
conditions.push(
`(invoice_number ILIKE $${paramIndex} OR buyer_name ILIKE $${paramIndex} OR supplier_name ILIKE $${paramIndex})`
);
values.push(`%${search}%`);
paramIndex++;
}
if (buyer_name) {
conditions.push(`buyer_name ILIKE $${paramIndex}`);
values.push(`%${buyer_name}%`);
paramIndex++;
}
if (cost_type) {
conditions.push(`cost_type = $${paramIndex}`);
values.push(cost_type);
paramIndex++;
}
const whereClause = conditions.join(" AND ");
// 전체 개수 조회
const countResult = await query<{ count: string }>(
`SELECT COUNT(*) as count FROM tax_invoice WHERE ${whereClause}`,
values
);
const total = parseInt(countResult[0]?.count || "0", 10);
// 데이터 조회
values.push(pageSize, offset);
const data = await query<TaxInvoice>(
`SELECT * FROM tax_invoice
WHERE ${whereClause}
ORDER BY created_date DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
values
);
return { data, total, page, pageSize };
}
/**
* ( )
*/
static async getById(
id: string,
companyCode: string
): Promise<{ invoice: TaxInvoice; items: TaxInvoiceItem[] } | null> {
const invoiceResult = await query<TaxInvoice>(
`SELECT * FROM tax_invoice WHERE id = $1 AND company_code = $2`,
[id, companyCode]
);
if (invoiceResult.length === 0) {
return null;
}
const items = await query<TaxInvoiceItem>(
`SELECT * FROM tax_invoice_item
WHERE tax_invoice_id = $1 AND company_code = $2
ORDER BY item_seq`,
[id, companyCode]
);
return { invoice: invoiceResult[0], items };
}
/**
*
*/
static async create(
data: CreateTaxInvoiceDto,
companyCode: string,
userId: string
): Promise<TaxInvoice> {
return await transaction(async (client) => {
// 세금계산서 번호 채번
const invoiceNumber = await this.generateInvoiceNumber(companyCode);
// 세금계산서 생성
const invoiceResult = await client.query(
`INSERT INTO tax_invoice (
company_code, invoice_number, invoice_type, invoice_status,
supplier_business_no, supplier_name, supplier_ceo_name, supplier_address,
supplier_business_type, supplier_business_item,
buyer_business_no, buyer_name, buyer_ceo_name, buyer_address, buyer_email,
supply_amount, tax_amount, total_amount, invoice_date,
remarks, order_id, customer_id, attachments, cost_type, writer
) VALUES (
$1, $2, $3, 'draft',
$4, $5, $6, $7, $8, $9,
$10, $11, $12, $13, $14,
$15, $16, $17, $18,
$19, $20, $21, $22, $23, $24
) RETURNING *`,
[
companyCode,
invoiceNumber,
data.invoice_type,
data.supplier_business_no || null,
data.supplier_name || null,
data.supplier_ceo_name || null,
data.supplier_address || null,
data.supplier_business_type || null,
data.supplier_business_item || null,
data.buyer_business_no || null,
data.buyer_name || null,
data.buyer_ceo_name || null,
data.buyer_address || null,
data.buyer_email || null,
data.supply_amount,
data.tax_amount,
data.total_amount,
data.invoice_date,
data.remarks || null,
data.order_id || null,
data.customer_id || null,
data.attachments ? JSON.stringify(data.attachments) : null,
data.cost_type || null,
userId,
]
);
const invoice = invoiceResult.rows[0];
// 품목 생성
if (data.items && data.items.length > 0) {
for (let i = 0; i < data.items.length; i++) {
const item = data.items[i];
await client.query(
`INSERT INTO tax_invoice_item (
tax_invoice_id, company_code, item_seq,
item_date, item_name, item_spec, quantity, unit_price,
supply_amount, tax_amount, remarks
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
[
invoice.id,
companyCode,
i + 1,
item.item_date || null,
item.item_name,
item.item_spec || null,
item.quantity,
item.unit_price,
item.supply_amount,
item.tax_amount,
item.remarks || null,
]
);
}
}
logger.info("세금계산서 생성 완료", {
invoiceId: invoice.id,
invoiceNumber,
companyCode,
userId,
});
return invoice;
});
}
/**
*
*/
static async update(
id: string,
data: Partial<CreateTaxInvoiceDto>,
companyCode: string,
userId: string
): Promise<TaxInvoice | null> {
return await transaction(async (client) => {
// 기존 세금계산서 확인
const existing = await client.query(
`SELECT * FROM tax_invoice WHERE id = $1 AND company_code = $2`,
[id, companyCode]
);
if (existing.rows.length === 0) {
return null;
}
// 발행된 세금계산서는 수정 불가
if (existing.rows[0].invoice_status !== "draft") {
throw new Error("발행된 세금계산서는 수정할 수 없습니다.");
}
// 세금계산서 수정
const updateResult = await client.query(
`UPDATE tax_invoice SET
supplier_business_no = COALESCE($3, supplier_business_no),
supplier_name = COALESCE($4, supplier_name),
supplier_ceo_name = COALESCE($5, supplier_ceo_name),
supplier_address = COALESCE($6, supplier_address),
supplier_business_type = COALESCE($7, supplier_business_type),
supplier_business_item = COALESCE($8, supplier_business_item),
buyer_business_no = COALESCE($9, buyer_business_no),
buyer_name = COALESCE($10, buyer_name),
buyer_ceo_name = COALESCE($11, buyer_ceo_name),
buyer_address = COALESCE($12, buyer_address),
buyer_email = COALESCE($13, buyer_email),
supply_amount = COALESCE($14, supply_amount),
tax_amount = COALESCE($15, tax_amount),
total_amount = COALESCE($16, total_amount),
invoice_date = COALESCE($17, invoice_date),
remarks = COALESCE($18, remarks),
attachments = $19,
cost_type = COALESCE($20, cost_type),
updated_date = NOW()
WHERE id = $1 AND company_code = $2
RETURNING *`,
[
id,
companyCode,
data.supplier_business_no,
data.supplier_name,
data.supplier_ceo_name,
data.supplier_address,
data.supplier_business_type,
data.supplier_business_item,
data.buyer_business_no,
data.buyer_name,
data.buyer_ceo_name,
data.buyer_address,
data.buyer_email,
data.supply_amount,
data.tax_amount,
data.total_amount,
data.invoice_date,
data.remarks,
data.attachments ? JSON.stringify(data.attachments) : null,
data.cost_type,
]
);
// 품목 업데이트 (기존 삭제 후 재생성)
if (data.items) {
await client.query(
`DELETE FROM tax_invoice_item WHERE tax_invoice_id = $1 AND company_code = $2`,
[id, companyCode]
);
for (let i = 0; i < data.items.length; i++) {
const item = data.items[i];
await client.query(
`INSERT INTO tax_invoice_item (
tax_invoice_id, company_code, item_seq,
item_date, item_name, item_spec, quantity, unit_price,
supply_amount, tax_amount, remarks
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
[
id,
companyCode,
i + 1,
item.item_date || null,
item.item_name,
item.item_spec || null,
item.quantity,
item.unit_price,
item.supply_amount,
item.tax_amount,
item.remarks || null,
]
);
}
}
logger.info("세금계산서 수정 완료", { invoiceId: id, companyCode, userId });
return updateResult.rows[0];
});
}
/**
*
*/
static async delete(id: string, companyCode: string, userId: string): Promise<boolean> {
return await transaction(async (client) => {
// 기존 세금계산서 확인
const existing = await client.query(
`SELECT * FROM tax_invoice WHERE id = $1 AND company_code = $2`,
[id, companyCode]
);
if (existing.rows.length === 0) {
return false;
}
// 발행된 세금계산서는 삭제 불가
if (existing.rows[0].invoice_status !== "draft") {
throw new Error("발행된 세금계산서는 삭제할 수 없습니다.");
}
// 품목 삭제
await client.query(
`DELETE FROM tax_invoice_item WHERE tax_invoice_id = $1 AND company_code = $2`,
[id, companyCode]
);
// 세금계산서 삭제
await client.query(`DELETE FROM tax_invoice WHERE id = $1 AND company_code = $2`, [
id,
companyCode,
]);
logger.info("세금계산서 삭제 완료", { invoiceId: id, companyCode, userId });
return true;
});
}
/**
* ( )
*/
static async issue(id: string, companyCode: string, userId: string): Promise<TaxInvoice | null> {
const result = await query<TaxInvoice>(
`UPDATE tax_invoice SET
invoice_status = 'issued',
issue_date = NOW(),
updated_date = NOW()
WHERE id = $1 AND company_code = $2 AND invoice_status = 'draft'
RETURNING *`,
[id, companyCode]
);
if (result.length === 0) {
return null;
}
logger.info("세금계산서 발행 완료", { invoiceId: id, companyCode, userId });
return result[0];
}
/**
*
*/
static async cancel(
id: string,
companyCode: string,
userId: string,
reason?: string
): Promise<TaxInvoice | null> {
const result = await query<TaxInvoice>(
`UPDATE tax_invoice SET
invoice_status = 'cancelled',
remarks = CASE WHEN $3 IS NOT NULL THEN remarks || ' [취소사유: ' || $3 || ']' ELSE remarks END,
updated_date = NOW()
WHERE id = $1 AND company_code = $2 AND invoice_status IN ('draft', 'issued')
RETURNING *`,
[id, companyCode, reason || null]
);
if (result.length === 0) {
return null;
}
logger.info("세금계산서 취소 완료", { invoiceId: id, companyCode, userId, reason });
return result[0];
}
/**
*
*/
static async getMonthlyStats(
companyCode: string,
year: number,
month: number
): Promise<{
sales: { count: number; supply_amount: number; tax_amount: number; total_amount: number };
purchase: { count: number; supply_amount: number; tax_amount: number; total_amount: number };
}> {
const startDate = `${year}-${String(month).padStart(2, "0")}-01`;
const endDate = new Date(year, month, 0).toISOString().split("T")[0]; // 해당 월 마지막 날
const result = await query<{
invoice_type: string;
count: string;
supply_amount: string;
tax_amount: string;
total_amount: string;
}>(
`SELECT
invoice_type,
COUNT(*) as count,
COALESCE(SUM(supply_amount), 0) as supply_amount,
COALESCE(SUM(tax_amount), 0) as tax_amount,
COALESCE(SUM(total_amount), 0) as total_amount
FROM tax_invoice
WHERE company_code = $1
AND invoice_date >= $2
AND invoice_date <= $3
AND invoice_status != 'cancelled'
GROUP BY invoice_type`,
[companyCode, startDate, endDate]
);
const stats = {
sales: { count: 0, supply_amount: 0, tax_amount: 0, total_amount: 0 },
purchase: { count: 0, supply_amount: 0, tax_amount: 0, total_amount: 0 },
};
for (const row of result) {
const type = row.invoice_type as "sales" | "purchase";
stats[type] = {
count: parseInt(row.count, 10),
supply_amount: parseFloat(row.supply_amount),
tax_amount: parseFloat(row.tax_amount),
total_amount: parseFloat(row.total_amount),
};
}
return stats;
}
/**
*
*/
static async getCostTypeStats(
companyCode: string,
year?: number,
month?: number
): Promise<{
by_cost_type: Array<{
cost_type: CostType | null;
count: number;
supply_amount: number;
tax_amount: number;
total_amount: number;
}>;
by_month: Array<{
year_month: string;
cost_type: CostType | null;
count: number;
total_amount: number;
}>;
summary: {
total_count: number;
total_amount: number;
purchase_amount: number;
installation_amount: number;
repair_amount: number;
maintenance_amount: number;
disposal_amount: number;
other_amount: number;
};
}> {
const conditions: string[] = ["company_code = $1", "invoice_status != 'cancelled'"];
const values: any[] = [companyCode];
let paramIndex = 2;
// 연도/월 필터
if (year && month) {
const startDate = `${year}-${String(month).padStart(2, "0")}-01`;
const endDate = new Date(year, month, 0).toISOString().split("T")[0];
conditions.push(`invoice_date >= $${paramIndex} AND invoice_date <= $${paramIndex + 1}`);
values.push(startDate, endDate);
paramIndex += 2;
} else if (year) {
conditions.push(`EXTRACT(YEAR FROM invoice_date) = $${paramIndex}`);
values.push(year);
paramIndex++;
}
const whereClause = conditions.join(" AND ");
// 비용 유형별 집계
const byCostType = await query<{
cost_type: CostType | null;
count: string;
supply_amount: string;
tax_amount: string;
total_amount: string;
}>(
`SELECT
cost_type,
COUNT(*) as count,
COALESCE(SUM(supply_amount), 0) as supply_amount,
COALESCE(SUM(tax_amount), 0) as tax_amount,
COALESCE(SUM(total_amount), 0) as total_amount
FROM tax_invoice
WHERE ${whereClause}
GROUP BY cost_type
ORDER BY total_amount DESC`,
values
);
// 월별 비용 유형 집계
const byMonth = await query<{
year_month: string;
cost_type: CostType | null;
count: string;
total_amount: string;
}>(
`SELECT
TO_CHAR(invoice_date, 'YYYY-MM') as year_month,
cost_type,
COUNT(*) as count,
COALESCE(SUM(total_amount), 0) as total_amount
FROM tax_invoice
WHERE ${whereClause}
GROUP BY TO_CHAR(invoice_date, 'YYYY-MM'), cost_type
ORDER BY year_month DESC, cost_type`,
values
);
// 전체 요약
const summaryResult = await query<{
total_count: string;
total_amount: string;
purchase_amount: string;
installation_amount: string;
repair_amount: string;
maintenance_amount: string;
disposal_amount: string;
other_amount: string;
}>(
`SELECT
COUNT(*) as total_count,
COALESCE(SUM(total_amount), 0) as total_amount,
COALESCE(SUM(CASE WHEN cost_type = 'purchase' THEN total_amount ELSE 0 END), 0) as purchase_amount,
COALESCE(SUM(CASE WHEN cost_type = 'installation' THEN total_amount ELSE 0 END), 0) as installation_amount,
COALESCE(SUM(CASE WHEN cost_type = 'repair' THEN total_amount ELSE 0 END), 0) as repair_amount,
COALESCE(SUM(CASE WHEN cost_type = 'maintenance' THEN total_amount ELSE 0 END), 0) as maintenance_amount,
COALESCE(SUM(CASE WHEN cost_type = 'disposal' THEN total_amount ELSE 0 END), 0) as disposal_amount,
COALESCE(SUM(CASE WHEN cost_type = 'other' THEN total_amount ELSE 0 END), 0) as other_amount
FROM tax_invoice
WHERE ${whereClause}`,
values
);
const summary = summaryResult[0] || {
total_count: "0",
total_amount: "0",
purchase_amount: "0",
installation_amount: "0",
repair_amount: "0",
maintenance_amount: "0",
disposal_amount: "0",
other_amount: "0",
};
return {
by_cost_type: byCostType.map((row) => ({
cost_type: row.cost_type,
count: parseInt(row.count, 10),
supply_amount: parseFloat(row.supply_amount),
tax_amount: parseFloat(row.tax_amount),
total_amount: parseFloat(row.total_amount),
})),
by_month: byMonth.map((row) => ({
year_month: row.year_month,
cost_type: row.cost_type,
count: parseInt(row.count, 10),
total_amount: parseFloat(row.total_amount),
})),
summary: {
total_count: parseInt(summary.total_count, 10),
total_amount: parseFloat(summary.total_amount),
purchase_amount: parseFloat(summary.purchase_amount),
installation_amount: parseFloat(summary.installation_amount),
repair_amount: parseFloat(summary.repair_amount),
maintenance_amount: parseFloat(summary.maintenance_amount),
disposal_amount: parseFloat(summary.disposal_amount),
other_amount: parseFloat(summary.other_amount),
},
};
}
}

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