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:
kjs
2026-04-01 12:35:40 +09:00
parent ccb0c8df4c
commit 8be4159f17
37 changed files with 2 additions and 1 deletions
+620
View File
@@ -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
```
데이터가 없으면 "좌측 테이블에 데이터가 없습니다" 오류가 발생합니다.
+214
View File
@@ -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곳 수정.
+78
View File
@@ -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]로 표시
- 현재 진행 중인 테스트는 [진행중]으로 표시
+194
View File
@@ -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]로 표시
- 현재 진행 중인 테스트는 [진행중]으로 표시
+176
View File
@@ -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) 필터링 확인
+573
View File
@@ -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 병합 로직 |