383 lines
12 KiB
Markdown
383 lines
12 KiB
Markdown
# 기간별 단가 설정 시스템 구현 가이드
|
|
|
|
## 개요
|
|
|
|
**선택항목 상세입력(selected-items-detail-input)** 컴포넌트를 활용하여 기간별 단가를 설정하는 범용 시스템입니다.
|
|
|
|
## 데이터베이스 설계
|
|
|
|
### 1. 마이그레이션 실행
|
|
|
|
```bash
|
|
# 마이그레이션 파일 위치
|
|
db/migrations/999_add_period_price_columns_to_customer_item_mapping.sql
|
|
|
|
# 실행 (로컬)
|
|
npm run migrate:local
|
|
|
|
# 또는 수동 실행
|
|
psql -U your_user -d erp_db -f db/migrations/999_add_period_price_columns_to_customer_item_mapping.sql
|
|
```
|
|
|
|
### 2. 추가된 컬럼들
|
|
|
|
| 컬럼명 | 타입 | 설명 | 사진 항목 |
|
|
|--------|------|------|-----------|
|
|
| `start_date` | DATE | 기간 시작일 | ✅ 시작일 DatePicker |
|
|
| `end_date` | DATE | 기간 종료일 | ✅ 종료일 DatePicker |
|
|
| `discount_type` | VARCHAR(50) | 할인 방식 | ✅ 할인율/할인금액 Select |
|
|
| `discount_value` | NUMERIC(15,2) | 할인율 또는 할인금액 | ✅ 숫자 입력 |
|
|
| `rounding_type` | VARCHAR(50) | 반올림 방식 | ✅ 반올림/절삭/올림 Select |
|
|
| `rounding_unit_value` | VARCHAR(50) | 반올림 단위 | ✅ 1원/10원/100원/1,000원 Select |
|
|
| `calculated_price` | NUMERIC(15,2) | 계산된 최종 단가 | ✅ 계산 결과 표시 |
|
|
| `is_base_price` | BOOLEAN | 기준단가 여부 | ✅ 기준단가 Checkbox |
|
|
|
|
## 화면 편집기 설정 방법
|
|
|
|
### Step 1: 선택항목 상세입력 컴포넌트 추가
|
|
|
|
1. 화면 편집기에서 "선택항목 상세입력" 컴포넌트를 캔버스에 드래그앤드롭
|
|
2. 컴포넌트 ID: `customer-item-price-periods`
|
|
|
|
### Step 2: 데이터 소스 설정
|
|
|
|
- **원본 데이터 테이블**: `item_info` (품목 정보)
|
|
- **저장 대상 테이블**: `customer_item_mapping`
|
|
- **데이터 소스 ID**: URL 파라미터에서 자동 설정 (Button 컴포넌트가 전달)
|
|
|
|
### Step 3: 표시할 원본 데이터 컬럼 설정
|
|
|
|
이전 화면(품목 선택 모달)에서 전달받은 품목 정보를 표시:
|
|
|
|
```
|
|
컬럼1: item_code (품목코드)
|
|
컬럼2: item_name (품목명)
|
|
컬럼3: spec (규격)
|
|
```
|
|
|
|
### Step 4: 필드 그룹 2개 생성
|
|
|
|
#### 그룹 1: 거래처 품목/품명 관리 (group_customer)
|
|
|
|
| 필드명 | 라벨 | 타입 | 설명 |
|
|
|--------|------|------|------|
|
|
| `customer_item_code` | 거래처 품번 | text | 거래처에서 사용하는 품번 |
|
|
| `customer_item_name` | 거래처 품명 | text | 거래처에서 사용하는 품명 |
|
|
|
|
#### 그룹 2: 기간별 단가 설정 (group_period_price)
|
|
|
|
| 필드명 | 라벨 | 타입 | 자동 채우기 | 설명 |
|
|
|--------|------|------|-------------|------|
|
|
| `start_date` | 시작일 | date | - | 단가 적용 시작일 |
|
|
| `end_date` | 종료일 | date | - | 단가 적용 종료일 (NULL이면 무기한) |
|
|
| `current_unit_price` | 단가 | number | `item_info.standard_price` | 기본 단가 (품목에서 자동 채우기) |
|
|
| `currency_code` | 통화 | code/category | - | 통화 코드 (KRW, USD 등) |
|
|
| `discount_type` | 할인 방식 | code/category | - | 할인율없음/할인율(%)/할인금액 |
|
|
| `discount_value` | 할인값 | number | - | 할인율(5) 또는 할인금액 |
|
|
| `rounding_type` | 반올림 방식 | code/category | - | 반올림없음/반올림/절삭/올림 |
|
|
| `rounding_unit_value` | 반올림 단위 | code/category | - | 1원/10원/100원/1,000원 |
|
|
| `calculated_price` | 최종 단가 | number | - | 계산된 최종 단가 (읽기 전용) |
|
|
| `is_base_price` | 기준단가 | checkbox | - | 기준단가 여부 |
|
|
|
|
### Step 5: 그룹별 표시 항목 설정 (DisplayItems)
|
|
|
|
**그룹 2 (기간별 단가 설정)의 표시 설정:**
|
|
|
|
```
|
|
1. [필드] start_date | 라벨: "" | 형식: date | 빈 값: 기본값 (미설정)
|
|
2. [텍스트] " ~ "
|
|
3. [필드] end_date | 라벨: "" | 형식: date | 빈 값: 기본값 (무기한)
|
|
4. [텍스트] " | "
|
|
5. [필드] calculated_price | 라벨: "" | 형식: currency | 빈 값: 기본값 (계산 중)
|
|
6. [텍스트] " "
|
|
7. [필드] currency_code | 라벨: "" | 형식: text | 빈 값: 기본값 (KRW)
|
|
8. [조건] is_base_price가 true이면 → [배지] "기준단가" (variant: default)
|
|
```
|
|
|
|
**렌더링 예시:**
|
|
```
|
|
2024-01-01 ~ 2024-06-30 | 50,000 KRW [기준단가]
|
|
2024-07-01 ~ 무기한 | 55,000 KRW
|
|
```
|
|
|
|
## 데이터 흐름
|
|
|
|
### 1. 품목 선택 모달 (이전 화면)
|
|
|
|
```tsx
|
|
// TableList 컴포넌트에서 품목 선택
|
|
<Button
|
|
onClick={() => {
|
|
const selectedItems = tableData.filter(item => selectedRowIds.includes(item.id));
|
|
|
|
// modalDataStore에 데이터 저장
|
|
useModalDataStore.getState().setData("item_info", selectedItems);
|
|
|
|
// 다음 화면으로 이동 (dataSourceId 전달)
|
|
router.push("/screen/period-price-settings?dataSourceId=item_info");
|
|
}}
|
|
>
|
|
다음
|
|
</Button>
|
|
```
|
|
|
|
### 2. 기간별 단가 설정 화면
|
|
|
|
```tsx
|
|
// 선택항목 상세입력 컴포넌트가 자동으로 처리
|
|
// 1. URL 파라미터에서 dataSourceId 읽기
|
|
// 2. modalDataStore에서 item_info 데이터 가져오기
|
|
// 3. 사용자가 그룹별로 여러 개의 기간별 단가 입력
|
|
// 4. 저장 버튼 클릭 시 customer_item_mapping 테이블에 저장
|
|
```
|
|
|
|
### 3. 저장 데이터 구조
|
|
|
|
**하나의 품목(item_id = "ITEM001")에 대해 3개의 기간별 단가를 입력한 경우:**
|
|
|
|
```sql
|
|
-- customer_item_mapping 테이블에 3개의 행으로 저장
|
|
INSERT INTO customer_item_mapping (
|
|
customer_id, item_id,
|
|
customer_item_code, customer_item_name,
|
|
start_date, end_date,
|
|
current_unit_price, currency_code,
|
|
discount_type, discount_value,
|
|
rounding_type, rounding_unit_value,
|
|
calculated_price, is_base_price
|
|
) VALUES
|
|
-- 첫 번째 기간 (기준단가)
|
|
('CUST001', 'ITEM001',
|
|
'CUST-A-001', '실리콘 고무 시트',
|
|
'2024-01-01', '2024-06-30',
|
|
50000, 'KRW',
|
|
'할인율없음', 0,
|
|
'반올림', '100원',
|
|
50000, true),
|
|
|
|
-- 두 번째 기간
|
|
('CUST001', 'ITEM001',
|
|
'CUST-A-001', '실리콘 고무 시트',
|
|
'2024-07-01', '2024-12-31',
|
|
50000, 'KRW',
|
|
'할인율(%)', 5,
|
|
'절삭', '1원',
|
|
47500, false),
|
|
|
|
-- 세 번째 기간 (무기한)
|
|
('CUST001', 'ITEM001',
|
|
'CUST-A-001', '실리콘 고무 시트',
|
|
'2025-01-01', NULL,
|
|
50000, 'KRW',
|
|
'할인금액', 3000,
|
|
'올림', '1000원',
|
|
47000, false);
|
|
```
|
|
|
|
## 계산 로직 (선택사항)
|
|
|
|
단가 계산을 자동화하려면 프론트엔드에서 `calculated_price`를 자동 계산:
|
|
|
|
```typescript
|
|
const calculatePrice = (
|
|
basePrice: number,
|
|
discountType: string,
|
|
discountValue: number,
|
|
roundingType: string,
|
|
roundingUnit: string
|
|
): number => {
|
|
let price = basePrice;
|
|
|
|
// 1단계: 할인 적용
|
|
if (discountType === "할인율(%)") {
|
|
price = price * (1 - discountValue / 100);
|
|
} else if (discountType === "할인금액") {
|
|
price = price - discountValue;
|
|
}
|
|
|
|
// 2단계: 반올림 적용
|
|
const unitMap: Record<string, number> = {
|
|
"1원": 1,
|
|
"10원": 10,
|
|
"100원": 100,
|
|
"1,000원": 1000,
|
|
};
|
|
|
|
const unit = unitMap[roundingUnit] || 1;
|
|
|
|
if (roundingType === "반올림") {
|
|
price = Math.round(price / unit) * unit;
|
|
} else if (roundingType === "절삭") {
|
|
price = Math.floor(price / unit) * unit;
|
|
} else if (roundingType === "올림") {
|
|
price = Math.ceil(price / unit) * unit;
|
|
}
|
|
|
|
return price;
|
|
};
|
|
|
|
// 필드 변경 시 자동 계산
|
|
useEffect(() => {
|
|
const calculatedPrice = calculatePrice(
|
|
basePrice,
|
|
discountType,
|
|
discountValue,
|
|
roundingType,
|
|
roundingUnit
|
|
);
|
|
|
|
// calculated_price 필드 업데이트
|
|
handleFieldChange(itemId, groupId, entryId, "calculated_price", calculatedPrice);
|
|
}, [basePrice, discountType, discountValue, roundingType, roundingUnit]);
|
|
```
|
|
|
|
## 백엔드 API 구현 (필요시)
|
|
|
|
### 기간별 단가 조회
|
|
|
|
```typescript
|
|
// GET /api/customer-item/price-periods?customer_id=CUST001&item_id=ITEM001
|
|
router.get("/price-periods", async (req, res) => {
|
|
const { customer_id, item_id } = req.query;
|
|
const companyCode = req.user!.companyCode;
|
|
|
|
const query = `
|
|
SELECT * FROM customer_item_mapping
|
|
WHERE customer_id = $1
|
|
AND item_id = $2
|
|
AND company_code = $3
|
|
ORDER BY start_date ASC
|
|
`;
|
|
|
|
const result = await pool.query(query, [customer_id, item_id, companyCode]);
|
|
|
|
return res.json({ success: true, data: result.rows });
|
|
});
|
|
```
|
|
|
|
### 기간별 단가 저장
|
|
|
|
```typescript
|
|
// POST /api/customer-item/price-periods
|
|
router.post("/price-periods", async (req, res) => {
|
|
const { items } = req.body; // 선택항목 상세입력 컴포넌트에서 전달
|
|
const companyCode = req.user!.companyCode;
|
|
|
|
const client = await pool.connect();
|
|
|
|
try {
|
|
await client.query("BEGIN");
|
|
|
|
for (const item of items) {
|
|
// item.fieldGroups.group_period_price 배열의 각 항목을 INSERT
|
|
const periodPrices = item.fieldGroups.group_period_price || [];
|
|
|
|
for (const periodPrice of periodPrices) {
|
|
const query = `
|
|
INSERT INTO customer_item_mapping (
|
|
company_code, customer_id, item_id,
|
|
customer_item_code, customer_item_name,
|
|
start_date, end_date,
|
|
current_unit_price, currency_code,
|
|
discount_type, discount_value,
|
|
rounding_type, rounding_unit_value,
|
|
calculated_price, is_base_price
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
|
`;
|
|
|
|
await client.query(query, [
|
|
companyCode,
|
|
item.originalData.customer_id,
|
|
item.originalData.item_id,
|
|
periodPrice.customer_item_code,
|
|
periodPrice.customer_item_name,
|
|
periodPrice.start_date,
|
|
periodPrice.end_date || null,
|
|
periodPrice.current_unit_price,
|
|
periodPrice.currency_code,
|
|
periodPrice.discount_type,
|
|
periodPrice.discount_value,
|
|
periodPrice.rounding_type,
|
|
periodPrice.rounding_unit_value,
|
|
periodPrice.calculated_price,
|
|
periodPrice.is_base_price
|
|
]);
|
|
}
|
|
}
|
|
|
|
await client.query("COMMIT");
|
|
|
|
return res.json({ success: true, message: "기간별 단가가 저장되었습니다." });
|
|
} catch (error) {
|
|
await client.query("ROLLBACK");
|
|
console.error("기간별 단가 저장 실패:", error);
|
|
return res.status(500).json({ success: false, error: "저장 실패" });
|
|
} finally {
|
|
client.release();
|
|
}
|
|
});
|
|
```
|
|
|
|
## 사용 시나리오 예시
|
|
|
|
### 시나리오 1: 거래처별 단가 관리
|
|
|
|
1. 거래처 선택 모달 → 거래처 선택 → 다음
|
|
2. 품목 선택 모달 → 품목 여러 개 선택 → 다음
|
|
3. **기간별 단가 설정 화면**
|
|
- 품목1 (실리콘 고무 시트)
|
|
- **그룹1 추가**: 거래처 품번: CUST-A-001, 품명: 실리콘 시트
|
|
- **그룹2 추가**: 2024-01-01 ~ 2024-06-30, 50,000원 (기준단가)
|
|
- **그룹2 추가**: 2024-07-01 ~ 무기한, 할인율 5% → 47,500원
|
|
- 품목2 (스테인리스 판)
|
|
- **그룹1 추가**: 거래처 품번: CUST-A-002, 품명: SUS304 판
|
|
- **그룹2 추가**: 2024-01-01 ~ 무기한, 150,000원 (기준단가)
|
|
4. 저장 버튼 클릭 → customer_item_mapping 테이블에 4개 행 저장
|
|
|
|
### 시나리오 2: 단순 단가 입력
|
|
|
|
필드 그룹을 사용하지 않고 단일 입력도 가능:
|
|
|
|
```
|
|
그룹 없이 필드 정의:
|
|
- customer_item_code
|
|
- customer_item_name
|
|
- current_unit_price
|
|
- currency_code
|
|
|
|
→ 각 품목당 1개의 행만 저장
|
|
```
|
|
|
|
## 장점
|
|
|
|
### 1. 범용성
|
|
- 기간별 단가뿐만 아니라 **모든 숫자 계산 시나리오**에 적용 가능
|
|
- 견적서, 발주서, 판매 단가, 구매 단가 등
|
|
|
|
### 2. 유연성
|
|
- 필드 그룹으로 자유롭게 섹션 구성
|
|
- 표시 항목 설정으로 UI 커스터마이징
|
|
|
|
### 3. 데이터 무결성
|
|
- 1:N 관계로 여러 기간별 데이터 관리
|
|
- 기간 중복 체크는 백엔드에서 처리
|
|
|
|
### 4. 사용자 경험
|
|
- 품목별로 여러 개의 기간별 단가를 손쉽게 입력
|
|
- 입력 완료 후 작은 카드로 요약 표시
|
|
|
|
## 다음 단계
|
|
|
|
1. **마이그레이션 실행** (999_add_period_price_columns_to_customer_item_mapping.sql)
|
|
2. **화면 편집기에서 설정** (위 Step 1~5 참고)
|
|
3. **백엔드 API 구현** (저장/조회 엔드포인트)
|
|
4. **계산 로직 추가** (선택사항: 자동 계산)
|
|
5. **테스트** (품목 선택 → 기간별 단가 입력 → 저장 → 조회)
|
|
|
|
## 참고 자료
|
|
|
|
- 선택항목 상세입력 컴포넌트: `frontend/lib/registry/components/selected-items-detail-input/`
|
|
- 타입 정의: `frontend/lib/registry/components/selected-items-detail-input/types.ts`
|
|
- 설정 패널: `SelectedItemsDetailInputConfigPanel.tsx`
|
|
|