docs: HR API-React 동기화 스펙 문서 업데이트
- API snake_case 유지, React에서 camelCase 변환 방식으로 변경 - 백엔드 Resource 클래스 작업 취소 - 프론트엔드 변환 유틸리티 가이드 추가 - 타입 정의 (내부용 + API 응답용) 예시 포함
This commit is contained in:
402
rules/pricing-policy.md
Normal file
402
rules/pricing-policy.md
Normal file
@@ -0,0 +1,402 @@
|
||||
# 단가 정책 (Pricing Policy)
|
||||
|
||||
> **작성일**: 2025-12-08
|
||||
> **상태**: 설계 확정
|
||||
> **관련 요청**: `docs/front/[API-2025-12-08] pricing-api-enhancement-request.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 개요
|
||||
|
||||
### 1.1 목적
|
||||
- 품목(제품/자재)의 **표준단가** 관리
|
||||
- **실제 입고단가** 기반 원가 계산
|
||||
- 단가 변경 **이력(리비전)** 추적
|
||||
- 고객그룹별 **차등 판매가** 지원
|
||||
|
||||
### 1.2 핵심 원칙
|
||||
|
||||
| 원칙 | 설명 |
|
||||
|------|------|
|
||||
| **실제원가 우선** | 수입검사 입고단가 > 표준원가 |
|
||||
| **시점 기반 유효성** | effective_from ~ effective_to 기간 내 유효 |
|
||||
| **리비전 추적** | 모든 변경 사항 이력 보관 |
|
||||
| **확정 후 불변** | finalized 상태는 수정 불가 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 테이블 구조
|
||||
|
||||
### 2.1 ERD 개요
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────────┐
|
||||
│ prices │──────<│ price_revisions │
|
||||
│ (단가 마스터) │ │ (변경 이력) │
|
||||
└────────┬────────┘ └─────────────────────┘
|
||||
│
|
||||
│ item_type_code + item_id
|
||||
│
|
||||
┌────┴────┐
|
||||
│ │
|
||||
┌───▼───┐ ┌───▼─────┐
|
||||
│products│ │materials│
|
||||
└───────┘ └────┬────┘
|
||||
│
|
||||
│ material_id
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│material_receipts│
|
||||
│ (실제 입고단가) │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 prices 테이블 (단가 마스터)
|
||||
|
||||
```sql
|
||||
CREATE TABLE prices (
|
||||
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '테넌트 ID',
|
||||
|
||||
-- 품목 연결 --
|
||||
item_type_code VARCHAR(20) NOT NULL COMMENT '품목유형 (PRODUCT/MATERIAL)',
|
||||
item_id BIGINT UNSIGNED NOT NULL COMMENT '품목 ID',
|
||||
client_group_id BIGINT UNSIGNED NULL COMMENT '고객그룹 ID (NULL=기본가)',
|
||||
|
||||
-- 원가 정보 --
|
||||
purchase_price DECIMAL(15,4) NULL COMMENT '매입단가 (표준원가)',
|
||||
processing_cost DECIMAL(15,4) NULL COMMENT '가공비',
|
||||
loss_rate DECIMAL(5,2) NULL COMMENT 'LOSS율 (%)',
|
||||
|
||||
-- 판매가 정보 --
|
||||
margin_rate DECIMAL(5,2) NULL COMMENT '마진율 (%)',
|
||||
sales_price DECIMAL(15,4) NULL COMMENT '판매단가',
|
||||
rounding_rule ENUM('round','ceil','floor') DEFAULT 'round' COMMENT '반올림 규칙',
|
||||
rounding_unit INT DEFAULT 1 COMMENT '반올림 단위 (1,10,100,1000)',
|
||||
|
||||
-- 메타 정보 --
|
||||
supplier VARCHAR(255) NULL COMMENT '공급업체',
|
||||
effective_from DATE NOT NULL COMMENT '적용 시작일',
|
||||
effective_to DATE NULL COMMENT '적용 종료일',
|
||||
note TEXT NULL COMMENT '비고',
|
||||
|
||||
-- 상태 관리 --
|
||||
status ENUM('draft','active','inactive','finalized') DEFAULT 'draft' COMMENT '상태',
|
||||
is_final BOOLEAN DEFAULT FALSE COMMENT '최종 확정 여부',
|
||||
finalized_at DATETIME NULL COMMENT '확정 일시',
|
||||
finalized_by BIGINT UNSIGNED NULL COMMENT '확정자 ID',
|
||||
|
||||
-- 감사 컬럼 --
|
||||
created_by BIGINT UNSIGNED NULL COMMENT '생성자 ID',
|
||||
updated_by BIGINT UNSIGNED NULL COMMENT '수정자 ID',
|
||||
deleted_by BIGINT UNSIGNED NULL COMMENT '삭제자 ID',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP NULL COMMENT 'Soft Delete',
|
||||
|
||||
-- 인덱스 --
|
||||
INDEX idx_prices_tenant (tenant_id),
|
||||
INDEX idx_prices_item (tenant_id, item_type_code, item_id),
|
||||
INDEX idx_prices_effective (tenant_id, effective_from, effective_to),
|
||||
INDEX idx_prices_status (tenant_id, status),
|
||||
UNIQUE idx_prices_unique (tenant_id, item_type_code, item_id, client_group_id, effective_from, deleted_at)
|
||||
) COMMENT='단가 마스터';
|
||||
```
|
||||
|
||||
### 2.3 price_revisions 테이블 (변경 이력)
|
||||
|
||||
```sql
|
||||
CREATE TABLE price_revisions (
|
||||
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '테넌트 ID',
|
||||
price_id BIGINT UNSIGNED NOT NULL COMMENT '단가 ID',
|
||||
|
||||
-- 리비전 정보 --
|
||||
revision_number INT NOT NULL COMMENT '리비전 번호',
|
||||
changed_at DATETIME NOT NULL COMMENT '변경 일시',
|
||||
changed_by BIGINT UNSIGNED NOT NULL COMMENT '변경자 ID',
|
||||
change_reason VARCHAR(500) NULL COMMENT '변경 사유',
|
||||
|
||||
-- 변경 스냅샷 (JSON) --
|
||||
before_snapshot JSON NULL COMMENT '변경 전 데이터',
|
||||
after_snapshot JSON NOT NULL COMMENT '변경 후 데이터',
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- 인덱스 --
|
||||
INDEX idx_revisions_price (price_id),
|
||||
INDEX idx_revisions_tenant (tenant_id),
|
||||
UNIQUE idx_revisions_unique (price_id, revision_number),
|
||||
|
||||
FOREIGN KEY (price_id) REFERENCES prices(id) ON DELETE CASCADE
|
||||
) COMMENT='단가 변경 이력';
|
||||
```
|
||||
|
||||
### 2.4 material_receipts 테이블 (기존 - 실제 입고단가)
|
||||
|
||||
```sql
|
||||
-- 기존 테이블, purchase_price_excl_vat 필드 활용
|
||||
-- 수입검사 시 실제 입고 단가 입력
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 원가 계산 정책
|
||||
|
||||
### 3.1 원가 조회 우선순위
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 원가 조회 로직 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 1순위: 수입검사 입고단가 │
|
||||
│ material_receipts.purchase_price_excl_vat │
|
||||
│ (가장 최근 입고 기준) │
|
||||
│ │
|
||||
│ 2순위: 표준원가 │
|
||||
│ prices.purchase_price │
|
||||
│ (해당 일자에 유효한 단가) │
|
||||
│ │
|
||||
│ 3순위: NULL (단가 미등록) │
|
||||
│ → 경고 메시지 반환 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 원가 계산 공식
|
||||
|
||||
```
|
||||
총원가 = (매입단가 + 가공비) × (1 + LOSS율/100)
|
||||
|
||||
예시:
|
||||
- 매입단가: 10,000원
|
||||
- 가공비: 2,000원
|
||||
- LOSS율: 5%
|
||||
- 총원가 = (10,000 + 2,000) × 1.05 = 12,600원
|
||||
```
|
||||
|
||||
### 3.3 품목 유형별 원가 적용
|
||||
|
||||
| 품목 유형 | 원가 소스 | 설명 |
|
||||
|----------|----------|------|
|
||||
| **MATERIAL** (자재) | material_receipts 우선 | 실제 입고단가 사용 |
|
||||
| **PRODUCT** (제품) | prices 테이블 | 표준원가 사용 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 판매가 계산 정책
|
||||
|
||||
### 4.1 판매가 계산 공식
|
||||
|
||||
```
|
||||
판매단가 = 반올림(총원가 × (1 + 마진율/100), 반올림단위, 반올림규칙)
|
||||
|
||||
예시:
|
||||
- 총원가: 12,600원
|
||||
- 마진율: 25%
|
||||
- 반올림단위: 100
|
||||
- 반올림규칙: round
|
||||
- 판매단가 = round(12,600 × 1.25, 100) = round(15,750, 100) = 15,800원
|
||||
```
|
||||
|
||||
### 4.2 반올림 규칙
|
||||
|
||||
| 규칙 | 설명 | 예시 (단위: 100) |
|
||||
|------|------|-----------------|
|
||||
| `round` | 반올림 | 15,750 → 15,800 |
|
||||
| `ceil` | 올림 | 15,701 → 15,800 |
|
||||
| `floor` | 버림 | 15,799 → 15,700 |
|
||||
|
||||
### 4.3 고객그룹별 차등가
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 판매가 조회 우선순위 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 1순위: 고객그룹별 특별가 │
|
||||
│ prices WHERE client_group_id = [고객의 그룹ID] │
|
||||
│ │
|
||||
│ 2순위: 기본 판매가 │
|
||||
│ prices WHERE client_group_id IS NULL │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 상태 관리
|
||||
|
||||
### 5.1 단가 상태 (status)
|
||||
|
||||
```
|
||||
┌──────────┐ 등록 ┌──────────┐ 활성화 ┌──────────┐
|
||||
│ draft │ ──────────> │ active │ ─────────── │ finalized│
|
||||
│ (초안) │ │ (활성) │ 확정 │ (확정) │
|
||||
└──────────┘ └────┬─────┘ └──────────┘
|
||||
│
|
||||
│ 비활성화
|
||||
▼
|
||||
┌──────────┐
|
||||
│ inactive │
|
||||
│ (비활성) │
|
||||
└──────────┘
|
||||
```
|
||||
|
||||
### 5.2 상태별 권한
|
||||
|
||||
| 상태 | 조회 | 수정 | 삭제 | 확정 |
|
||||
|------|------|------|------|------|
|
||||
| `draft` | ✅ | ✅ | ✅ | ✅ |
|
||||
| `active` | ✅ | ✅ | ✅ | ✅ |
|
||||
| `inactive` | ✅ | ✅ | ✅ | ❌ |
|
||||
| `finalized` | ✅ | ❌ | ❌ | ❌ |
|
||||
|
||||
### 5.3 확정 (Finalize) 규칙
|
||||
|
||||
- **확정 조건**: status가 `draft` 또는 `active`일 때만 가능
|
||||
- **확정 효과**:
|
||||
- `is_final = true`
|
||||
- `status = 'finalized'`
|
||||
- `finalized_at = NOW()`
|
||||
- `finalized_by = 현재 사용자`
|
||||
- **확정 후**: 수정/삭제 불가, 조회만 가능
|
||||
|
||||
---
|
||||
|
||||
## 6. 리비전 관리
|
||||
|
||||
### 6.1 리비전 생성 시점
|
||||
|
||||
| 이벤트 | 리비전 생성 | 설명 |
|
||||
|--------|-----------|------|
|
||||
| 최초 등록 | ✅ | revision_number = 1, before_snapshot = NULL |
|
||||
| 수정 | ✅ | revision_number++, 변경 전/후 기록 |
|
||||
| 삭제 | ✅ | soft delete, 삭제 전 상태 기록 |
|
||||
| 확정 | ✅ | 확정 시점 스냅샷 기록 |
|
||||
|
||||
### 6.2 스냅샷 JSON 구조
|
||||
|
||||
```json
|
||||
{
|
||||
"purchase_price": 10000,
|
||||
"processing_cost": 2000,
|
||||
"loss_rate": 5.0,
|
||||
"margin_rate": 25.0,
|
||||
"sales_price": 15800,
|
||||
"effective_from": "2025-01-01",
|
||||
"effective_to": null,
|
||||
"status": "active",
|
||||
"supplier": "ABC공급"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. API 엔드포인트
|
||||
|
||||
### 7.1 엔드포인트 목록
|
||||
|
||||
| Method | Endpoint | 설명 | 우선순위 |
|
||||
|--------|----------|------|---------|
|
||||
| GET | `/api/v1/pricing` | 단가 목록 조회 | 🔴 필수 |
|
||||
| GET | `/api/v1/pricing/{id}` | 단가 상세 조회 | 🔴 필수 |
|
||||
| POST | `/api/v1/pricing` | 단가 등록 | 🔴 필수 |
|
||||
| PUT | `/api/v1/pricing/{id}` | 단가 수정 | 🔴 필수 |
|
||||
| DELETE | `/api/v1/pricing/{id}` | 단가 삭제 | 🔴 필수 |
|
||||
| GET | `/api/v1/pricing/by-items` | 품목별 단가 현황 | 🔴 필수 |
|
||||
| GET | `/api/v1/pricing/{id}/revisions` | 변경 이력 조회 | 🟡 중요 |
|
||||
| POST | `/api/v1/pricing/{id}/finalize` | 단가 확정 | 🟢 권장 |
|
||||
| GET | `/api/v1/pricing/cost` | 원가 조회 (계산) | 🟡 중요 |
|
||||
|
||||
### 7.2 원가 조회 API
|
||||
|
||||
```
|
||||
GET /api/v1/pricing/cost?item_type=MATERIAL&item_id=123&date=2025-01-15
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"item_type": "MATERIAL",
|
||||
"item_id": 123,
|
||||
"cost_source": "receipt", // "receipt" | "standard" | "not_found"
|
||||
"purchase_price": 10500,
|
||||
"receipt_id": 456, // cost_source가 receipt일 때
|
||||
"receipt_date": "2025-01-10",
|
||||
"price_id": null // cost_source가 standard일 때
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 비즈니스 규칙
|
||||
|
||||
### 8.1 검증 규칙
|
||||
|
||||
| 규칙 | 설명 |
|
||||
|------|------|
|
||||
| **R1** | effective_from은 필수 |
|
||||
| **R2** | effective_from ≤ effective_to (종료일이 있을 경우) |
|
||||
| **R3** | 동일 품목+고객그룹+적용시작일 조합은 중복 불가 |
|
||||
| **R4** | 확정된 단가는 수정/삭제 불가 |
|
||||
| **R5** | 마진율은 0~100% 범위 |
|
||||
| **R6** | LOSS율은 0~100% 범위 |
|
||||
| **R7** | 반올림단위는 1, 10, 100, 1000 중 하나 |
|
||||
|
||||
### 8.2 기간 중복 처리
|
||||
|
||||
```
|
||||
기존: 2025-01-01 ~ NULL (무기한)
|
||||
신규: 2025-06-01 ~ NULL
|
||||
|
||||
→ 기존 단가의 effective_to를 2025-05-31로 자동 설정
|
||||
→ 신규 단가 등록
|
||||
```
|
||||
|
||||
### 8.3 삭제 정책
|
||||
|
||||
- **Soft Delete** 적용
|
||||
- 삭제 시 `deleted_at`, `deleted_by` 기록
|
||||
- 삭제된 단가도 리비전 이력에서 조회 가능
|
||||
|
||||
---
|
||||
|
||||
## 9. 기존 시스템 호환
|
||||
|
||||
### 9.1 price_histories 테이블 처리
|
||||
|
||||
| 상태 | 설명 |
|
||||
|------|------|
|
||||
| **현재** | 기존 price_histories 데이터 유지 |
|
||||
| **전환 기간** | 양쪽 테이블 병행 운영 |
|
||||
| **전환 완료 후** | price_histories deprecated |
|
||||
|
||||
### 9.2 데이터 마이그레이션
|
||||
|
||||
```sql
|
||||
-- 기존 데이터를 prices 테이블로 마이그레이션
|
||||
INSERT INTO prices (
|
||||
tenant_id, item_type_code, item_id, client_group_id,
|
||||
purchase_price, effective_from, effective_to,
|
||||
status, created_by, created_at
|
||||
)
|
||||
SELECT
|
||||
tenant_id, item_type_code, item_id, client_group_id,
|
||||
price as purchase_price, started_at, ended_at,
|
||||
'active', created_by, created_at
|
||||
FROM price_histories
|
||||
WHERE deleted_at IS NULL;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 관련 문서
|
||||
|
||||
- [API 개발 규칙](../standards/api-rules.md)
|
||||
- [데이터베이스 스키마](../specs/database-schema.md)
|
||||
- [프론트엔드 요청서](../front/[API-2025-12-08]%20pricing-api-enhancement-request.md)
|
||||
|
||||
---
|
||||
|
||||
**최종 업데이트**: 2025-12-08
|
||||
669
specs/hr-api-react-sync-plan.md
Normal file
669
specs/hr-api-react-sync-plan.md
Normal file
@@ -0,0 +1,669 @@
|
||||
# HR API - React 동기화 계획
|
||||
|
||||
> **작성일**: 2025-12-09
|
||||
> **수정일**: 2025-12-09
|
||||
> **목적**: API와 React 프론트엔드 간 데이터 타입 동기화
|
||||
> **원칙**: **API snake_case 유지** - React에서 camelCase 변환 처리
|
||||
|
||||
---
|
||||
|
||||
## 📋 작업 요약
|
||||
|
||||
| 영역 | 작업 | 수정 필요 |
|
||||
|------|------|----------|
|
||||
| **Employee API** | 기존 snake_case 유지 | ❌ 불필요 |
|
||||
| **Attendance API** | 기존 snake_case 유지 | ❌ 불필요 |
|
||||
| **Department Tree API** | 기존 snake_case 유지 | ❌ 불필요 |
|
||||
| **React 프론트엔드** | 변환 유틸리티 적용 | ✅ 프론트엔드 |
|
||||
|
||||
---
|
||||
|
||||
# Part 1: 백엔드 (API) - 변경 없음
|
||||
|
||||
## 설계 원칙
|
||||
|
||||
- **API 응답은 snake_case 유지** (Laravel 표준)
|
||||
- **json_extra, json_details 구조 그대로 유지**
|
||||
- **기존 API 클라이언트 호환성 보장**
|
||||
|
||||
## 현재 API 응답 구조
|
||||
|
||||
### Employee API 응답
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"tenant_id": 1,
|
||||
"user_id": 10,
|
||||
"department_id": 5,
|
||||
"position_key": "DEVELOPER",
|
||||
"employment_type_key": "REGULAR",
|
||||
"employee_status": "active",
|
||||
"profile_photo_path": null,
|
||||
"json_extra": {
|
||||
"employee_code": "EMP001",
|
||||
"resident_number": "******-*******",
|
||||
"gender": "male",
|
||||
"address": "서울시 강남구",
|
||||
"salary": 50000000,
|
||||
"hire_date": "2023-01-15",
|
||||
"rank": "대리",
|
||||
"bank_account": {
|
||||
"bank": "국민",
|
||||
"account": "123-456-789",
|
||||
"holder": "홍길동"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"id": 10,
|
||||
"name": "홍길동",
|
||||
"email": "hong@example.com",
|
||||
"phone": "010-1234-5678",
|
||||
"is_active": true
|
||||
},
|
||||
"department": {
|
||||
"id": 5,
|
||||
"name": "개발팀"
|
||||
},
|
||||
"created_at": "2023-01-15T09:00:00.000000Z",
|
||||
"updated_at": "2024-12-09T10:30:00.000000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Attendance API 응답
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"tenant_id": 1,
|
||||
"user_id": 10,
|
||||
"base_date": "2024-12-09",
|
||||
"status": "onTime",
|
||||
"json_details": {
|
||||
"check_in": "09:00:00",
|
||||
"check_out": "18:00:00",
|
||||
"work_minutes": 540,
|
||||
"overtime_minutes": 60,
|
||||
"late_minutes": 0,
|
||||
"gps_data": {
|
||||
"check_in": { "lat": 37.5665, "lng": 126.9780 }
|
||||
}
|
||||
},
|
||||
"remarks": null,
|
||||
"user": {
|
||||
"id": 10,
|
||||
"name": "홍길동",
|
||||
"email": "hong@example.com"
|
||||
},
|
||||
"created_at": "2024-12-09T09:00:00.000000Z",
|
||||
"updated_at": "2024-12-09T18:00:00.000000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Department Tree API 응답
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"tenant_id": 1,
|
||||
"parent_id": null,
|
||||
"code": "DEV",
|
||||
"name": "개발본부",
|
||||
"description": "개발 조직",
|
||||
"is_active": true,
|
||||
"sort_order": 1,
|
||||
"children": [
|
||||
{
|
||||
"id": 2,
|
||||
"tenant_id": 1,
|
||||
"parent_id": 1,
|
||||
"code": "DEV-FE",
|
||||
"name": "프론트엔드팀",
|
||||
"is_active": true,
|
||||
"sort_order": 1,
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Part 2: 프론트엔드 (React) 수정사항
|
||||
|
||||
## 변환 전략
|
||||
|
||||
React 프론트엔드에서 API 응답을 받아 내부 타입으로 변환합니다.
|
||||
|
||||
### 변환 유틸리티 위치
|
||||
|
||||
```
|
||||
react/src/lib/
|
||||
├── api/
|
||||
│ └── transformers/
|
||||
│ ├── employee.ts # Employee 변환
|
||||
│ ├── attendance.ts # Attendance 변환
|
||||
│ ├── department.ts # Department 변환
|
||||
│ └── index.ts # 공통 유틸리티
|
||||
```
|
||||
|
||||
## 1. 공통 변환 유틸리티
|
||||
|
||||
**파일**: `react/src/lib/api/transformers/index.ts`
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* snake_case → camelCase 변환
|
||||
*/
|
||||
export function toCamelCase(str: string): string {
|
||||
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* 객체 키를 camelCase로 변환 (재귀)
|
||||
*/
|
||||
export function transformKeys<T>(obj: unknown): T {
|
||||
if (obj === null || obj === undefined) {
|
||||
return obj as T;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(item => transformKeys(item)) as T;
|
||||
}
|
||||
|
||||
if (typeof obj === 'object') {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const camelKey = toCamelCase(key);
|
||||
result[camelKey] = transformKeys(value);
|
||||
}
|
||||
return result as T;
|
||||
}
|
||||
|
||||
return obj as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO 문자열을 Date로 변환
|
||||
*/
|
||||
export function parseDate(dateStr: string | null): Date | null {
|
||||
if (!dateStr) return null;
|
||||
return new Date(dateStr);
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Employee 변환
|
||||
|
||||
**파일**: `react/src/lib/api/transformers/employee.ts`
|
||||
|
||||
```typescript
|
||||
import { transformKeys } from './index';
|
||||
import type { Employee, EmployeeApiResponse } from '@/types/hr';
|
||||
|
||||
/**
|
||||
* API 응답 → React Employee 타입 변환
|
||||
*/
|
||||
export function transformEmployee(data: EmployeeApiResponse): Employee {
|
||||
const base = transformKeys<Record<string, unknown>>(data);
|
||||
const jsonExtra = data.json_extra ?? {};
|
||||
|
||||
return {
|
||||
id: String(data.id),
|
||||
name: data.user?.name ?? '',
|
||||
email: data.user?.email ?? '',
|
||||
phone: data.user?.phone ?? null,
|
||||
residentNumber: jsonExtra.resident_number ?? null,
|
||||
salary: jsonExtra.salary ?? null,
|
||||
profileImage: data.profile_photo_path ?? null,
|
||||
employeeCode: jsonExtra.employee_code ?? null,
|
||||
gender: jsonExtra.gender ?? null,
|
||||
address: transformAddress(jsonExtra.address),
|
||||
bankAccount: transformBankAccount(jsonExtra.bank_account),
|
||||
hireDate: jsonExtra.hire_date ?? null,
|
||||
employmentType: mapEmploymentType(data.employment_type_key),
|
||||
rank: jsonExtra.rank ?? null,
|
||||
status: data.employee_status ?? 'active',
|
||||
departmentPositions: buildDepartmentPositions(data),
|
||||
userInfo: buildUserInfo(data),
|
||||
createdAt: data.created_at ?? null,
|
||||
updatedAt: data.updated_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function transformAddress(address: unknown): Employee['address'] {
|
||||
if (!address) return null;
|
||||
|
||||
if (typeof address === 'string') {
|
||||
return {
|
||||
zipCode: '',
|
||||
address1: address,
|
||||
address2: '',
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof address === 'object') {
|
||||
const addr = address as Record<string, string>;
|
||||
return {
|
||||
zipCode: addr.zip_code ?? addr.zipCode ?? '',
|
||||
address1: addr.address1 ?? addr.address_1 ?? '',
|
||||
address2: addr.address2 ?? addr.address_2 ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function transformBankAccount(bankAccount: unknown): Employee['bankAccount'] {
|
||||
if (!bankAccount || typeof bankAccount !== 'object') return null;
|
||||
|
||||
const ba = bankAccount as Record<string, string>;
|
||||
return {
|
||||
bankName: ba.bank ?? ba.bankName ?? '',
|
||||
accountNumber: ba.account ?? ba.accountNumber ?? '',
|
||||
accountHolder: ba.holder ?? ba.accountHolder ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function mapEmploymentType(key: string | null): string | null {
|
||||
if (!key) return null;
|
||||
|
||||
const map: Record<string, string> = {
|
||||
REGULAR: 'regular',
|
||||
CONTRACT: 'contract',
|
||||
PARTTIME: 'parttime',
|
||||
INTERN: 'intern',
|
||||
};
|
||||
|
||||
return map[key] ?? key.toLowerCase();
|
||||
}
|
||||
|
||||
function buildDepartmentPositions(data: EmployeeApiResponse): Employee['departmentPositions'] {
|
||||
if (!data.department_id) return [];
|
||||
|
||||
return [{
|
||||
id: String(data.id),
|
||||
departmentId: String(data.department_id),
|
||||
departmentName: data.department?.name ?? '',
|
||||
positionId: data.position_key ?? '',
|
||||
positionName: data.position_key ?? '',
|
||||
}];
|
||||
}
|
||||
|
||||
function buildUserInfo(data: EmployeeApiResponse): Employee['userInfo'] {
|
||||
if (!data.user) return null;
|
||||
|
||||
return {
|
||||
userId: data.user.user_id ?? data.user.email,
|
||||
role: 'user', // TODO: 실제 역할 정보
|
||||
accountStatus: data.user.is_active ? 'active' : 'inactive',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Employee 목록 변환
|
||||
*/
|
||||
export function transformEmployeeList(data: EmployeeApiResponse[]): Employee[] {
|
||||
return data.map(transformEmployee);
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Attendance 변환
|
||||
|
||||
**파일**: `react/src/lib/api/transformers/attendance.ts`
|
||||
|
||||
```typescript
|
||||
import type { Attendance, AttendanceApiResponse } from '@/types/hr';
|
||||
|
||||
/**
|
||||
* API 응답 → React Attendance 타입 변환
|
||||
*/
|
||||
export function transformAttendance(data: AttendanceApiResponse): Attendance {
|
||||
const jsonDetails = data.json_details ?? {};
|
||||
|
||||
return {
|
||||
id: String(data.id),
|
||||
employeeId: String(data.user_id),
|
||||
employeeName: data.user?.name ?? '',
|
||||
department: '', // TODO: user.tenantProfile.department.name
|
||||
position: '', // TODO: user.tenantProfile.position_key
|
||||
rank: '', // TODO: user.tenantProfile.json_extra.rank
|
||||
baseDate: data.base_date,
|
||||
checkIn: jsonDetails.check_in ?? null,
|
||||
checkOut: jsonDetails.check_out ?? null,
|
||||
breakTime: jsonDetails.break_time ?? null,
|
||||
overtimeHours: formatOvertimeHours(jsonDetails.overtime_minutes),
|
||||
reason: buildReason(data),
|
||||
status: data.status,
|
||||
createdAt: data.created_at ?? null,
|
||||
updatedAt: data.updated_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function formatOvertimeHours(minutes: number | undefined): string | null {
|
||||
if (minutes === undefined || minutes === null) return null;
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
|
||||
return mins > 0 ? `${hours}시간 ${mins}분` : `${hours}시간`;
|
||||
}
|
||||
|
||||
function buildReason(data: AttendanceApiResponse): Attendance['reason'] {
|
||||
if (!data.remarks) return null;
|
||||
|
||||
const typeMap: Record<string, string> = {
|
||||
vacation: 'vacationRequest',
|
||||
businessTrip: 'businessTripRequest',
|
||||
fieldWork: 'fieldWorkRequest',
|
||||
overtime: 'overtimeRequest',
|
||||
};
|
||||
|
||||
return {
|
||||
type: typeMap[data.status] ?? 'vacationRequest',
|
||||
label: data.remarks,
|
||||
documentId: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Attendance 목록 변환
|
||||
*/
|
||||
export function transformAttendanceList(data: AttendanceApiResponse[]): Attendance[] {
|
||||
return data.map(transformAttendance);
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Department Tree 변환
|
||||
|
||||
**파일**: `react/src/lib/api/transformers/department.ts`
|
||||
|
||||
```typescript
|
||||
import type { DepartmentNode, DepartmentApiResponse } from '@/types/hr';
|
||||
|
||||
/**
|
||||
* API 응답 → React Department 타입 변환 (재귀)
|
||||
*/
|
||||
export function transformDepartmentTree(
|
||||
data: DepartmentApiResponse[],
|
||||
depth: number = 0
|
||||
): DepartmentNode[] {
|
||||
return data.map(dept => ({
|
||||
id: dept.id,
|
||||
name: dept.name,
|
||||
parentId: dept.parent_id,
|
||||
depth: depth,
|
||||
children: dept.children
|
||||
? transformDepartmentTree(dept.children, depth + 1)
|
||||
: [],
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
## 5. API 호출 래퍼
|
||||
|
||||
**파일**: `react/src/lib/api/hr.ts`
|
||||
|
||||
```typescript
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import {
|
||||
transformEmployee,
|
||||
transformEmployeeList
|
||||
} from './transformers/employee';
|
||||
import {
|
||||
transformAttendance,
|
||||
transformAttendanceList
|
||||
} from './transformers/attendance';
|
||||
import { transformDepartmentTree } from './transformers/department';
|
||||
|
||||
// Employee API
|
||||
export async function getEmployees(params?: Record<string, unknown>) {
|
||||
const response = await apiClient.get('/v1/employees', { params });
|
||||
return transformEmployeeList(response.data.data);
|
||||
}
|
||||
|
||||
export async function getEmployee(id: string) {
|
||||
const response = await apiClient.get(`/v1/employees/${id}`);
|
||||
return transformEmployee(response.data.data);
|
||||
}
|
||||
|
||||
// Attendance API
|
||||
export async function getAttendances(params?: Record<string, unknown>) {
|
||||
const response = await apiClient.get('/v1/attendances', { params });
|
||||
return transformAttendanceList(response.data.data);
|
||||
}
|
||||
|
||||
export async function getAttendance(id: string) {
|
||||
const response = await apiClient.get(`/v1/attendances/${id}`);
|
||||
return transformAttendance(response.data.data);
|
||||
}
|
||||
|
||||
// Department API
|
||||
export async function getDepartmentTree(params?: Record<string, unknown>) {
|
||||
const response = await apiClient.get('/v1/departments/tree', { params });
|
||||
return transformDepartmentTree(response.data.data);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Part 3: React 타입 정의
|
||||
|
||||
**파일**: `react/src/types/hr.ts`
|
||||
|
||||
```typescript
|
||||
// ============================================================
|
||||
// React 내부 타입 (camelCase)
|
||||
// ============================================================
|
||||
|
||||
export interface Employee {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
residentNumber: string | null;
|
||||
salary: number | null;
|
||||
profileImage: string | null;
|
||||
employeeCode: string | null;
|
||||
gender: 'male' | 'female' | null;
|
||||
address: {
|
||||
zipCode: string;
|
||||
address1: string;
|
||||
address2: string;
|
||||
} | null;
|
||||
bankAccount: {
|
||||
bankName: string;
|
||||
accountNumber: string;
|
||||
accountHolder: string;
|
||||
} | null;
|
||||
hireDate: string | null;
|
||||
employmentType: 'regular' | 'contract' | 'parttime' | 'intern' | null;
|
||||
rank: string | null;
|
||||
status: 'active' | 'leave' | 'resigned';
|
||||
departmentPositions: {
|
||||
id: string;
|
||||
departmentId: string;
|
||||
departmentName: string;
|
||||
positionId: string;
|
||||
positionName: string;
|
||||
}[];
|
||||
userInfo: {
|
||||
userId: string;
|
||||
role: string;
|
||||
accountStatus: 'active' | 'inactive';
|
||||
} | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface Attendance {
|
||||
id: string;
|
||||
employeeId: string;
|
||||
employeeName: string;
|
||||
department: string;
|
||||
position: string;
|
||||
rank: string;
|
||||
baseDate: string;
|
||||
checkIn: string | null;
|
||||
checkOut: string | null;
|
||||
breakTime: string | null;
|
||||
overtimeHours: string | null;
|
||||
reason: {
|
||||
type: 'vacationRequest' | 'businessTripRequest' | 'fieldWorkRequest' | 'overtimeRequest';
|
||||
label: string;
|
||||
documentId: string | null;
|
||||
} | null;
|
||||
status: string;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface DepartmentNode {
|
||||
id: number;
|
||||
name: string;
|
||||
parentId: number | null;
|
||||
depth: number;
|
||||
children: DepartmentNode[];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// API 응답 타입 (snake_case)
|
||||
// ============================================================
|
||||
|
||||
export interface EmployeeApiResponse {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
user_id: number;
|
||||
department_id: number | null;
|
||||
position_key: string | null;
|
||||
employment_type_key: string | null;
|
||||
employee_status: string;
|
||||
profile_photo_path: string | null;
|
||||
json_extra: Record<string, unknown> | null;
|
||||
user: {
|
||||
id: number;
|
||||
user_id?: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
is_active: boolean;
|
||||
} | null;
|
||||
department: {
|
||||
id: number;
|
||||
name: string;
|
||||
} | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface AttendanceApiResponse {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
user_id: number;
|
||||
base_date: string;
|
||||
status: string;
|
||||
json_details: Record<string, unknown> | null;
|
||||
remarks: string | null;
|
||||
user: {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
} | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface DepartmentApiResponse {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
parent_id: number | null;
|
||||
code: string | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
is_active: boolean;
|
||||
sort_order: number;
|
||||
children: DepartmentApiResponse[] | null;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Part 4: 작업 체크리스트
|
||||
|
||||
## 프론트엔드 작업
|
||||
|
||||
- [ ] `react/src/lib/api/transformers/index.ts` - 공통 변환 유틸리티
|
||||
- [ ] `react/src/lib/api/transformers/employee.ts` - Employee 변환
|
||||
- [ ] `react/src/lib/api/transformers/attendance.ts` - Attendance 변환
|
||||
- [ ] `react/src/lib/api/transformers/department.ts` - Department 변환
|
||||
- [ ] `react/src/lib/api/hr.ts` - API 호출 래퍼
|
||||
- [ ] `react/src/types/hr.ts` - 타입 정의 (내부용 + API 응답용)
|
||||
- [ ] 기존 API 호출 코드를 래퍼 함수로 교체
|
||||
|
||||
## 백엔드 작업
|
||||
|
||||
- [x] ~~Resource 클래스 생성~~ → **취소** (기존 응답 유지)
|
||||
- [x] ~~Controller 수정~~ → **취소**
|
||||
- [x] ~~Service 수정~~ → **취소**
|
||||
|
||||
---
|
||||
|
||||
# Part 5: 장단점 비교
|
||||
|
||||
## 현재 접근법 (React 변환)
|
||||
|
||||
**장점:**
|
||||
- API 하위 호환성 유지 (기존 클라이언트 영향 없음)
|
||||
- Laravel 표준 컨벤션 유지 (snake_case)
|
||||
- 백엔드 변경 불필요
|
||||
|
||||
**단점:**
|
||||
- React에서 변환 로직 필요
|
||||
- 타입 이중 정의 (API 타입 + 내부 타입)
|
||||
|
||||
## 대안 접근법 (API camelCase 변환)
|
||||
|
||||
**장점:**
|
||||
- React에서 변환 불필요
|
||||
- 프론트엔드 코드 단순화
|
||||
|
||||
**단점:**
|
||||
- 기존 API 클라이언트 호환성 깨짐
|
||||
- Laravel 표준과 불일치
|
||||
- Resource 클래스 추가 유지보수
|
||||
|
||||
---
|
||||
|
||||
# Part 6: 참고 사항
|
||||
|
||||
## 변환 시점
|
||||
|
||||
1. **API 호출 직후**: `transformXxx()` 함수로 즉시 변환
|
||||
2. **React Query/SWR 사용 시**: fetcher 함수 내에서 변환
|
||||
3. **Zustand/Redux 사용 시**: store에 저장 전 변환
|
||||
|
||||
## 성능 고려
|
||||
|
||||
- 대량 데이터 변환 시 Web Worker 고려
|
||||
- 변환 결과 캐싱 (React Query의 staleTime 활용)
|
||||
- 필요한 필드만 변환하는 최적화 가능
|
||||
|
||||
## 테스트 전략
|
||||
|
||||
```typescript
|
||||
// 변환 함수 단위 테스트
|
||||
describe('transformEmployee', () => {
|
||||
it('should transform snake_case to camelCase', () => {
|
||||
const apiResponse = { employee_status: 'active' };
|
||||
const result = transformEmployee(apiResponse);
|
||||
expect(result.status).toBe('active');
|
||||
});
|
||||
|
||||
it('should handle null json_extra', () => {
|
||||
const apiResponse = { json_extra: null };
|
||||
const result = transformEmployee(apiResponse);
|
||||
expect(result.address).toBeNull();
|
||||
});
|
||||
});
|
||||
```
|
||||
Reference in New Issue
Block a user