Remove outdated documents related to the approval system and WACE system analysis
- Deleted the following files as they are no longer relevant to the current project structure: - 결재 시스템 구현 현황 - 결재 시스템 v2 사용 가이드 - WACE 시스템 문제점 분석 및 개선 계획 - Agent Pipeline 한계점 분석 - AI 기반 화면 자동 생성 시스템 설계서 - WACE ERP Backend - 분석 문서 인덱스 These deletions help streamline the documentation and remove obsolete information, ensuring that only current and relevant resources are maintained.
This commit is contained in:
@@ -0,0 +1,620 @@
|
||||
# POP 작업진행 관리 설계서
|
||||
|
||||
> 작성일: 2026-03-13
|
||||
> 목적: POP 시스템에서 작업지시 기반으로 라우팅/작업기준정보를 조회하고, 공정별 작업 진행 상태를 관리하는 구조 설계
|
||||
|
||||
---
|
||||
|
||||
## 1. 핵심 설계 원칙
|
||||
|
||||
**작업지시에 라우팅ID, 작업기준정보ID 등을 별도 컬럼으로 넣지 않는다.**
|
||||
|
||||
- 작업지시(`work_instruction`)에는 `item_id`(품목 ID)만 있으면 충분
|
||||
- 품목 → 라우팅 → 작업기준정보는 마스터 데이터 체인으로 조회
|
||||
- 작업 진행 상태만 별도 테이블에서 관리
|
||||
|
||||
---
|
||||
|
||||
## 2. 기존 테이블 구조 (마스터 데이터)
|
||||
|
||||
### 2-1. ER 다이어그램
|
||||
|
||||
> GitHub / VSCode Mermaid 플러그인에서 렌더링됩니다.
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
%% ========== 마스터 데이터 (변경 없음) ==========
|
||||
|
||||
item_info {
|
||||
varchar id PK "UUID"
|
||||
varchar item_number "품번"
|
||||
varchar item_name "품명"
|
||||
varchar company_code "회사코드"
|
||||
}
|
||||
|
||||
item_routing_version {
|
||||
varchar id PK "UUID"
|
||||
varchar item_code "품번 (= item_info.item_number)"
|
||||
varchar version_name "버전명"
|
||||
boolean is_default "기본버전 여부"
|
||||
varchar company_code "회사코드"
|
||||
}
|
||||
|
||||
item_routing_detail {
|
||||
varchar id PK "UUID"
|
||||
varchar routing_version_id FK "→ item_routing_version.id"
|
||||
varchar seq_no "공정순서 10,20,30..."
|
||||
varchar process_code FK "→ process_mng.process_code"
|
||||
varchar is_required "필수/선택"
|
||||
varchar is_fixed_order "고정/선택"
|
||||
varchar standard_time "표준시간(분)"
|
||||
varchar company_code "회사코드"
|
||||
}
|
||||
|
||||
process_mng {
|
||||
varchar id PK "UUID"
|
||||
varchar process_code "공정코드"
|
||||
varchar process_name "공정명"
|
||||
varchar process_type "공정유형"
|
||||
varchar company_code "회사코드"
|
||||
}
|
||||
|
||||
process_work_item {
|
||||
varchar id PK "UUID"
|
||||
varchar routing_detail_id FK "→ item_routing_detail.id"
|
||||
varchar work_phase "PRE / IN / POST"
|
||||
varchar title "작업항목명"
|
||||
varchar is_required "Y/N"
|
||||
int sort_order "정렬순서"
|
||||
varchar company_code "회사코드"
|
||||
}
|
||||
|
||||
process_work_item_detail {
|
||||
varchar id PK "UUID"
|
||||
varchar work_item_id FK "→ process_work_item.id"
|
||||
varchar detail_type "check/inspect/input/procedure/info"
|
||||
varchar content "내용"
|
||||
varchar input_type "입력타입"
|
||||
varchar inspection_code "검사코드"
|
||||
varchar unit "단위"
|
||||
varchar lower_limit "하한값"
|
||||
varchar upper_limit "상한값"
|
||||
varchar company_code "회사코드"
|
||||
}
|
||||
|
||||
%% ========== 트랜잭션 데이터 ==========
|
||||
|
||||
work_instruction {
|
||||
varchar id PK "UUID"
|
||||
varchar work_instruction_no "작업지시번호"
|
||||
varchar item_id FK "→ item_info.id ★핵심★"
|
||||
varchar status "waiting/in_progress/completed/cancelled"
|
||||
varchar qty "지시수량"
|
||||
varchar completed_qty "완성수량"
|
||||
varchar worker "작업자"
|
||||
varchar company_code "회사코드"
|
||||
}
|
||||
|
||||
work_order_process {
|
||||
varchar id PK "UUID"
|
||||
varchar wo_id FK "→ work_instruction.id"
|
||||
varchar routing_detail_id FK "→ item_routing_detail.id ★추가★"
|
||||
varchar seq_no "공정순서"
|
||||
varchar process_code "공정코드"
|
||||
varchar process_name "공정명"
|
||||
varchar status "waiting/in_progress/completed/skipped"
|
||||
varchar plan_qty "계획수량"
|
||||
varchar good_qty "양품수량"
|
||||
varchar defect_qty "불량수량"
|
||||
timestamp started_at "시작시간"
|
||||
timestamp completed_at "완료시간"
|
||||
varchar company_code "회사코드"
|
||||
}
|
||||
|
||||
work_order_work_item {
|
||||
varchar id PK "UUID ★신규★"
|
||||
varchar company_code "회사코드"
|
||||
varchar work_order_process_id FK "→ work_order_process.id"
|
||||
varchar work_item_id FK "→ process_work_item.id"
|
||||
varchar work_phase "PRE/IN/POST"
|
||||
varchar status "pending/completed/skipped/failed"
|
||||
varchar completed_by "완료자"
|
||||
timestamp completed_at "완료시간"
|
||||
}
|
||||
|
||||
work_order_work_item_result {
|
||||
varchar id PK "UUID ★신규★"
|
||||
varchar company_code "회사코드"
|
||||
varchar work_order_work_item_id FK "→ work_order_work_item.id"
|
||||
varchar work_item_detail_id FK "→ process_work_item_detail.id"
|
||||
varchar detail_type "check/inspect/input/procedure"
|
||||
varchar result_value "결과값"
|
||||
varchar is_passed "Y/N/null"
|
||||
varchar recorded_by "기록자"
|
||||
timestamp recorded_at "기록시간"
|
||||
}
|
||||
|
||||
%% ========== 관계 ==========
|
||||
|
||||
%% 마스터 체인: 품목 → 라우팅 → 작업기준정보
|
||||
item_info ||--o{ item_routing_version : "item_number = item_code"
|
||||
item_routing_version ||--o{ item_routing_detail : "id = routing_version_id"
|
||||
item_routing_detail }o--|| process_mng : "process_code"
|
||||
item_routing_detail ||--o{ process_work_item : "id = routing_detail_id"
|
||||
process_work_item ||--o{ process_work_item_detail : "id = work_item_id"
|
||||
|
||||
%% 트랜잭션: 작업지시 → 공정진행 → 작업기준정보 진행
|
||||
work_instruction }o--|| item_info : "item_id = id"
|
||||
work_instruction ||--o{ work_order_process : "id = wo_id"
|
||||
work_order_process }o--|| item_routing_detail : "routing_detail_id = id"
|
||||
work_order_process ||--o{ work_order_work_item : "id = work_order_process_id"
|
||||
work_order_work_item }o--|| process_work_item : "work_item_id = id"
|
||||
work_order_work_item ||--o{ work_order_work_item_result : "id = work_order_work_item_id"
|
||||
work_order_work_item_result }o--|| process_work_item_detail : "work_item_detail_id = id"
|
||||
```
|
||||
|
||||
### 2-1-1. 관계 요약 (텍스트)
|
||||
|
||||
```
|
||||
[마스터 데이터 체인 - 조회용, 변경 없음]
|
||||
|
||||
item_info ─── 1:N ───→ item_routing_version ─── 1:N ───→ item_routing_detail
|
||||
(품목) item_number (라우팅 버전) routing_ (공정별 상세)
|
||||
= item_code version_id
|
||||
│
|
||||
process_mng ◄───┘ process_code (공정 마스터)
|
||||
│
|
||||
├── 1:N ───→ process_work_item ─── 1:N ───→ process_work_item_detail
|
||||
│ (작업기준정보) (작업기준정보 상세)
|
||||
│ routing_detail_id work_item_id
|
||||
│
|
||||
[트랜잭션 데이터 - 상태 관리] │
|
||||
│
|
||||
work_instruction ─── 1:N ───→ work_order_process ─┘ routing_detail_id (★추가★)
|
||||
(작업지시) wo_id (공정별 진행)
|
||||
item_id → item_info │
|
||||
├── 1:N ───→ work_order_work_item ─── 1:N ───→ work_order_work_item_result
|
||||
│ (작업기준정보 진행) (상세 결과값)
|
||||
│ work_order_process_id work_order_work_item_id
|
||||
│ work_item_id → process_work_item work_item_detail_id → process_work_item_detail
|
||||
│ ★신규 테이블★ ★신규 테이블★
|
||||
```
|
||||
|
||||
### 2-2. 마스터 테이블 상세
|
||||
|
||||
#### item_info (품목 마스터)
|
||||
| 컬럼 | 설명 | 비고 |
|
||||
|------|------|------|
|
||||
| id | PK (UUID) | |
|
||||
| item_number | 품번 | item_routing_version.item_code와 매칭 |
|
||||
| item_name | 품명 | |
|
||||
| company_code | 회사코드 | 멀티테넌시 |
|
||||
|
||||
#### item_routing_version (라우팅 버전)
|
||||
| 컬럼 | 설명 | 비고 |
|
||||
|------|------|------|
|
||||
| id | PK (UUID) | |
|
||||
| item_code | 품번 | item_info.item_number와 매칭 |
|
||||
| version_name | 버전명 | 예: "기본 라우팅", "버전2" |
|
||||
| is_default | 기본 버전 여부 | true/false, 기본 버전을 사용 |
|
||||
| company_code | 회사코드 | |
|
||||
|
||||
#### item_routing_detail (라우팅 상세 - 공정별)
|
||||
| 컬럼 | 설명 | 비고 |
|
||||
|------|------|------|
|
||||
| id | PK (UUID) | |
|
||||
| routing_version_id | FK → item_routing_version.id | |
|
||||
| seq_no | 공정 순서 | 10, 20, 30... |
|
||||
| process_code | 공정코드 | FK → process_mng.process_code |
|
||||
| is_required | 필수/선택 | "필수" / "선택" |
|
||||
| is_fixed_order | 순서고정 여부 | "고정" / "선택" |
|
||||
| work_type | 작업유형 | |
|
||||
| standard_time | 표준시간(분) | |
|
||||
| outsource_supplier | 외주업체 | |
|
||||
| company_code | 회사코드 | |
|
||||
|
||||
#### process_work_item (작업기준정보)
|
||||
| 컬럼 | 설명 | 비고 |
|
||||
|------|------|------|
|
||||
| id | PK (UUID) | |
|
||||
| routing_detail_id | FK → item_routing_detail.id | |
|
||||
| work_phase | 작업단계 | PRE(작업전) / IN(작업중) / POST(작업후) |
|
||||
| title | 작업항목명 | 예: "장비 체크", "소재 준비" |
|
||||
| is_required | 필수여부 | Y/N |
|
||||
| sort_order | 정렬순서 | |
|
||||
| description | 설명 | |
|
||||
| company_code | 회사코드 | |
|
||||
|
||||
#### process_work_item_detail (작업기준정보 상세)
|
||||
| 컬럼 | 설명 | 비고 |
|
||||
|------|------|------|
|
||||
| id | PK (UUID) | |
|
||||
| work_item_id | FK → process_work_item.id | |
|
||||
| detail_type | 상세유형 | check(체크) / inspect(검사) / input(입력) / procedure(절차) / info(정보) |
|
||||
| content | 내용 | 예: "소음검사", "치수검사" |
|
||||
| input_type | 입력타입 | select, text 등 |
|
||||
| inspection_code | 검사코드 | |
|
||||
| inspection_method | 검사방법 | |
|
||||
| unit | 단위 | |
|
||||
| lower_limit | 하한값 | |
|
||||
| upper_limit | 상한값 | |
|
||||
| is_required | 필수여부 | Y/N |
|
||||
| sort_order | 정렬순서 | |
|
||||
| company_code | 회사코드 | |
|
||||
|
||||
---
|
||||
|
||||
## 3. 작업 진행 테이블 (트랜잭션 데이터)
|
||||
|
||||
### 3-1. work_instruction (작업지시) - 기존 테이블
|
||||
|
||||
| 컬럼 | 설명 | 비고 |
|
||||
|------|------|------|
|
||||
| id | PK (UUID) | |
|
||||
| work_instruction_no | 작업지시번호 | 예: WO-2026-001 |
|
||||
| **item_id** | **FK → item_info.id** | **이것만으로 라우팅/작업기준정보 전부 조회 가능** |
|
||||
| status | 작업지시 상태 | waiting / in_progress / completed / cancelled |
|
||||
| qty | 지시수량 | |
|
||||
| completed_qty | 완성수량 | |
|
||||
| work_team | 작업팀 | |
|
||||
| worker | 작업자 | |
|
||||
| equipment_id | 설비 | |
|
||||
| start_date | 시작일 | |
|
||||
| end_date | 종료일 | |
|
||||
| remark | 비고 | |
|
||||
| company_code | 회사코드 | |
|
||||
|
||||
> **routing 컬럼**: 현재 존재하지만 사용하지 않음 (null). 라우팅 버전을 지정하고 싶으면 이 컬럼에 `item_routing_version.id`를 넣어 특정 버전을 지정할 수 있음. 없으면 `is_default=true` 버전 자동 사용.
|
||||
|
||||
### 3-2. work_order_process (공정별 진행) - 기존 테이블, 변경 필요
|
||||
|
||||
작업지시가 생성될 때, 해당 품목의 라우팅 공정을 복사해서 이 테이블에 INSERT.
|
||||
|
||||
| 컬럼 | 설명 | 비고 |
|
||||
|------|------|------|
|
||||
| id | PK (UUID) | |
|
||||
| wo_id | FK → work_instruction.id | 작업지시 참조 |
|
||||
| **routing_detail_id** | **FK → item_routing_detail.id** | **추가 필요 - 라우팅 상세 참조** |
|
||||
| seq_no | 공정 순서 | 라우팅에서 복사 |
|
||||
| process_code | 공정코드 | 라우팅에서 복사 |
|
||||
| process_name | 공정명 | 라우팅에서 복사 (비정규화, 조회 편의) |
|
||||
| is_required | 필수여부 | 라우팅에서 복사 |
|
||||
| is_fixed_order | 순서고정 | 라우팅에서 복사 |
|
||||
| standard_time | 표준시간 | 라우팅에서 복사 |
|
||||
| **status** | **공정 상태** | **waiting / in_progress / completed / skipped** |
|
||||
| plan_qty | 계획수량 | |
|
||||
| input_qty | 투입수량 | |
|
||||
| good_qty | 양품수량 | |
|
||||
| defect_qty | 불량수량 | |
|
||||
| equipment_code | 사용설비 | |
|
||||
| accepted_by | 접수자 | |
|
||||
| accepted_at | 접수시간 | |
|
||||
| started_at | 시작시간 | |
|
||||
| completed_at | 완료시간 | |
|
||||
| remark | 비고 | |
|
||||
| company_code | 회사코드 | |
|
||||
|
||||
### 3-3. work_order_work_item (작업기준정보별 진행) - 신규 테이블
|
||||
|
||||
POP에서 작업자가 각 작업기준정보 항목을 체크/입력할 때 사용.
|
||||
|
||||
| 컬럼 | 설명 | 비고 |
|
||||
|------|------|------|
|
||||
| id | PK (UUID) | gen_random_uuid() |
|
||||
| company_code | 회사코드 | 멀티테넌시 |
|
||||
| work_order_process_id | FK → work_order_process.id | 어떤 작업지시의 어떤 공정인지 |
|
||||
| work_item_id | FK → process_work_item.id | 어떤 작업기준정보인지 |
|
||||
| work_phase | 작업단계 | PRE / IN / POST (마스터에서 복사) |
|
||||
| status | 완료상태 | pending / completed / skipped / failed |
|
||||
| completed_by | 완료자 | 작업자 ID |
|
||||
| completed_at | 완료시간 | |
|
||||
| created_date | 생성일 | |
|
||||
| updated_date | 수정일 | |
|
||||
| writer | 작성자 | |
|
||||
|
||||
### 3-4. work_order_work_item_result (작업기준정보 상세 결과) - 신규 테이블
|
||||
|
||||
작업기준정보의 상세 항목(체크, 검사, 입력 등)에 대한 실제 결과값 저장.
|
||||
|
||||
| 컬럼 | 설명 | 비고 |
|
||||
|------|------|------|
|
||||
| id | PK (UUID) | gen_random_uuid() |
|
||||
| company_code | 회사코드 | 멀티테넌시 |
|
||||
| work_order_work_item_id | FK → work_order_work_item.id | |
|
||||
| work_item_detail_id | FK → process_work_item_detail.id | 어떤 상세항목인지 |
|
||||
| detail_type | 상세유형 | check / inspect / input / procedure (마스터에서 복사) |
|
||||
| result_value | 결과값 | 체크: "Y"/"N", 검사: 측정값, 입력: 입력값 |
|
||||
| is_passed | 합격여부 | Y / N / null(해당없음) |
|
||||
| remark | 비고 | 불합격 사유 등 |
|
||||
| recorded_by | 기록자 | |
|
||||
| recorded_at | 기록시간 | |
|
||||
| created_date | 생성일 | |
|
||||
| updated_date | 수정일 | |
|
||||
| writer | 작성자 | |
|
||||
|
||||
---
|
||||
|
||||
## 4. POP 데이터 플로우
|
||||
|
||||
### 4-1. 작업지시 등록 시 (ERP 측)
|
||||
|
||||
```
|
||||
[작업지시 생성]
|
||||
│
|
||||
├── 1. work_instruction INSERT (item_id, qty, status='waiting' 등)
|
||||
│
|
||||
├── 2. item_id → item_info.item_number 조회
|
||||
│
|
||||
├── 3. item_number → item_routing_version 조회 (is_default=true 또는 지정 버전)
|
||||
│
|
||||
├── 4. routing_version_id → item_routing_detail 조회 (공정 목록)
|
||||
│
|
||||
└── 5. 각 공정별로 work_order_process INSERT
|
||||
├── wo_id = work_instruction.id
|
||||
├── routing_detail_id = item_routing_detail.id ← 핵심!
|
||||
├── seq_no, process_code, process_name 복사
|
||||
├── status = 'waiting'
|
||||
└── plan_qty = work_instruction.qty
|
||||
```
|
||||
|
||||
### 4-2. POP 작업 조회 시
|
||||
|
||||
```
|
||||
[POP 화면: 작업지시 선택]
|
||||
│
|
||||
├── 1. work_instruction 목록 조회 (status = 'waiting' or 'in_progress')
|
||||
│
|
||||
├── 2. 선택한 작업지시의 공정 목록 조회
|
||||
│ SELECT wop.*, pm.process_name
|
||||
│ FROM work_order_process wop
|
||||
│ LEFT JOIN process_mng pm ON wop.process_code = pm.process_code
|
||||
│ WHERE wop.wo_id = {작업지시ID}
|
||||
│ ORDER BY CAST(wop.seq_no AS int)
|
||||
│
|
||||
└── 3. 선택한 공정의 작업기준정보 조회 (마스터 데이터 참조)
|
||||
SELECT pwi.*, pwid.*
|
||||
FROM process_work_item pwi
|
||||
LEFT JOIN process_work_item_detail pwid ON pwi.id = pwid.work_item_id
|
||||
WHERE pwi.routing_detail_id = {work_order_process.routing_detail_id}
|
||||
ORDER BY pwi.work_phase, pwi.sort_order, pwid.sort_order
|
||||
```
|
||||
|
||||
### 4-3. POP 작업 실행 시
|
||||
|
||||
```
|
||||
[작업자가 공정 시작]
|
||||
│
|
||||
├── 1. work_order_process UPDATE
|
||||
│ SET status = 'in_progress', started_at = NOW(), accepted_by = {작업자}
|
||||
│
|
||||
├── 2. work_instruction UPDATE (첫 공정 시작 시)
|
||||
│ SET status = 'in_progress'
|
||||
│
|
||||
├── 3. 작업기준정보 항목별 체크/입력 시
|
||||
│ ├── work_order_work_item UPSERT (항목별 상태)
|
||||
│ └── work_order_work_item_result UPSERT (상세 결과값)
|
||||
│
|
||||
└── 4. 공정 완료 시
|
||||
├── work_order_process UPDATE
|
||||
│ SET status = 'completed', completed_at = NOW(),
|
||||
│ good_qty = {양품}, defect_qty = {불량}
|
||||
│
|
||||
└── (모든 공정 완료 시)
|
||||
work_instruction UPDATE
|
||||
SET status = 'completed', completed_qty = {최종양품}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 핵심 조회 쿼리
|
||||
|
||||
### 5-1. 작업지시 → 전체 공정 + 작업기준정보 한방 조회
|
||||
|
||||
```sql
|
||||
-- 작업지시의 공정별 진행 현황 + 작업기준정보
|
||||
SELECT
|
||||
wi.work_instruction_no,
|
||||
wi.qty,
|
||||
wi.status as wi_status,
|
||||
ii.item_number,
|
||||
ii.item_name,
|
||||
wop.id as process_id,
|
||||
wop.seq_no,
|
||||
wop.process_code,
|
||||
wop.process_name,
|
||||
wop.status as process_status,
|
||||
wop.plan_qty,
|
||||
wop.good_qty,
|
||||
wop.defect_qty,
|
||||
wop.started_at,
|
||||
wop.completed_at,
|
||||
wop.routing_detail_id,
|
||||
-- 작업기준정보는 routing_detail_id로 마스터 조회
|
||||
pwi.id as work_item_id,
|
||||
pwi.work_phase,
|
||||
pwi.title as work_item_title,
|
||||
pwi.is_required as work_item_required
|
||||
FROM work_instruction wi
|
||||
JOIN item_info ii ON wi.item_id = ii.id
|
||||
JOIN work_order_process wop ON wi.id = wop.wo_id
|
||||
LEFT JOIN process_work_item pwi ON wop.routing_detail_id = pwi.routing_detail_id
|
||||
WHERE wi.id = $1
|
||||
AND wi.company_code = $2
|
||||
ORDER BY CAST(wop.seq_no AS int), pwi.work_phase, pwi.sort_order;
|
||||
```
|
||||
|
||||
### 5-2. 특정 공정의 작업기준정보 + 진행 상태 조회
|
||||
|
||||
```sql
|
||||
-- POP에서 특정 공정 선택 시: 마스터 + 진행 상태 조인
|
||||
SELECT
|
||||
pwi.id as work_item_id,
|
||||
pwi.work_phase,
|
||||
pwi.title,
|
||||
pwi.is_required,
|
||||
pwid.id as detail_id,
|
||||
pwid.detail_type,
|
||||
pwid.content,
|
||||
pwid.input_type,
|
||||
pwid.inspection_code,
|
||||
pwid.inspection_method,
|
||||
pwid.unit,
|
||||
pwid.lower_limit,
|
||||
pwid.upper_limit,
|
||||
-- 진행 상태
|
||||
wowi.status as item_status,
|
||||
wowi.completed_by,
|
||||
wowi.completed_at,
|
||||
-- 결과값
|
||||
wowir.result_value,
|
||||
wowir.is_passed,
|
||||
wowir.remark as result_remark
|
||||
FROM process_work_item pwi
|
||||
LEFT JOIN process_work_item_detail pwid
|
||||
ON pwi.id = pwid.work_item_id
|
||||
LEFT JOIN work_order_work_item wowi
|
||||
ON wowi.work_item_id = pwi.id
|
||||
AND wowi.work_order_process_id = $1 -- work_order_process.id
|
||||
LEFT JOIN work_order_work_item_result wowir
|
||||
ON wowir.work_order_work_item_id = wowi.id
|
||||
AND wowir.work_item_detail_id = pwid.id
|
||||
WHERE pwi.routing_detail_id = $2 -- work_order_process.routing_detail_id
|
||||
ORDER BY
|
||||
CASE pwi.work_phase WHEN 'PRE' THEN 1 WHEN 'IN' THEN 2 WHEN 'POST' THEN 3 END,
|
||||
pwi.sort_order,
|
||||
pwid.sort_order;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 변경사항 요약
|
||||
|
||||
### 6-1. 기존 테이블 변경
|
||||
|
||||
| 테이블 | 변경내용 |
|
||||
|--------|---------|
|
||||
| work_order_process | `routing_detail_id VARCHAR(500)` 컬럼 추가 |
|
||||
|
||||
### 6-2. 신규 테이블
|
||||
|
||||
| 테이블 | 용도 |
|
||||
|--------|------|
|
||||
| work_order_work_item | 작업지시 공정별 작업기준정보 진행 상태 |
|
||||
| work_order_work_item_result | 작업기준정보 상세 항목의 실제 결과값 |
|
||||
|
||||
### 6-3. 건드리지 않는 것
|
||||
|
||||
| 테이블 | 이유 |
|
||||
|--------|------|
|
||||
| work_instruction | item_id만 있으면 충분. 라우팅/작업기준정보 ID 추가 불필요 |
|
||||
| item_routing_version | 마스터 데이터, 변경 없음 |
|
||||
| item_routing_detail | 마스터 데이터, 변경 없음 |
|
||||
| process_work_item | 마스터 데이터, 변경 없음 |
|
||||
| process_work_item_detail | 마스터 데이터, 변경 없음 |
|
||||
|
||||
---
|
||||
|
||||
## 7. DDL (마이그레이션 SQL)
|
||||
|
||||
```sql
|
||||
-- 1. work_order_process에 routing_detail_id 추가
|
||||
ALTER TABLE work_order_process
|
||||
ADD COLUMN IF NOT EXISTS routing_detail_id VARCHAR(500);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_wop_routing_detail_id
|
||||
ON work_order_process(routing_detail_id);
|
||||
|
||||
-- 2. 작업기준정보별 진행 상태 테이블
|
||||
CREATE TABLE IF NOT EXISTS work_order_work_item (
|
||||
id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
company_code VARCHAR(500) NOT NULL,
|
||||
work_order_process_id VARCHAR(500) NOT NULL,
|
||||
work_item_id VARCHAR(500) NOT NULL,
|
||||
work_phase VARCHAR(500),
|
||||
status VARCHAR(500) DEFAULT 'pending',
|
||||
completed_by VARCHAR(500),
|
||||
completed_at TIMESTAMP,
|
||||
created_date TIMESTAMP DEFAULT NOW(),
|
||||
updated_date TIMESTAMP DEFAULT NOW(),
|
||||
writer VARCHAR(500)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_wowi_process_id ON work_order_work_item(work_order_process_id);
|
||||
CREATE INDEX idx_wowi_work_item_id ON work_order_work_item(work_item_id);
|
||||
CREATE INDEX idx_wowi_company_code ON work_order_work_item(company_code);
|
||||
|
||||
-- 3. 작업기준정보 상세 결과 테이블
|
||||
CREATE TABLE IF NOT EXISTS work_order_work_item_result (
|
||||
id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
company_code VARCHAR(500) NOT NULL,
|
||||
work_order_work_item_id VARCHAR(500) NOT NULL,
|
||||
work_item_detail_id VARCHAR(500) NOT NULL,
|
||||
detail_type VARCHAR(500),
|
||||
result_value VARCHAR(500),
|
||||
is_passed VARCHAR(500),
|
||||
remark TEXT,
|
||||
recorded_by VARCHAR(500),
|
||||
recorded_at TIMESTAMP DEFAULT NOW(),
|
||||
created_date TIMESTAMP DEFAULT NOW(),
|
||||
updated_date TIMESTAMP DEFAULT NOW(),
|
||||
writer VARCHAR(500)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_wowir_work_order_work_item_id ON work_order_work_item_result(work_order_work_item_id);
|
||||
CREATE INDEX idx_wowir_detail_id ON work_order_work_item_result(work_item_detail_id);
|
||||
CREATE INDEX idx_wowir_company_code ON work_order_work_item_result(company_code);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 상태값 정의
|
||||
|
||||
### work_instruction.status (작업지시 상태)
|
||||
| 값 | 의미 |
|
||||
|----|------|
|
||||
| waiting | 대기 |
|
||||
| in_progress | 진행중 |
|
||||
| completed | 완료 |
|
||||
| cancelled | 취소 |
|
||||
|
||||
### work_order_process.status (공정 상태)
|
||||
| 값 | 의미 |
|
||||
|----|------|
|
||||
| waiting | 대기 (아직 시작 안 함) |
|
||||
| in_progress | 진행중 (작업자가 시작) |
|
||||
| completed | 완료 |
|
||||
| skipped | 건너뜀 (선택 공정인 경우) |
|
||||
|
||||
### work_order_work_item.status (작업기준정보 항목 상태)
|
||||
| 값 | 의미 |
|
||||
|----|------|
|
||||
| pending | 미완료 |
|
||||
| completed | 완료 |
|
||||
| skipped | 건너뜀 |
|
||||
| failed | 실패 (검사 불합격 등) |
|
||||
|
||||
### work_order_work_item_result.is_passed (검사 합격여부)
|
||||
| 값 | 의미 |
|
||||
|----|------|
|
||||
| Y | 합격 |
|
||||
| N | 불합격 |
|
||||
| null | 해당없음 (체크/입력 항목) |
|
||||
|
||||
---
|
||||
|
||||
## 9. 설계 의도 요약
|
||||
|
||||
1. **마스터와 트랜잭션 분리**: 라우팅/작업기준정보는 마스터(템플릿), 실제 진행은 트랜잭션 테이블에서 관리
|
||||
2. **조회 경로**: `work_instruction.item_id` → `item_info.item_number` → `item_routing_version` → `item_routing_detail` → `process_work_item` → `process_work_item_detail`
|
||||
3. **진행 경로**: `work_order_process.routing_detail_id`로 마스터 작업기준정보를 참조하되, 실제 진행/결과는 `work_order_work_item` + `work_order_work_item_result`에 저장
|
||||
4. **중복 저장 최소화**: 작업지시에 공정/작업기준정보 ID를 넣지 않음. 품목만 있으면 전부 파생 조회 가능
|
||||
5. **work_order_process**: 작업지시 생성 시 라우팅 공정을 복사하는 이유는 진행 중 수량/상태/시간 등 트랜잭션 데이터를 기록해야 하기 때문 (마스터가 변경되어도 이미 발행된 작업지시의 공정은 유지)
|
||||
|
||||
---
|
||||
|
||||
## 10. 주의사항
|
||||
|
||||
- `work_order_process`에 공정 정보를 복사(스냅샷)하는 이유: 마스터 라우팅이 나중에 변경되어도 이미 진행 중인 작업지시의 공정 구성은 영향받지 않아야 함
|
||||
- `routing_detail_id`는 "이 공정이 어떤 마스터 라우팅에서 왔는지" 추적용. 작업기준정보 조회 키로 사용
|
||||
- POP에서 작업기준정보를 표시할 때는 항상 마스터(`process_work_item`)를 조회하고, 결과만 트랜잭션 테이블에 저장
|
||||
- 모든 테이블에 `company_code` 필수 (멀티테넌시)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
||||
# formData 콘솔 로그 수동 테스트 가이드
|
||||
|
||||
## 테스트 시나리오
|
||||
|
||||
1. http://localhost:9771/screens/1599?menuObjid=1762422235300 접속
|
||||
2. 로그인 필요 시: `topseal_admin` / `1234`
|
||||
3. 5초 대기 (페이지 로드)
|
||||
4. 첫 번째 탭 "공정 마스터" 확인
|
||||
5. 좌측 패널에서 **P003** 행 클릭
|
||||
6. 우측 패널에서 **추가** 버튼 클릭
|
||||
7. 모달에서 설비(equipment) 드롭다운에서 항목 선택
|
||||
8. **저장** 버튼 클릭 **전** 콘솔 스냅샷 확인
|
||||
9. **저장** 버튼 클릭 **후** 콘솔 로그 확인
|
||||
|
||||
## 확인할 콘솔 로그
|
||||
|
||||
### 1. ADD 모드 formData 설정 (ScreenModal)
|
||||
|
||||
```
|
||||
🔵 [ScreenModal] ADD모드 formData 설정: {...}
|
||||
```
|
||||
|
||||
- **위치**: `frontend/components/common/ScreenModal.tsx` 358행
|
||||
- **의미**: 모달이 ADD 모드로 열릴 때 부모 데이터(splitPanelParentData)로 설정된 초기 formData
|
||||
- **확인**: `process_code`가 P003으로 포함되어 있는지
|
||||
|
||||
### 2. formData 변경 시 (ScreenModal)
|
||||
|
||||
```
|
||||
🟡 [ScreenModal] onFormDataChange: equipment_code → E001 | formData keys: [...] | process_code: P003
|
||||
```
|
||||
|
||||
- **위치**: `frontend/components/common/ScreenModal.tsx` 1184행
|
||||
- **의미**: 사용자가 설비를 선택할 때마다 발생
|
||||
- **확인**: `process_code`가 유지되는지, `equipment_code`가 추가되는지
|
||||
|
||||
### 3. 저장 시 formData 디버그 (ButtonPrimary)
|
||||
|
||||
```
|
||||
🔴 [ButtonPrimary] 저장 시 formData 디버그: {
|
||||
propsFormDataKeys: [...],
|
||||
screenContextFormDataKeys: [...],
|
||||
effectiveFormDataKeys: [...],
|
||||
process_code: "P003",
|
||||
equipment_code: "E001",
|
||||
fullData: "{...}"
|
||||
}
|
||||
```
|
||||
|
||||
- **위치**: `frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx` 1110행
|
||||
- **의미**: 저장 버튼 클릭 시 실제로 API에 전달되는 formData
|
||||
- **확인**: `process_code`, `equipment_code`가 모두 포함되어 있는지
|
||||
|
||||
## 추가로 확인할 로그
|
||||
|
||||
- `process_code` 포함 로그
|
||||
- `splitPanelParentData` 포함 로그
|
||||
- `🆕 [추가모달] screenId 기반 모달 열기:` (SplitPanelLayoutComponent 1639행)
|
||||
|
||||
## 에러 확인
|
||||
|
||||
콘솔에 빨간색으로 표시되는 에러 메시지가 있는지 확인하세요.
|
||||
|
||||
## 사전 조건
|
||||
|
||||
- **process_mng** 테이블에 P003 데이터가 있어야 함 (company_code = 로그인 사용자 회사)
|
||||
- **equipment_mng** 테이블에 설비 데이터가 있어야 함
|
||||
- 로그인 사용자가 해당 회사(COMPANY_7 등) 권한이 있어야 함
|
||||
|
||||
## 자동 테스트 스크립트
|
||||
|
||||
데이터가 준비된 환경에서:
|
||||
|
||||
```bash
|
||||
cd frontend && npx tsx scripts/test-formdata-logs.ts
|
||||
```
|
||||
|
||||
데이터가 없으면 "좌측 테이블에 데이터가 없습니다" 오류가 발생합니다.
|
||||
@@ -0,0 +1,214 @@
|
||||
# 이미지/파일 저장 방식 가이드
|
||||
|
||||
## 개요
|
||||
|
||||
WACE 솔루션에서 이미지 및 파일은 **attach_file_info 테이블**에 메타데이터를 저장하고, 실제 파일은 **서버 디스크**에 저장하는 이중 구조를 사용합니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 데이터 흐름
|
||||
|
||||
```
|
||||
[사용자 업로드] → [백엔드 API] → [디스크 저장] + [DB 메타데이터 저장]
|
||||
↓ ↓
|
||||
/uploads/COMPANY_7/ attach_file_info 테이블
|
||||
2026/02/06/ (objid, file_path, ...)
|
||||
1770346704685_5.png
|
||||
```
|
||||
|
||||
### 저장 과정
|
||||
|
||||
1. 사용자가 파일 업로드 → `POST /api/files/upload`
|
||||
2. 백엔드가 파일을 디스크에 저장: `/uploads/{company_code}/{YYYY}/{MM}/{DD}/{timestamp}_{filename}`
|
||||
3. `attach_file_info` 테이블에 메타데이터 INSERT (objid, file_path, target_objid 등)
|
||||
4. 비즈니스 테이블의 이미지 컬럼에 **파일 objid** 저장 (예: `item_info.image = '433765011963536400'`)
|
||||
|
||||
### 조회 과정
|
||||
|
||||
1. 비즈니스 테이블에서 이미지 컬럼 값(objid) 로드
|
||||
2. `GET /api/files/preview/{objid}` 로 이미지 프리뷰 요청
|
||||
3. 백엔드가 `attach_file_info`에서 objid로 파일 정보 조회
|
||||
4. 디스크에서 실제 파일을 읽어 응답
|
||||
|
||||
---
|
||||
|
||||
## 2. 테이블 구조
|
||||
|
||||
### attach_file_info (파일 메타데이터)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| objid | numeric | 파일 고유 ID (PK, 큰 숫자) |
|
||||
| real_file_name | varchar | 원본 파일명 |
|
||||
| saved_file_name | varchar | 저장된 파일명 (timestamp_원본명) |
|
||||
| file_path | varchar | 저장 경로 (/uploads/COMPANY_7/2026/02/06/...) |
|
||||
| file_ext | varchar | 파일 확장자 |
|
||||
| file_size | numeric | 파일 크기 (bytes) |
|
||||
| target_objid | varchar | 연결 대상 (아래 패턴 참조) |
|
||||
| company_code | varchar | 회사 코드 (멀티테넌시) |
|
||||
| status | varchar | 상태 (ACTIVE, DELETED) |
|
||||
| writer | varchar | 업로더 ID |
|
||||
| regdate | timestamp | 등록일시 |
|
||||
| is_representative | boolean | 대표 이미지 여부 |
|
||||
|
||||
### 비즈니스 테이블 (예: item_info, company_mng)
|
||||
|
||||
이미지 컬럼에 `attach_file_info.objid` 값을 문자열로 저장합니다.
|
||||
|
||||
```sql
|
||||
-- item_info.image = '433765011963536400'
|
||||
-- company_mng.company_image = '413276787660035200'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. target_objid 패턴
|
||||
|
||||
`attach_file_info.target_objid`는 파일이 어디에 연결되어 있는지를 나타냅니다.
|
||||
|
||||
| 패턴 | 예시 | 설명 |
|
||||
|------|------|------|
|
||||
| 템플릿 모드 | `screen_files:140:comp_z4yffowb:image` | 화면 설계 시 업로드 (screenId:componentId:columnName) |
|
||||
| 레코드 모드 | `item_info:uuid-xxx:image` | 특정 레코드에 연결 (tableName:recordId:columnName) |
|
||||
|
||||
---
|
||||
|
||||
## 4. 파일 조회 API
|
||||
|
||||
### GET /api/files/preview/{objid}
|
||||
|
||||
이미지 프리뷰 (공개 접근 허용).
|
||||
|
||||
```
|
||||
GET /api/files/preview/433765011963536400
|
||||
→ 200 OK (이미지 바이너리)
|
||||
```
|
||||
|
||||
**주의: objid를 parseInt()로 변환하면 안 됩니다.** JavaScript의 `Number.MAX_SAFE_INTEGER`(9007199254740991)를 초과하는 큰 숫자이므로 **정밀도 손실**이 발생합니다. 반드시 **문자열**로 전달해야 합니다.
|
||||
|
||||
```typescript
|
||||
// 잘못된 방법
|
||||
const fileRecord = await query("SELECT * FROM attach_file_info WHERE objid = $1", [parseInt(objid)]);
|
||||
// → parseInt("433765011963536400") = 433765011963536416 (16 차이!)
|
||||
// → DB에서 찾을 수 없음 → 404
|
||||
|
||||
// 올바른 방법
|
||||
const fileRecord = await query("SELECT * FROM attach_file_info WHERE objid = $1", [objid]);
|
||||
// → PostgreSQL이 문자열 → numeric 자동 캐스팅
|
||||
```
|
||||
|
||||
### GET /api/files/component-files
|
||||
|
||||
컴포넌트별 파일 목록 조회 (인증 필요).
|
||||
|
||||
```
|
||||
GET /api/files/component-files?screenId=149&componentId=comp_z4yffowb&tableName=item_info&recordId=uuid-xxx&columnName=image
|
||||
```
|
||||
|
||||
**조회 우선순위:**
|
||||
1. **데이터 파일**: `target_objid = '{tableName}:{recordId}:{columnName}'` 패턴으로 조회
|
||||
2. **템플릿 파일**: `target_objid = 'screen_files:{screenId}:{componentId}:{columnName}'` 패턴으로 조회
|
||||
3. **레코드 컬럼 값 조회 (fallback)**: 위 두 방법으로 파일을 찾지 못하면, 비즈니스 테이블의 레코드에서 해당 컬럼 값(파일 objid)을 읽어 직접 조회
|
||||
|
||||
```sql
|
||||
-- fallback: 레코드의 image 컬럼에 저장된 objid로 직접 조회
|
||||
SELECT "image" FROM "item_info" WHERE id = $1;
|
||||
-- → '433765011963536400'
|
||||
SELECT * FROM attach_file_info WHERE objid = '433765011963536400' AND status = 'ACTIVE';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 프론트엔드 컴포넌트
|
||||
|
||||
### v2-file-upload (FileUploadComponent.tsx)
|
||||
|
||||
현재 사용되는 V2 파일 업로드 컴포넌트입니다.
|
||||
|
||||
**파일 경로**: `frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx`
|
||||
|
||||
#### 이미지 로드 방식
|
||||
|
||||
1. **formData의 컬럼 값으로 로드**: `formData[columnName]`에 파일 objid가 있으면 `/api/files/preview/{objid}`로 이미지 표시
|
||||
2. **getComponentFiles API로 로드**: target_objid 패턴으로 서버에서 파일 목록 조회
|
||||
|
||||
#### 상태 관리
|
||||
|
||||
- `uploadedFiles` state: 현재 표시 중인 파일 목록
|
||||
- `localStorage` 백업: `fileUpload_{componentId}_{columnName}` 키로 저장
|
||||
- `window.globalFileState`: 전역 파일 상태 (컴포넌트 간 동기화)
|
||||
|
||||
#### 등록/수정 모드 구분
|
||||
|
||||
- **수정 모드** (isRecordMode=true, recordId 있음): localStorage/서버에서 기존 파일 복원
|
||||
- **등록 모드** (isRecordMode=false, recordId 없음): localStorage 복원 스킵, 빈 상태로 시작
|
||||
- **단일 폼 화면** (회사정보 등): `formData[columnName]`의 objid 값으로 이미지 자동 로드
|
||||
|
||||
### file-upload (레거시)
|
||||
|
||||
**파일 경로**: `frontend/lib/registry/components/file-upload/FileUploadComponent.tsx`
|
||||
|
||||
V2MediaRenderer에서 사용하는 레거시 컴포넌트. v2-file-upload와 유사하지만 별도 파일입니다.
|
||||
|
||||
### ImageWidget
|
||||
|
||||
**파일 경로**: `frontend/components/screen/widgets/types/ImageWidget.tsx`
|
||||
|
||||
단순 이미지 표시용 위젯. 파일 업로드 기능은 있으나, `getFullImageUrl()`로 URL을 변환하여 `<img>` 태그로 직접 표시합니다. 파일 관리(목록, 삭제 등) 기능은 없습니다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 디스크 저장 구조
|
||||
|
||||
```
|
||||
backend-node/uploads/
|
||||
├── COMPANY_7/ # 회사별 격리
|
||||
│ ├── 2026/
|
||||
│ │ ├── 01/
|
||||
│ │ │ └── 08/
|
||||
│ │ │ └── 1767863580718_img.jpg
|
||||
│ │ └── 02/
|
||||
│ │ └── 06/
|
||||
│ │ ├── 1770346704685_5.png
|
||||
│ │ └── 1770352493105_5.png
|
||||
├── COMPANY_9/
|
||||
│ └── ...
|
||||
└── company_*/ # 최고 관리자 전용
|
||||
└── ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 수정 이력 (2026-02-06)
|
||||
|
||||
### parseInt 정밀도 손실 수정
|
||||
|
||||
**파일**: `backend-node/src/controllers/fileController.ts`
|
||||
|
||||
`attach_file_info.objid`는 `numeric` 타입으로 `433765011963536400` 같은 매우 큰 숫자입니다. JavaScript의 `parseInt()`는 `Number.MAX_SAFE_INTEGER`(약 9 * 10^15)를 초과하면 정밀도 손실이 발생합니다.
|
||||
|
||||
| objid (원본) | parseInt 결과 | 차이 |
|
||||
|:---|:---|:---:|
|
||||
| 396361999644927100 | 396361999644927104 | -4 |
|
||||
| 433765011963536400 | 433765011963536384 | +16 |
|
||||
| 1128460590844245000 | 1128460590844244992 | +8 |
|
||||
|
||||
**수정**: `parseInt(objid)` → `objid` (문자열 직접 전달, 8곳)
|
||||
|
||||
### getComponentFiles fallback 추가
|
||||
|
||||
**파일**: `backend-node/src/controllers/fileController.ts`
|
||||
|
||||
수정 모달에서 이미지가 안 보이는 문제. `target_objid` 패턴이 일치하지 않을 때, 비즈니스 테이블의 레코드 컬럼 값으로 파일을 직접 조회하는 fallback 로직 추가.
|
||||
|
||||
### v2-file-upload 등록 모드 파일 잔존 방지
|
||||
|
||||
**파일**: `frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx`
|
||||
|
||||
연속 등록 시 이전 등록의 이미지가 남아있는 문제. `loadComponentFiles`와 fallback 로직에서 등록 모드(recordId 없음)일 때 파일 복원을 스킵하도록 수정.
|
||||
|
||||
### ORDER BY 기본 정렬 추가
|
||||
|
||||
**파일**: `backend-node/src/services/tableManagementService.ts`
|
||||
|
||||
`sortBy` 파라미터가 없을 때 `ORDER BY created_date DESC`를 기본값으로 적용. 4곳 수정.
|
||||
@@ -0,0 +1,78 @@
|
||||
# BOM 엑셀 업로드 기능 개발 계획
|
||||
|
||||
## 개요
|
||||
탑씰(COMPANY_7) BOM관리 화면(screen_id=4168)에 엑셀 업로드 기능을 추가한다.
|
||||
BOM은 트리 구조(parent_detail_id 자기참조)이므로 범용 엑셀 업로드를 사용할 수 없고,
|
||||
BOM 전용 엑셀 업로드 컴포넌트를 개발한다.
|
||||
|
||||
## 핵심 구조
|
||||
|
||||
### DB 테이블
|
||||
- `bom` (마스터): id(UUID), item_id(→item_info), version, current_version_id
|
||||
- `bom_detail` (디테일-트리): id(UUID), bom_id(FK), parent_detail_id(자기참조), child_item_id(→item_info), level, seq_no, quantity, unit, loss_rate, process_type, version_id
|
||||
- `item_info`: id, item_number(품번), item_name(품명), division(구분), unit, size, material
|
||||
|
||||
### 엑셀 포맷 설계 (화면과 동일한 레벨 체계)
|
||||
엑셀 파일은 다음 컬럼으로 구성:
|
||||
|
||||
| 레벨 | 품번 | 품명 | 소요량 | 단위 | 로스율(%) | 공정구분 | 비고 |
|
||||
|------|------|------|--------|------|-----------|----------|------|
|
||||
| 0 | PROD-001 | 완제품A | 1 | EA | 0 | | ← BOM 헤더 (건너뜀) |
|
||||
| 1 | P-001 | 부품A | 2 | EA | 0 | | ← 직접 자품목 |
|
||||
| 2 | P-002 | 부품B | 3 | EA | 5 | 가공 | ← P-001의 하위 |
|
||||
| 1 | P-003 | 부품C | 1 | KG | 0 | | ← 직접 자품목 |
|
||||
| 2 | P-004 | 부품D | 4 | EA | 0 | 조립 | ← P-003의 하위 |
|
||||
| 1 | P-005 | 부품E | 1 | EA | 0 | | ← 직접 자품목 |
|
||||
|
||||
- 레벨 0: BOM 헤더 (최상위 품목) → 업로드 시 건너뜀 (이미 존재)
|
||||
- 레벨 1: 직접 자품목 → bom_detail (parent_detail_id=null, DB level=0)
|
||||
- 레벨 2: 자품목의 하위 → bom_detail (parent_detail_id=부모ID, DB level=1)
|
||||
- 레벨 N: → bom_detail (DB level=N-1)
|
||||
- 품번으로 item_info를 조회하여 child_item_id 자동 매핑
|
||||
|
||||
### 트리 변환 로직 (레벨 1 이상만 처리)
|
||||
엑셀 행을 순서대로 순회하면서 (레벨 0 건너뜀):
|
||||
1. 각 행의 엑셀 레벨에서 -1하여 DB 레벨 계산
|
||||
2. 스택으로 부모-자식 관계 추적
|
||||
|
||||
```
|
||||
행1(레벨0) → BOM 헤더, 건너뜀
|
||||
행2(레벨1) → DB level=0, 스택: [행2] → parent_detail_id = null
|
||||
행3(레벨2) → DB level=1, 스택: [행2, 행3] → parent_detail_id = 행2.id
|
||||
행4(레벨1) → DB level=0, 스택: [행4] → parent_detail_id = null
|
||||
행5(레벨2) → DB level=1, 스택: [행4, 행5] → parent_detail_id = 행4.id
|
||||
행6(레벨1) → DB level=0, 스택: [행6] → parent_detail_id = null
|
||||
```
|
||||
|
||||
## 테스트 계획
|
||||
|
||||
### 1단계: 백엔드 API
|
||||
- [x] 테스트 1: 품번으로 item_info 일괄 조회 (존재하는 품번)
|
||||
- [x] 테스트 2: 존재하지 않는 품번 에러 처리
|
||||
- [x] 테스트 3: 플랫 데이터 → 트리 구조 변환 (parent_detail_id 계산)
|
||||
- [x] 테스트 4: bom_detail INSERT (version_id 포함)
|
||||
- [x] 테스트 5: 기존 디테일 처리 (추가 모드 vs 전체교체 모드)
|
||||
|
||||
### 2단계: 프론트엔드 모달
|
||||
- [x] 테스트 6: 엑셀 파일 파싱 및 미리보기
|
||||
- [x] 테스트 7: 품번 매핑 결과 표시 (성공/실패)
|
||||
- [x] 테스트 8: 업로드 실행 및 결과 표시
|
||||
|
||||
### 3단계: 통합
|
||||
- [x] 테스트 9: BomTreeComponent에 엑셀 업로드 버튼 추가
|
||||
- [x] 테스트 10: 업로드 후 트리 자동 새로고침
|
||||
|
||||
## 구현 파일 목록
|
||||
|
||||
### 백엔드
|
||||
1. `backend-node/src/services/bomService.ts` - `uploadBomExcel()` 함수 추가
|
||||
2. `backend-node/src/controllers/bomController.ts` - `uploadBomExcel` 핸들러 추가
|
||||
3. `backend-node/src/routes/bomRoutes.ts` - `POST /:bomId/excel-upload` 라우트 추가
|
||||
|
||||
### 프론트엔드
|
||||
4. `frontend/lib/registry/components/v2-bom-tree/BomExcelUploadModal.tsx` - 전용 모달 신규
|
||||
5. `frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx` - 업로드 버튼 추가
|
||||
|
||||
## 진행 상태
|
||||
- 완료된 테스트는 [x]로 표시
|
||||
- 현재 진행 중인 테스트는 [진행중]으로 표시
|
||||
@@ -0,0 +1,194 @@
|
||||
# 다중 테이블 엑셀 업로드 범용 시스템
|
||||
|
||||
## 개요
|
||||
하나의 플랫 엑셀 파일로 계층적 다중 테이블(2~N개)에 데이터를 일괄 등록하는 범용 시스템.
|
||||
거래처 관리(customer_mng → customer_item_mapping → customer_item_prices)를 첫 번째 적용 대상으로 하되,
|
||||
공급업체, BOM 등 다른 화면에서도 재사용 가능하도록 설계한다.
|
||||
|
||||
## 핵심 기능
|
||||
1. 모드 선택: 어느 레벨까지 등록할지 사용자가 선택
|
||||
2. 템플릿 다운로드: 모드에 맞는 엑셀 양식 자동 생성
|
||||
3. 파일 업로드: 플랫 엑셀 → 계층 그룹핑 → 트랜잭션 UPSERT
|
||||
4. 컬럼 매핑: 엑셀 헤더 ↔ DB 컬럼 자동/수동 매핑
|
||||
|
||||
## DB 테이블 관계 (거래처 관리)
|
||||
|
||||
```
|
||||
customer_mng (Level 1 - 루트)
|
||||
PK: id (SERIAL)
|
||||
UNIQUE: customer_code
|
||||
└─ customer_item_mapping (Level 2)
|
||||
PK: id (UUID)
|
||||
FK: customer_id → customer_mng.id
|
||||
UPSERT키: customer_id + customer_item_code
|
||||
└─ customer_item_prices (Level 3)
|
||||
PK: id (UUID)
|
||||
FK: mapping_id → customer_item_mapping.id
|
||||
항상 INSERT (기간별 단가 이력)
|
||||
```
|
||||
|
||||
## 범용 설정 구조 (TableChainConfig)
|
||||
|
||||
```typescript
|
||||
interface TableLevel {
|
||||
tableName: string;
|
||||
label: string;
|
||||
// 부모와의 관계
|
||||
parentFkColumn?: string; // 이 테이블에서 부모를 참조하는 FK 컬럼
|
||||
parentRefColumn?: string; // 부모 테이블에서 참조되는 컬럼 (PK 또는 UNIQUE)
|
||||
// UPSERT 설정
|
||||
upsertMode: 'upsert' | 'insert'; // upsert: 기존 데이터 있으면 UPDATE, insert: 항상 신규
|
||||
upsertKeyColumns?: string[]; // UPSERT 매칭 키 (예: ['customer_code'])
|
||||
// 엑셀 매핑 컬럼
|
||||
columns: Array<{
|
||||
dbColumn: string;
|
||||
excelHeader: string;
|
||||
required: boolean;
|
||||
defaultValue?: any;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface TableChainConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
levels: TableLevel[]; // 0 = 루트, 1 = 자식, 2 = 손자...
|
||||
uploadModes: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
activeLevels: number[]; // 이 모드에서 활성화되는 레벨 인덱스
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
## 거래처 관리 설정 예시
|
||||
|
||||
```typescript
|
||||
const customerChainConfig: TableChainConfig = {
|
||||
id: 'customer_management',
|
||||
name: '거래처 관리',
|
||||
description: '거래처, 품목매핑, 단가 일괄 등록',
|
||||
levels: [
|
||||
{
|
||||
tableName: 'customer_mng',
|
||||
label: '거래처',
|
||||
upsertMode: 'upsert',
|
||||
upsertKeyColumns: ['customer_code'],
|
||||
columns: [
|
||||
{ dbColumn: 'customer_code', excelHeader: '거래처코드', required: true },
|
||||
{ dbColumn: 'customer_name', excelHeader: '거래처명', required: true },
|
||||
{ dbColumn: 'division', excelHeader: '구분', required: false },
|
||||
{ dbColumn: 'contact_person', excelHeader: '담당자', required: false },
|
||||
{ dbColumn: 'contact_phone', excelHeader: '연락처', required: false },
|
||||
{ dbColumn: 'email', excelHeader: '이메일', required: false },
|
||||
{ dbColumn: 'business_number', excelHeader: '사업자번호', required: false },
|
||||
{ dbColumn: 'address', excelHeader: '주소', required: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
tableName: 'customer_item_mapping',
|
||||
label: '품목매핑',
|
||||
parentFkColumn: 'customer_id',
|
||||
parentRefColumn: 'id',
|
||||
upsertMode: 'upsert',
|
||||
upsertKeyColumns: ['customer_id', 'customer_item_code'],
|
||||
columns: [
|
||||
{ dbColumn: 'customer_item_code', excelHeader: '거래처품번', required: true },
|
||||
{ dbColumn: 'customer_item_name', excelHeader: '거래처품명', required: true },
|
||||
{ dbColumn: 'item_id', excelHeader: '품목ID', required: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
tableName: 'customer_item_prices',
|
||||
label: '단가',
|
||||
parentFkColumn: 'mapping_id',
|
||||
parentRefColumn: 'id',
|
||||
upsertMode: 'insert',
|
||||
columns: [
|
||||
{ dbColumn: 'base_price', excelHeader: '기준단가', required: true },
|
||||
{ dbColumn: 'discount_type', excelHeader: '할인유형', required: false },
|
||||
{ dbColumn: 'discount_value', excelHeader: '할인값', required: false },
|
||||
{ dbColumn: 'start_date', excelHeader: '적용시작일', required: false },
|
||||
{ dbColumn: 'end_date', excelHeader: '적용종료일', required: false },
|
||||
{ dbColumn: 'currency_code', excelHeader: '통화', required: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
uploadModes: [
|
||||
{ id: 'customer_only', label: '거래처만 등록', description: '거래처 기본정보만', activeLevels: [0] },
|
||||
{ id: 'customer_item', label: '거래처 + 품목정보', description: '거래처와 품목매핑', activeLevels: [0, 1] },
|
||||
{ id: 'customer_item_price', label: '거래처 + 품목 + 단가', description: '전체 등록', activeLevels: [0, 1, 2] },
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
## 처리 로직 (백엔드)
|
||||
|
||||
### 1단계: 그룹핑
|
||||
엑셀의 플랫 행을 계층별 그룹으로 변환:
|
||||
- Level 0 (거래처): customer_code 기준 그룹핑
|
||||
- Level 1 (품목매핑): customer_code + customer_item_code 기준 그룹핑
|
||||
- Level 2 (단가): 매 행마다 INSERT
|
||||
|
||||
### 2단계: 계단식 UPSERT (트랜잭션)
|
||||
```
|
||||
BEGIN TRANSACTION
|
||||
|
||||
FOR EACH unique customer_code:
|
||||
1. customer_mng UPSERT → 결과에서 id 획득 (returnedId)
|
||||
|
||||
FOR EACH unique customer_item_code (해당 거래처):
|
||||
2. customer_item_mapping의 customer_id = returnedId 주입
|
||||
UPSERT → 결과에서 id 획득 (mappingId)
|
||||
|
||||
FOR EACH price row (해당 품목매핑):
|
||||
3. customer_item_prices의 mapping_id = mappingId 주입
|
||||
INSERT
|
||||
|
||||
COMMIT (전체 성공) or ROLLBACK (하나라도 실패)
|
||||
```
|
||||
|
||||
### 3단계: 결과 반환
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"results": {
|
||||
"customer_mng": { "inserted": 2, "updated": 1 },
|
||||
"customer_item_mapping": { "inserted": 5, "updated": 2 },
|
||||
"customer_item_prices": { "inserted": 12 }
|
||||
},
|
||||
"errors": []
|
||||
}
|
||||
```
|
||||
|
||||
## 테스트 계획
|
||||
|
||||
### 1단계: 백엔드 서비스
|
||||
- [x] plan.md 작성
|
||||
- [ ] multiTableExcelService.ts 기본 구조 작성
|
||||
- [ ] 그룹핑 로직 구현
|
||||
- [ ] 계단식 UPSERT 로직 구현
|
||||
- [ ] 트랜잭션 처리
|
||||
- [ ] 에러 핸들링
|
||||
|
||||
### 2단계: API 엔드포인트
|
||||
- [ ] POST /api/data/multi-table/upload 추가
|
||||
- [ ] POST /api/data/multi-table/template 추가 (템플릿 다운로드)
|
||||
- [ ] 입력값 검증
|
||||
|
||||
### 3단계: 프론트엔드
|
||||
- [ ] MultiTableExcelUploadModal.tsx 컴포넌트 작성
|
||||
- [ ] 모드 선택 UI
|
||||
- [ ] 템플릿 다운로드 버튼
|
||||
- [ ] 파일 업로드 + 미리보기
|
||||
- [ ] 컬럼 매핑 UI
|
||||
- [ ] 업로드 결과 표시
|
||||
|
||||
### 4단계: 통합
|
||||
- [ ] 거래처 관리 화면에 연결
|
||||
- [ ] 실제 데이터로 테스트
|
||||
|
||||
## 진행 상태
|
||||
- 완료된 테스트는 [x]로 표시
|
||||
- 현재 진행 중인 테스트는 [진행중]으로 표시
|
||||
@@ -0,0 +1,176 @@
|
||||
# 출하계획 동시 등록 컴포넌트 (v2-shipping-plan-editor) 설계서
|
||||
|
||||
## 개요
|
||||
|
||||
수주 목록에서 다건 선택 후 "출하계획" 버튼 클릭 시 모달로 열리는 출하계획 일괄 등록 화면.
|
||||
기존 ScreenModal + modalScreenId 매커니즘을 활용하여, DB 기반 화면(screen_definitions)으로 구현한다.
|
||||
|
||||
## 핵심 기능
|
||||
|
||||
1. 선택된 수주를 **품목(part_code) 기준으로 그룹핑**
|
||||
2. 그룹별 **5칸 집계 카드**: 총수주잔량, 총 출하계획량, 현재고, 가용재고, 생산중수량
|
||||
3. 그룹별 상세 테이블: 기존 계획(기존) + 신규 입력(신규) 구분 표시
|
||||
4. 출하계획량만 입력 → 확인 시 shipment_plan에 일괄 INSERT
|
||||
|
||||
## 테이블 관계
|
||||
|
||||
```
|
||||
sales_order_mng (수주)
|
||||
├─ id (PK)
|
||||
├─ part_code (품목코드) ← 그룹핑 기준
|
||||
├─ part_name (품명)
|
||||
├─ order_qty (수주수량)
|
||||
├─ ship_qty (출하수량)
|
||||
├─ balance_qty (잔량) = order_qty - ship_qty
|
||||
├─ partner_id (거래처)
|
||||
└─ due_date (납기일)
|
||||
|
||||
shipment_plan (출하계획)
|
||||
├─ sales_order_id (FK → sales_order_mng.id)
|
||||
├─ plan_qty (출하계획수량)
|
||||
├─ plan_date (출하예정일)
|
||||
├─ shipment_plan_no (자동 채번)
|
||||
└─ status (READY)
|
||||
|
||||
inventory_stock (재고)
|
||||
├─ item_code (품목코드)
|
||||
└─ current_qty (현재고)
|
||||
|
||||
production_plan_mng (생산계획)
|
||||
├─ item_code (품목코드)
|
||||
├─ plan_qty (계획수량)
|
||||
├─ completed_qty (완료수량)
|
||||
└─ status (진행중 = in_progress / planned)
|
||||
```
|
||||
|
||||
## 집계 카드 데이터 소스
|
||||
|
||||
| 카드 | 계산 방법 |
|
||||
|------|----------|
|
||||
| 총수주잔량 | SUM(sales_order_mng.balance_qty) WHERE part_code = ? |
|
||||
| 총 출하계획량 | SUM(shipment_plan.plan_qty) WHERE sales_order_id IN (해당 품목 수주들) |
|
||||
| 현재고 | SUM(inventory_stock.current_qty) WHERE item_code = part_code |
|
||||
| 가용재고 | 현재고 - 총 출하계획량 (기존 계획분) |
|
||||
| 생산중수량 | SUM(production_plan_mng.plan_qty - completed_qty) WHERE item_code = part_code AND status IN ('in_progress', 'planned') |
|
||||
|
||||
## 상세 테이블 컬럼
|
||||
|
||||
| 컬럼 | 소스 | 편집 |
|
||||
|------|------|------|
|
||||
| 구분 | "기존" or "신규" | 읽기 전용 (배지) |
|
||||
| 수주번호 | sales_order_mng.order_no | 읽기 전용 |
|
||||
| 거래처 | sales_order_mng.partner_id (엔티티 조인) | 읽기 전용 |
|
||||
| 납기일 | sales_order_mng.due_date | 읽기 전용 |
|
||||
| 미출하 | sales_order_mng.balance_qty | 읽기 전용 |
|
||||
| 출하계획량 | 입력값 / shipment_plan.plan_qty | **입력 가능** |
|
||||
|
||||
## 데이터 흐름
|
||||
|
||||
```
|
||||
1. 수주 목록에서 체크박스 선택 → "출하계획" 버튼 클릭
|
||||
2. openScreenModal 이벤트 발생 (selectedData = 선택된 수주 배열)
|
||||
3. ScreenModal이 모달 화면 로드 (v2-shipping-plan-editor 컴포넌트)
|
||||
4. 컴포넌트가 groupedData (= selectedData) 수신
|
||||
5. part_code 기준 그룹핑
|
||||
6. 백엔드 API 호출: GET /api/shipping-plan/aggregate
|
||||
→ 품목별 재고, 생산중수량, 기존 출하계획 조회
|
||||
7. UI 렌더링 (집계 카드 + 상세 테이블)
|
||||
8. 사용자가 출하계획량 입력
|
||||
9. 확인 버튼 → POST /api/shipping-plan/batch
|
||||
→ shipment_plan INSERT + sales_order_mng.plan_ship_qty UPDATE
|
||||
```
|
||||
|
||||
## 파일 구조
|
||||
|
||||
```
|
||||
frontend/lib/registry/components/v2-shipping-plan-editor/
|
||||
├── index.ts # createComponentDefinition
|
||||
├── ShippingPlanEditorRenderer.tsx # AutoRegisteringComponentRenderer
|
||||
├── ShippingPlanEditorComponent.tsx # 메인 UI 컴포넌트
|
||||
└── types.ts # 타입 정의
|
||||
|
||||
frontend/lib/api/
|
||||
└── shipping.ts # API 클라이언트 함수
|
||||
|
||||
backend-node/src/
|
||||
├── controllers/shippingPlanController.ts # API 핸들러
|
||||
└── routes/shippingPlanRoutes.ts # 라우터
|
||||
```
|
||||
|
||||
## 백엔드 API
|
||||
|
||||
### GET /api/shipping-plan/aggregate
|
||||
품목별 집계 + 기존 출하계획 조회
|
||||
|
||||
Request: `?partCodes=ITEM001,SEAL-100&orderIds=172,175,178`
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"ITEM001": {
|
||||
"totalBalance": 1700,
|
||||
"totalPlanQty": 500,
|
||||
"currentStock": 1000,
|
||||
"availableStock": 500,
|
||||
"inProductionQty": 300,
|
||||
"existingPlans": [
|
||||
{ "id": 76, "salesOrderId": 172, "planQty": 500, "planDate": "2025-12-10", "shipmentPlanNo": "SPL-..." }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/shipping-plan/batch
|
||||
출하계획 일괄 저장
|
||||
|
||||
Request:
|
||||
```json
|
||||
{
|
||||
"plans": [
|
||||
{ "salesOrderId": 172, "planQty": 1000 },
|
||||
{ "salesOrderId": 175, "planQty": 500 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 구현 상태
|
||||
|
||||
### 완료
|
||||
- [x] types.ts (타입 정의)
|
||||
- [x] index.ts (컴포넌트 정의)
|
||||
- [x] ShippingPlanEditorRenderer.tsx (레지스트리 등록)
|
||||
- [x] ShippingPlanEditorComponent.tsx (메인 UI)
|
||||
- [x] frontend/lib/api/shipping.ts (API 클라이언트)
|
||||
- [x] backend-node/src/controllers/shippingPlanController.ts (집계 + 일괄 저장)
|
||||
- [x] backend-node/src/routes/shippingPlanRoutes.ts (라우터)
|
||||
- [x] screen_definitions (screen_id: 4573, screen_code: *_SHIP_PLAN_EDITOR)
|
||||
- [x] screen_layouts_v2 (layout_id: 11562)
|
||||
|
||||
### 연동 정보
|
||||
| 항목 | 마스터(*) | 탑씰(COMPANY_7) |
|
||||
|------|-----------|-----------------|
|
||||
| screen_id | 4573 | 4574 |
|
||||
| screen_code | *_SHIP_PLAN_EDITOR | TOPSEAL_SHIP_PLAN_EDITOR |
|
||||
| layout_id | 11562 | 11563 |
|
||||
|
||||
탑씰 수주관리 화면(screen_id: 156)의 "출하계획" 버튼(comp_33659)이
|
||||
targetScreenId: 4574로 연결되어, 체크박스 선택 → 버튼 클릭 → 모달 오픈.
|
||||
선택된 수주 데이터는 `groupedData` prop으로 전달됨.
|
||||
|
||||
## 테스트 계획
|
||||
|
||||
### 1단계: 기본 기능
|
||||
- [ ] 수주 선택 → 모달 열기 → groupedData 수신 확인
|
||||
- [ ] part_code 기준 그룹핑 확인
|
||||
- [ ] 집계 카드 데이터 표시 확인
|
||||
|
||||
### 2단계: CRUD
|
||||
- [ ] 출하계획량 입력 → 집계 자동 재계산
|
||||
- [ ] 확인 버튼 → shipment_plan INSERT 확인
|
||||
- [ ] 기존 계획 "기존" 배지 표시 확인
|
||||
|
||||
### 3단계: 검증
|
||||
- [ ] 출하계획량 > 미출하 시 에러 처리
|
||||
- [ ] 멀티테넌시 (company_code) 필터링 확인
|
||||
@@ -0,0 +1,573 @@
|
||||
{
|
||||
"version": "2.0",
|
||||
"screenResolution": {
|
||||
"width": 1400,
|
||||
"height": 900,
|
||||
"name": "수주등록 모달",
|
||||
"category": "modal"
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"id": "section-options",
|
||||
"url": "@/lib/registry/components/v2-section-card",
|
||||
"position": { "x": 20, "y": 20, "z": 1 },
|
||||
"size": { "width": 1360, "height": 80 },
|
||||
"overrides": {
|
||||
"componentConfig": {
|
||||
"title": "",
|
||||
"showHeader": false,
|
||||
"padding": "md",
|
||||
"borderStyle": "solid"
|
||||
}
|
||||
},
|
||||
"displayOrder": 0
|
||||
},
|
||||
{
|
||||
"id": "select-input-method",
|
||||
"url": "@/lib/registry/components/v2-select",
|
||||
"position": { "x": 40, "y": 35, "z": 2 },
|
||||
"size": { "width": 300, "height": 40 },
|
||||
"overrides": {
|
||||
"label": "입력 방식",
|
||||
"columnName": "input_method",
|
||||
"mode": "dropdown",
|
||||
"source": "static",
|
||||
"options": [
|
||||
{ "value": "customer_first", "label": "거래처 우선" },
|
||||
{ "value": "item_first", "label": "품목 우선" }
|
||||
],
|
||||
"placeholder": "입력 방식 선택"
|
||||
},
|
||||
"displayOrder": 1
|
||||
},
|
||||
{
|
||||
"id": "select-sales-type",
|
||||
"url": "@/lib/registry/components/v2-select",
|
||||
"position": { "x": 360, "y": 35, "z": 2 },
|
||||
"size": { "width": 300, "height": 40 },
|
||||
"overrides": {
|
||||
"label": "판매 유형",
|
||||
"columnName": "sales_type",
|
||||
"mode": "dropdown",
|
||||
"source": "static",
|
||||
"options": [
|
||||
{ "value": "domestic", "label": "국내 판매" },
|
||||
{ "value": "overseas", "label": "해외 판매" }
|
||||
],
|
||||
"placeholder": "판매 유형 선택"
|
||||
},
|
||||
"displayOrder": 2
|
||||
},
|
||||
{
|
||||
"id": "select-price-method",
|
||||
"url": "@/lib/registry/components/v2-select",
|
||||
"position": { "x": 680, "y": 35, "z": 2 },
|
||||
"size": { "width": 250, "height": 40 },
|
||||
"overrides": {
|
||||
"label": "단가 방식",
|
||||
"columnName": "price_method",
|
||||
"mode": "dropdown",
|
||||
"source": "static",
|
||||
"options": [
|
||||
{ "value": "standard", "label": "기준 단가" },
|
||||
{ "value": "contract", "label": "계약 단가" },
|
||||
{ "value": "custom", "label": "개별 입력" }
|
||||
],
|
||||
"placeholder": "단가 방식"
|
||||
},
|
||||
"displayOrder": 3
|
||||
},
|
||||
{
|
||||
"id": "checkbox-price-edit",
|
||||
"url": "@/lib/registry/components/v2-select",
|
||||
"position": { "x": 950, "y": 35, "z": 2 },
|
||||
"size": { "width": 150, "height": 40 },
|
||||
"overrides": {
|
||||
"label": "단가 수정 허용",
|
||||
"columnName": "allow_price_edit",
|
||||
"mode": "check",
|
||||
"source": "static",
|
||||
"options": [{ "value": "Y", "label": "허용" }]
|
||||
},
|
||||
"displayOrder": 4
|
||||
},
|
||||
|
||||
{
|
||||
"id": "section-customer-info",
|
||||
"url": "@/lib/registry/components/v2-section-card",
|
||||
"position": { "x": 20, "y": 110, "z": 1 },
|
||||
"size": { "width": 1360, "height": 120 },
|
||||
"overrides": {
|
||||
"componentConfig": {
|
||||
"title": "거래처 정보",
|
||||
"showHeader": true,
|
||||
"padding": "md",
|
||||
"borderStyle": "solid"
|
||||
},
|
||||
"conditionalConfig": {
|
||||
"enabled": true,
|
||||
"field": "input_method",
|
||||
"operator": "=",
|
||||
"value": "customer_first",
|
||||
"action": "show"
|
||||
}
|
||||
},
|
||||
"displayOrder": 5
|
||||
},
|
||||
{
|
||||
"id": "select-customer",
|
||||
"url": "@/lib/registry/components/v2-select",
|
||||
"position": { "x": 40, "y": 155, "z": 3 },
|
||||
"size": { "width": 320, "height": 40 },
|
||||
"overrides": {
|
||||
"label": "거래처 *",
|
||||
"columnName": "partner_id",
|
||||
"mode": "dropdown",
|
||||
"source": "entity",
|
||||
"entityTable": "customer_mng",
|
||||
"entityValueColumn": "customer_code",
|
||||
"entityLabelColumn": "customer_name",
|
||||
"searchable": true,
|
||||
"placeholder": "거래처명 입력하여 검색",
|
||||
"required": true,
|
||||
"conditionalConfig": {
|
||||
"enabled": true,
|
||||
"field": "input_method",
|
||||
"operator": "=",
|
||||
"value": "customer_first",
|
||||
"action": "show"
|
||||
}
|
||||
},
|
||||
"displayOrder": 6
|
||||
},
|
||||
{
|
||||
"id": "input-manager",
|
||||
"url": "@/lib/registry/components/v2-input",
|
||||
"position": { "x": 380, "y": 155, "z": 3 },
|
||||
"size": { "width": 240, "height": 40 },
|
||||
"overrides": {
|
||||
"label": "담당자",
|
||||
"columnName": "manager_name",
|
||||
"placeholder": "담당자",
|
||||
"conditionalConfig": {
|
||||
"enabled": true,
|
||||
"field": "input_method",
|
||||
"operator": "=",
|
||||
"value": "customer_first",
|
||||
"action": "show"
|
||||
}
|
||||
},
|
||||
"displayOrder": 7
|
||||
},
|
||||
{
|
||||
"id": "input-delivery-partner",
|
||||
"url": "@/lib/registry/components/v2-input",
|
||||
"position": { "x": 640, "y": 155, "z": 3 },
|
||||
"size": { "width": 240, "height": 40 },
|
||||
"overrides": {
|
||||
"label": "납품처",
|
||||
"columnName": "delivery_partner_id",
|
||||
"placeholder": "납품처",
|
||||
"conditionalConfig": {
|
||||
"enabled": true,
|
||||
"field": "input_method",
|
||||
"operator": "=",
|
||||
"value": "customer_first",
|
||||
"action": "show"
|
||||
}
|
||||
},
|
||||
"displayOrder": 8
|
||||
},
|
||||
{
|
||||
"id": "input-delivery-address",
|
||||
"url": "@/lib/registry/components/v2-input",
|
||||
"position": { "x": 900, "y": 155, "z": 3 },
|
||||
"size": { "width": 460, "height": 40 },
|
||||
"overrides": {
|
||||
"label": "납품장소",
|
||||
"columnName": "delivery_address",
|
||||
"placeholder": "납품장소",
|
||||
"conditionalConfig": {
|
||||
"enabled": true,
|
||||
"field": "input_method",
|
||||
"operator": "=",
|
||||
"value": "customer_first",
|
||||
"action": "show"
|
||||
}
|
||||
},
|
||||
"displayOrder": 9
|
||||
},
|
||||
|
||||
{
|
||||
"id": "section-item-first",
|
||||
"url": "@/lib/registry/components/v2-section-card",
|
||||
"position": { "x": 20, "y": 110, "z": 1 },
|
||||
"size": { "width": 1360, "height": 200 },
|
||||
"overrides": {
|
||||
"componentConfig": {
|
||||
"title": "품목 및 거래처별 수주",
|
||||
"showHeader": true,
|
||||
"padding": "md",
|
||||
"borderStyle": "solid"
|
||||
},
|
||||
"conditionalConfig": {
|
||||
"enabled": true,
|
||||
"field": "input_method",
|
||||
"operator": "=",
|
||||
"value": "item_first",
|
||||
"action": "show"
|
||||
}
|
||||
},
|
||||
"displayOrder": 10
|
||||
},
|
||||
|
||||
{
|
||||
"id": "section-items",
|
||||
"url": "@/lib/registry/components/v2-section-card",
|
||||
"position": { "x": 20, "y": 240, "z": 1 },
|
||||
"size": { "width": 1360, "height": 280 },
|
||||
"overrides": {
|
||||
"componentConfig": {
|
||||
"title": "추가된 품목",
|
||||
"showHeader": true,
|
||||
"padding": "md",
|
||||
"borderStyle": "solid"
|
||||
},
|
||||
"conditionalConfig": {
|
||||
"enabled": true,
|
||||
"field": "input_method",
|
||||
"operator": "=",
|
||||
"value": "customer_first",
|
||||
"action": "show"
|
||||
}
|
||||
},
|
||||
"displayOrder": 11
|
||||
},
|
||||
{
|
||||
"id": "btn-item-search",
|
||||
"url": "@/lib/registry/components/v2-button-primary",
|
||||
"position": { "x": 1140, "y": 245, "z": 5 },
|
||||
"size": { "width": 100, "height": 36 },
|
||||
"overrides": {
|
||||
"label": "품목 검색",
|
||||
"action": {
|
||||
"type": "openModal",
|
||||
"modalType": "itemSelection"
|
||||
},
|
||||
"conditionalConfig": {
|
||||
"enabled": true,
|
||||
"field": "input_method",
|
||||
"operator": "=",
|
||||
"value": "customer_first",
|
||||
"action": "show"
|
||||
}
|
||||
},
|
||||
"displayOrder": 12
|
||||
},
|
||||
{
|
||||
"id": "btn-shipping-plan",
|
||||
"url": "@/lib/registry/components/v2-button-primary",
|
||||
"position": { "x": 1250, "y": 245, "z": 5 },
|
||||
"size": { "width": 100, "height": 36 },
|
||||
"overrides": {
|
||||
"label": "출하계획",
|
||||
"webTypeConfig": {
|
||||
"variant": "destructive"
|
||||
},
|
||||
"conditionalConfig": {
|
||||
"enabled": true,
|
||||
"field": "input_method",
|
||||
"operator": "=",
|
||||
"value": "customer_first",
|
||||
"action": "show"
|
||||
}
|
||||
},
|
||||
"displayOrder": 13
|
||||
},
|
||||
{
|
||||
"id": "repeater-items",
|
||||
"url": "@/lib/registry/components/v2-repeater",
|
||||
"position": { "x": 40, "y": 290, "z": 3 },
|
||||
"size": { "width": 1320, "height": 200 },
|
||||
"overrides": {
|
||||
"renderMode": "modal",
|
||||
"dataSource": {
|
||||
"tableName": "sales_order_detail",
|
||||
"foreignKey": "order_no",
|
||||
"referenceKey": "order_no"
|
||||
},
|
||||
"columns": [
|
||||
{ "field": "part_code", "header": "품번", "width": 100 },
|
||||
{ "field": "part_name", "header": "품명", "width": 150 },
|
||||
{ "field": "spec", "header": "규격", "width": 100 },
|
||||
{ "field": "unit", "header": "단위", "width": 80 },
|
||||
{ "field": "qty", "header": "수량", "width": 100, "editable": true },
|
||||
{
|
||||
"field": "unit_price",
|
||||
"header": "단가",
|
||||
"width": 100,
|
||||
"editable": true
|
||||
},
|
||||
{ "field": "amount", "header": "금액", "width": 100 },
|
||||
{
|
||||
"field": "due_date",
|
||||
"header": "납기일",
|
||||
"width": 120,
|
||||
"editable": true
|
||||
}
|
||||
],
|
||||
"modal": {
|
||||
"sourceTable": "item_info",
|
||||
"sourceColumns": [
|
||||
"part_code",
|
||||
"part_name",
|
||||
"spec",
|
||||
"material",
|
||||
"unit_price"
|
||||
],
|
||||
"filterCondition": {}
|
||||
},
|
||||
"features": {
|
||||
"showAddButton": false,
|
||||
"showDeleteButton": true,
|
||||
"inlineEdit": true
|
||||
},
|
||||
"conditionalConfig": {
|
||||
"enabled": true,
|
||||
"field": "input_method",
|
||||
"operator": "=",
|
||||
"value": "customer_first",
|
||||
"action": "show"
|
||||
}
|
||||
},
|
||||
"displayOrder": 14
|
||||
},
|
||||
|
||||
{
|
||||
"id": "section-trade-info",
|
||||
"url": "@/lib/registry/components/v2-section-card",
|
||||
"position": { "x": 20, "y": 530, "z": 1 },
|
||||
"size": { "width": 1360, "height": 150 },
|
||||
"overrides": {
|
||||
"componentConfig": {
|
||||
"title": "무역 정보",
|
||||
"showHeader": true,
|
||||
"padding": "md",
|
||||
"borderStyle": "solid"
|
||||
},
|
||||
"conditionalConfig": {
|
||||
"enabled": true,
|
||||
"field": "sales_type",
|
||||
"operator": "=",
|
||||
"value": "overseas",
|
||||
"action": "show"
|
||||
}
|
||||
},
|
||||
"displayOrder": 15
|
||||
},
|
||||
{
|
||||
"id": "select-incoterms",
|
||||
"url": "@/lib/registry/components/v2-select",
|
||||
"position": { "x": 40, "y": 575, "z": 3 },
|
||||
"size": { "width": 200, "height": 40 },
|
||||
"overrides": {
|
||||
"label": "인코텀즈",
|
||||
"columnName": "incoterms",
|
||||
"mode": "dropdown",
|
||||
"source": "static",
|
||||
"options": [
|
||||
{ "value": "FOB", "label": "FOB" },
|
||||
{ "value": "CIF", "label": "CIF" },
|
||||
{ "value": "EXW", "label": "EXW" },
|
||||
{ "value": "DDP", "label": "DDP" }
|
||||
],
|
||||
"placeholder": "선택",
|
||||
"conditionalConfig": {
|
||||
"enabled": true,
|
||||
"field": "sales_type",
|
||||
"operator": "=",
|
||||
"value": "overseas",
|
||||
"action": "show"
|
||||
}
|
||||
},
|
||||
"displayOrder": 16
|
||||
},
|
||||
{
|
||||
"id": "select-payment-term",
|
||||
"url": "@/lib/registry/components/v2-select",
|
||||
"position": { "x": 260, "y": 575, "z": 3 },
|
||||
"size": { "width": 200, "height": 40 },
|
||||
"overrides": {
|
||||
"label": "결제 조건",
|
||||
"columnName": "payment_term",
|
||||
"mode": "dropdown",
|
||||
"source": "static",
|
||||
"options": [
|
||||
{ "value": "TT", "label": "T/T" },
|
||||
{ "value": "LC", "label": "L/C" },
|
||||
{ "value": "DA", "label": "D/A" },
|
||||
{ "value": "DP", "label": "D/P" }
|
||||
],
|
||||
"placeholder": "선택",
|
||||
"conditionalConfig": {
|
||||
"enabled": true,
|
||||
"field": "sales_type",
|
||||
"operator": "=",
|
||||
"value": "overseas",
|
||||
"action": "show"
|
||||
}
|
||||
},
|
||||
"displayOrder": 17
|
||||
},
|
||||
{
|
||||
"id": "select-currency",
|
||||
"url": "@/lib/registry/components/v2-select",
|
||||
"position": { "x": 480, "y": 575, "z": 3 },
|
||||
"size": { "width": 200, "height": 40 },
|
||||
"overrides": {
|
||||
"label": "통화",
|
||||
"columnName": "currency",
|
||||
"mode": "dropdown",
|
||||
"source": "static",
|
||||
"options": [
|
||||
{ "value": "KRW", "label": "KRW (원)" },
|
||||
{ "value": "USD", "label": "USD (달러)" },
|
||||
{ "value": "EUR", "label": "EUR (유로)" },
|
||||
{ "value": "JPY", "label": "JPY (엔)" },
|
||||
{ "value": "CNY", "label": "CNY (위안)" }
|
||||
],
|
||||
"conditionalConfig": {
|
||||
"enabled": true,
|
||||
"field": "sales_type",
|
||||
"operator": "=",
|
||||
"value": "overseas",
|
||||
"action": "show"
|
||||
}
|
||||
},
|
||||
"displayOrder": 18
|
||||
},
|
||||
{
|
||||
"id": "input-port-loading",
|
||||
"url": "@/lib/registry/components/v2-input",
|
||||
"position": { "x": 40, "y": 625, "z": 3 },
|
||||
"size": { "width": 200, "height": 40 },
|
||||
"overrides": {
|
||||
"label": "선적항",
|
||||
"columnName": "port_of_loading",
|
||||
"placeholder": "선적항",
|
||||
"conditionalConfig": {
|
||||
"enabled": true,
|
||||
"field": "sales_type",
|
||||
"operator": "=",
|
||||
"value": "overseas",
|
||||
"action": "show"
|
||||
}
|
||||
},
|
||||
"displayOrder": 19
|
||||
},
|
||||
{
|
||||
"id": "input-port-discharge",
|
||||
"url": "@/lib/registry/components/v2-input",
|
||||
"position": { "x": 260, "y": 625, "z": 3 },
|
||||
"size": { "width": 200, "height": 40 },
|
||||
"overrides": {
|
||||
"label": "도착항",
|
||||
"columnName": "port_of_discharge",
|
||||
"placeholder": "도착항",
|
||||
"conditionalConfig": {
|
||||
"enabled": true,
|
||||
"field": "sales_type",
|
||||
"operator": "=",
|
||||
"value": "overseas",
|
||||
"action": "show"
|
||||
}
|
||||
},
|
||||
"displayOrder": 20
|
||||
},
|
||||
{
|
||||
"id": "input-hs-code",
|
||||
"url": "@/lib/registry/components/v2-input",
|
||||
"position": { "x": 480, "y": 625, "z": 3 },
|
||||
"size": { "width": 200, "height": 40 },
|
||||
"overrides": {
|
||||
"label": "HS Code",
|
||||
"columnName": "hs_code",
|
||||
"placeholder": "HS Code",
|
||||
"conditionalConfig": {
|
||||
"enabled": true,
|
||||
"field": "sales_type",
|
||||
"operator": "=",
|
||||
"value": "overseas",
|
||||
"action": "show"
|
||||
}
|
||||
},
|
||||
"displayOrder": 21
|
||||
},
|
||||
|
||||
{
|
||||
"id": "section-additional",
|
||||
"url": "@/lib/registry/components/v2-section-card",
|
||||
"position": { "x": 20, "y": 690, "z": 1 },
|
||||
"size": { "width": 1360, "height": 130 },
|
||||
"overrides": {
|
||||
"componentConfig": {
|
||||
"title": "추가 정보",
|
||||
"showHeader": true,
|
||||
"padding": "md",
|
||||
"borderStyle": "solid"
|
||||
}
|
||||
},
|
||||
"displayOrder": 22
|
||||
},
|
||||
{
|
||||
"id": "input-memo",
|
||||
"url": "@/lib/registry/components/v2-input",
|
||||
"position": { "x": 40, "y": 735, "z": 3 },
|
||||
"size": { "width": 1320, "height": 70 },
|
||||
"overrides": {
|
||||
"label": "메모",
|
||||
"columnName": "memo",
|
||||
"type": "textarea",
|
||||
"placeholder": "메모를 입력하세요"
|
||||
},
|
||||
"displayOrder": 23
|
||||
},
|
||||
|
||||
{
|
||||
"id": "btn-cancel",
|
||||
"url": "@/lib/registry/components/v2-button-primary",
|
||||
"position": { "x": 1180, "y": 840, "z": 5 },
|
||||
"size": { "width": 90, "height": 40 },
|
||||
"overrides": {
|
||||
"label": "취소",
|
||||
"webTypeConfig": {
|
||||
"variant": "outline"
|
||||
},
|
||||
"action": {
|
||||
"type": "close"
|
||||
}
|
||||
},
|
||||
"displayOrder": 24
|
||||
},
|
||||
{
|
||||
"id": "btn-save",
|
||||
"url": "@/lib/registry/components/v2-button-primary",
|
||||
"position": { "x": 1280, "y": 840, "z": 5 },
|
||||
"size": { "width": 90, "height": 40 },
|
||||
"overrides": {
|
||||
"label": "저장",
|
||||
"action": {
|
||||
"type": "save"
|
||||
}
|
||||
},
|
||||
"displayOrder": 25
|
||||
}
|
||||
],
|
||||
"gridSettings": {
|
||||
"columns": 12,
|
||||
"gap": 16,
|
||||
"padding": 20,
|
||||
"snapToGrid": true,
|
||||
"showGrid": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
# v2-table-list Entity 조인 기능 분석
|
||||
|
||||
v2-repeater에 동일 기능을 추가하기 위한 상세 분석 문서입니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 개요
|
||||
|
||||
v2-table-list의 Entity 조인 기능은 두 가지 유형으로 구분됩니다:
|
||||
|
||||
| 유형 | 설명 | 설정 방식 |
|
||||
|------|------|-----------|
|
||||
| **isEntityJoin** | 테이블 컬럼이 `input_type=entity`인 경우 (테이블 타입 관리에서 참조 테이블 설정됨) | 자동 감지 + entityDisplayConfig로 표시 컬럼 선택 |
|
||||
| **additionalJoinInfo** | ConfigPanel "Entity 조인 컬럼" 탭에서 수동 추가한 참조 테이블 컬럼 | addEntityColumn으로 추가, additionalJoinInfo 저장 |
|
||||
|
||||
---
|
||||
|
||||
## 2. Entity 조인 설정 UI 구조 (TableListConfigPanel)
|
||||
|
||||
### 2.1 데이터 소스
|
||||
|
||||
- **entityJoinApi.getEntityJoinColumns(tableName)** 호출
|
||||
- targetTableName 변경 시 useEffect로 재호출
|
||||
|
||||
### 2.2 entityJoinColumns 상태 구조
|
||||
|
||||
```typescript
|
||||
{
|
||||
availableColumns: Array<{
|
||||
tableName: string; // 참조 테이블명 (예: dept_info)
|
||||
columnName: string; // 참조 테이블 컬럼명 (예: company_name)
|
||||
columnLabel: string;
|
||||
dataType: string;
|
||||
joinAlias: string; // 예: dept_code_company_name (sourceColumn_columnName)
|
||||
suggestedLabel: string;
|
||||
}>;
|
||||
joinTables: Array<{
|
||||
tableName: string; // 참조 테이블명
|
||||
currentDisplayColumn: string;
|
||||
joinConfig: { // 백엔드 entity-join-columns API에서 반환
|
||||
sourceColumn: string; // 기준 테이블 FK 컬럼 (예: dept_code)
|
||||
referenceTable: string;
|
||||
referenceColumn: string;
|
||||
displayColumn: string;
|
||||
// ...
|
||||
};
|
||||
availableColumns: Array<{
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
dataType: string;
|
||||
inputType?: string;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 Entity 조인 컬럼 UI (ConfigPanel)
|
||||
|
||||
- **위치**: 기본 컬럼 선택 영역 아래, "Entity 조인 컬럼" 섹션
|
||||
- **조건**: `entityJoinColumns.joinTables.length > 0` 일 때만 표시
|
||||
- **구조**: joinTables별로 그룹화 → 각 그룹 내 availableColumns를 체크박스로 표시
|
||||
- **추가 로직**: `addEntityColumn(joinColumn)` 호출
|
||||
|
||||
### 2.4 addEntityColumn 함수 (핵심)
|
||||
|
||||
```typescript
|
||||
const addEntityColumn = (joinColumn: availableColumns[0]) => {
|
||||
// joinTables에서 sourceColumn 추출 (필수!)
|
||||
const joinTableInfo = entityJoinColumns.joinTables?.find(
|
||||
(jt) => jt.tableName === joinColumn.tableName
|
||||
);
|
||||
const sourceColumn = joinTableInfo?.joinConfig?.sourceColumn || "";
|
||||
|
||||
const newColumn: ColumnConfig = {
|
||||
columnName: joinColumn.joinAlias, // 예: dept_code_company_name
|
||||
displayName: joinColumn.columnLabel,
|
||||
// ...
|
||||
isEntityJoin: false, // 조인 탭에서 추가한 컬럼은 엔티티 타입이 아님
|
||||
additionalJoinInfo: {
|
||||
sourceTable: config.selectedTable || screenTableName || "",
|
||||
sourceColumn: sourceColumn, // dept_code
|
||||
referenceTable: joinColumn.tableName, // dept_info
|
||||
joinAlias: joinColumn.joinAlias, // dept_code_company_name
|
||||
},
|
||||
};
|
||||
handleChange("columns", [...config.columns, newColumn]);
|
||||
};
|
||||
```
|
||||
|
||||
**주의**: `sourceColumn`은 반드시 `joinTableInfo.joinConfig.sourceColumn`에서 가져와야 합니다. `joinColumn`에는 없습니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. additionalJoinInfo 데이터 구조
|
||||
|
||||
### 3.1 타입 정의 (types.ts)
|
||||
|
||||
```typescript
|
||||
additionalJoinInfo?: {
|
||||
sourceTable: string; // 기준 테이블 (예: user_info)
|
||||
sourceColumn: string; // 기준 테이블 FK 컬럼 (예: dept_code)
|
||||
referenceTable?: string; // 참조 테이블 (예: dept_info)
|
||||
joinAlias: string; // 조인 결과 컬럼 별칭 (예: dept_code_company_name)
|
||||
};
|
||||
```
|
||||
|
||||
### 3.2 네이밍 규칙
|
||||
|
||||
- **joinAlias**: `${sourceColumn}_${referenceTable컬럼명}`
|
||||
- 예: `dept_code` + `company_name` → `dept_code_company_name`
|
||||
- 백엔드가 이 규칙으로 SELECT 시 alias를 생성하고, 응답 row에 `dept_code_company_name` 키로 값이 들어옴
|
||||
|
||||
---
|
||||
|
||||
## 4. 백엔드 API 호출 흐름
|
||||
|
||||
### 4.1 TableListComponent 데이터 로딩
|
||||
|
||||
```typescript
|
||||
// 1. additionalJoinInfo가 있는 컬럼만 추출
|
||||
const entityJoinColumns = (tableConfig.columns || [])
|
||||
.filter((col) => col.additionalJoinInfo)
|
||||
.map((col) => ({
|
||||
sourceTable: col.additionalJoinInfo!.sourceTable,
|
||||
sourceColumn: col.additionalJoinInfo!.sourceColumn,
|
||||
joinAlias: col.additionalJoinInfo!.joinAlias,
|
||||
referenceTable: col.additionalJoinInfo!.referenceTable,
|
||||
}));
|
||||
|
||||
// 2. entityDisplayConfig가 있는 컬럼 (isEntityJoin) - 화면별 표시 설정
|
||||
const screenEntityConfigs: Record<string, any> = {};
|
||||
(tableConfig.columns || [])
|
||||
.filter((col) => col.entityDisplayConfig?.displayColumns?.length > 0)
|
||||
.forEach((col) => {
|
||||
screenEntityConfigs[col.columnName] = {
|
||||
displayColumns: col.entityDisplayConfig!.displayColumns,
|
||||
separator: col.entityDisplayConfig!.separator || " - ",
|
||||
sourceTable: col.entityDisplayConfig!.sourceTable || tableConfig.selectedTable,
|
||||
joinTable: col.entityDisplayConfig!.joinTable,
|
||||
};
|
||||
});
|
||||
|
||||
// 3. API 호출
|
||||
response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
|
||||
page, size, sortBy, sortOrder,
|
||||
search: hasFilters ? filters : undefined,
|
||||
enableEntityJoin: true,
|
||||
additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined,
|
||||
screenEntityConfigs: Object.keys(screenEntityConfigs).length > 0 ? screenEntityConfigs : undefined,
|
||||
dataFilter: tableConfig.dataFilter,
|
||||
excludeFilter: excludeFilterParam,
|
||||
});
|
||||
```
|
||||
|
||||
### 4.2 entityJoinApi.getTableDataWithJoins 파라미터
|
||||
|
||||
```typescript
|
||||
additionalJoinColumns?: Array<{
|
||||
sourceTable: string;
|
||||
sourceColumn: string;
|
||||
joinAlias: string;
|
||||
referenceTable?: string; // 백엔드에서 referenceTable로 기존 조인 찾을 때 사용
|
||||
}>;
|
||||
```
|
||||
|
||||
- **전달 방식**: `JSON.stringify(additionalJoinColumns)` 후 쿼리 파라미터로 전달
|
||||
- **백엔드**: `entityJoinController` → `tableManagementService.getTableDataWithEntityJoins`
|
||||
|
||||
### 4.3 백엔드 처리 (tableManagementService)
|
||||
|
||||
1. `detectEntityJoins`로 기본 Entity 조인 설정 조회
|
||||
2. `additionalJoinColumns`가 있으면:
|
||||
- `sourceColumn` 또는 `referenceTable`로 기존 joinConfig 찾기
|
||||
- `joinAlias`에서 실제 컬럼명 추출 (예: `dept_code_company_name` → `company_name`)
|
||||
- 기존 config에 `displayColumns` 병합 또는 새 config 추가
|
||||
- `aliasColumn`: `${sourceColumn}_${actualColumnName}` (예: `dept_code_company_name`)
|
||||
3. `additionalJoinColumns`가 있으면 **full_join** 전략 강제 사용 (캐시 미사용)
|
||||
|
||||
---
|
||||
|
||||
## 5. 데이터 표시 시 조인 데이터 매핑
|
||||
|
||||
### 5.1 additionalJoinInfo 컬럼 (조인 탭에서 추가한 컬럼)
|
||||
|
||||
- **백엔드 응답**: row에 `joinAlias` 키로 값이 직접 들어옴
|
||||
- 예: `row.dept_code_company_name = "개발팀"`
|
||||
- **프론트엔드**: `column.columnName`이 `joinAlias`와 동일하므로 `rowData[column.columnName]`으로 바로 접근
|
||||
- **formatCellValue**: `entityDisplayConfig`가 없으면 일반 컬럼처럼 `value` 사용 (이미 row에 joinAlias로 들어있음)
|
||||
|
||||
### 5.2 entityDisplayConfig 컬럼 (isEntityJoin, 테이블 타입 관리에서 entity 설정된 컬럼)
|
||||
|
||||
- **formatCellValue** 로직:
|
||||
```typescript
|
||||
if (column.entityDisplayConfig && rowData) {
|
||||
const displayColumns = column.entityDisplayConfig.displayColumns;
|
||||
const separator = column.entityDisplayConfig.separator;
|
||||
const values = displayColumns.map((colName) => {
|
||||
const joinedKey = `${column.columnName}_${colName}`; // 예: manager_user_name
|
||||
let cellValue = rowData[joinedKey];
|
||||
if (cellValue == null) cellValue = rowData[colName];
|
||||
return cellValue ?? "";
|
||||
});
|
||||
return values.filter(v => v !== "").join(separator || " - ");
|
||||
}
|
||||
```
|
||||
- **백엔드 alias 규칙**: `${sourceColumn}_${displayColumn}` (예: `manager_user_name`)
|
||||
|
||||
### 5.3 joinedColumnMeta (inputType/category 매핑)
|
||||
|
||||
- additionalJoinInfo 컬럼도 `joinedColumnMeta`에 등록됨
|
||||
- `actualColumn` 추출: `joinAlias.replace(\`${sourceColumn}_\`, "")` → 참조 테이블의 실제 컬럼명
|
||||
- 조인 테이블별로 `tableTypeApi.getColumnInputTypes` 호출하여 inputType 로드
|
||||
|
||||
---
|
||||
|
||||
## 6. entity-join-columns API (ConfigPanel용)
|
||||
|
||||
- **엔드포인트**: `GET /api/table-management/tables/:tableName/entity-join-columns`
|
||||
- **역할**: 화면 편집기에서 "Entity 조인 컬럼" 탭에 표시할 데이터 제공
|
||||
- **응답**:
|
||||
- `joinTables`: 각 Entity 조인별 `joinConfig`, `tableName`, `availableColumns`
|
||||
- `availableColumns`: 모든 조인 컬럼을 flat하게 (joinAlias 포함)
|
||||
- **joinConfig**: `entityJoinService.detectEntityJoins` 결과에서 옴 (테이블 타입 관리의 reference_table 설정 기반)
|
||||
|
||||
---
|
||||
|
||||
## 7. v2-repeater 적용 시 체크리스트
|
||||
|
||||
### ConfigPanel
|
||||
|
||||
- [ ] `entityJoinApi.getEntityJoinColumns(targetTableName)` 호출
|
||||
- [ ] `entityJoinColumns` 상태 (availableColumns, joinTables)
|
||||
- [ ] "Entity 조인 컬럼" UI 섹션 (joinTables.length > 0일 때)
|
||||
- [ ] `addEntityColumn` 함수: `joinConfig.sourceColumn` 사용
|
||||
- [ ] RepeaterColumnConfig에 `additionalJoinInfo` 타입 추가
|
||||
|
||||
### 데이터 로딩 (RepeaterComponent)
|
||||
|
||||
- [ ] `additionalJoinInfo`가 있는 컬럼 추출 → `entityJoinColumns` 배열 생성
|
||||
- [ ] `entityJoinApi.getTableDataWithJoins` 호출 시 `additionalJoinColumns` 전달
|
||||
- [ ] `entityDisplayConfig`가 있으면 `screenEntityConfigs`에도 포함 (isEntityJoin 컬럼용)
|
||||
|
||||
### 셀 렌더링
|
||||
|
||||
- [ ] additionalJoinInfo 컬럼: `rowData[column.columnName]` (joinAlias와 동일)
|
||||
- [ ] entityDisplayConfig 컬럼: displayColumns + separator로 조합, `joinedKey = ${columnName}_${colName}`
|
||||
|
||||
### 타입 정의
|
||||
|
||||
- [ ] `RepeaterColumnConfig`에 `additionalJoinInfo?: { sourceTable, sourceColumn, referenceTable, joinAlias }` 추가
|
||||
|
||||
---
|
||||
|
||||
## 8. 참고 파일
|
||||
|
||||
| 파일 | 용도 |
|
||||
|------|------|
|
||||
| `frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx` | Entity 조인 UI, addEntityColumn |
|
||||
| `frontend/lib/registry/components/v2-table-list/TableListComponent.tsx` | 데이터 로딩, formatCellValue |
|
||||
| `frontend/lib/registry/components/v2-table-list/types.ts` | additionalJoinInfo 타입 |
|
||||
| `frontend/lib/api/entityJoin.ts` | getTableDataWithJoins, getEntityJoinColumns |
|
||||
| `backend-node/src/controllers/entityJoinController.ts` | entity-join-columns, data-with-joins |
|
||||
| `backend-node/src/services/tableManagementService.ts` | additionalJoinColumns 병합 로직 |
|
||||
Reference in New Issue
Block a user