From b4e8dd765047eeb6c3e630f873e45333f6af3235 Mon Sep 17 00:00:00 2001 From: hskwon Date: Tue, 9 Dec 2025 14:23:15 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20HR=20API-React=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EC=8A=A4=ED=8E=99=20=EB=AC=B8=EC=84=9C=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API snake_case 유지, React에서 camelCase 변환 방식으로 변경 - 백엔드 Resource 클래스 작업 취소 - 프론트엔드 변환 유틸리티 가이드 추가 - 타입 정의 (내부용 + API 응답용) 예시 포함 --- rules/pricing-policy.md | 402 +++++++++++++++++++ specs/hr-api-react-sync-plan.md | 669 ++++++++++++++++++++++++++++++++ 2 files changed, 1071 insertions(+) create mode 100644 rules/pricing-policy.md create mode 100644 specs/hr-api-react-sync-plan.md diff --git a/rules/pricing-policy.md b/rules/pricing-policy.md new file mode 100644 index 0000000..699b413 --- /dev/null +++ b/rules/pricing-policy.md @@ -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 \ No newline at end of file diff --git a/specs/hr-api-react-sync-plan.md b/specs/hr-api-react-sync-plan.md new file mode 100644 index 0000000..8a2269b --- /dev/null +++ b/specs/hr-api-react-sync-plan.md @@ -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(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 = {}; + 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>(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; + 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; + 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 = { + 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 = { + 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) { + 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) { + 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) { + 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 | 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 | 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(); + }); +}); +```