Files
vexplor/docs/기간별_단가_설정_가이드.md
kjs e1a5befdf7 feat: 기간별 단가 설정 기능 구현 - 자동 계산 시스템
- 선택항목 상세입력 컴포넌트 확장
  - 실시간 가격 계산 기능 추가 (할인율/할인금액, 반올림 방식)
  - 카테고리 값 기반 연산 매핑 시스템
  - 3단계 드릴다운 방식 설정 UI (메뉴 → 카테고리 → 값 매핑)

- 설정 가능한 계산 로직
  - autoCalculation 설정으로 계산 필드명 동적 지정
  - valueMapping으로 카테고리 코드와 연산 타입 매핑
  - 할인 방식: none/rate/amount
  - 반올림 방식: none/round/floor/ceil
  - 반올림 단위: 1/10/100/1000

- UI 개선
  - 입력 필드 가로 배치 (반응형 Grid)
  - 카테고리 타입 필드 옵션 로딩 개선
  - 계산 결과 필드 자동 표시 및 읽기 전용 처리
  - 날짜 입력 필드 네이티브 피커 지원

- API 연동
  - 2레벨 메뉴 목록 조회
  - 메뉴별 카테고리 컬럼 조회
  - 카테고리별 값 목록 조회

- 문서화
  - 기간별 단가 설정 가이드 작성
2025-11-18 16:12:47 +09:00

12 KiB

기간별 단가 설정 시스템 구현 가이드

개요

선택항목 상세입력(selected-items-detail-input) 컴포넌트를 활용하여 기간별 단가를 설정하는 범용 시스템입니다.

데이터베이스 설계

1. 마이그레이션 실행

# 마이그레이션 파일 위치
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. 품목 선택 모달 (이전 화면)

// 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. 기간별 단가 설정 화면

// 선택항목 상세입력 컴포넌트가 자동으로 처리
// 1. URL 파라미터에서 dataSourceId 읽기
// 2. modalDataStore에서 item_info 데이터 가져오기
// 3. 사용자가 그룹별로 여러 개의 기간별 단가 입력
// 4. 저장 버튼 클릭 시 customer_item_mapping 테이블에 저장

3. 저장 데이터 구조

하나의 품목(item_id = "ITEM001")에 대해 3개의 기간별 단가를 입력한 경우:

-- 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를 자동 계산:

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 구현 (필요시)

기간별 단가 조회

// 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 });
});

기간별 단가 저장

// 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