docs: plans 폴더 추가 및 HR API 규칙 문서 정리

- plans/ 폴더 신규 생성 (개발 계획 임시 문서용)
- hr-api-react-sync-plan.md를 specs → plans로 이동
- INDEX.md 업데이트 (폴더 구조, 워크플로우)
- rules/ HR API 규칙 문서 추가 (employee, attendance, department-tree)
- pricing API 요청 문서 업데이트
This commit is contained in:
2025-12-09 14:44:39 +09:00
parent b4e8dd7650
commit 5d1190a0d3
7 changed files with 961 additions and 267 deletions

View File

@@ -17,6 +17,7 @@
| **Swagger 작성** | `guides/swagger-guide.md` | API 문서 작성 방법 |
| **품목관리** | `specs/item-master-integration.md` | 품목 시스템 스펙 |
| **게시판** | `specs/board-system-spec.md` | 게시판 시스템 설계 |
| **단가관리** | `rules/pricing-policy.md` | 원가/판매가 계산, 리비전 관리 |
| **MES 개발** | `projects/mes/README.md` | MES 프로젝트 개요 |
---
@@ -25,6 +26,7 @@
```
docs/
├── plans/ # 🆕 개발 계획 - 임시 (작업 완료 후 정리 → 삭제)
├── standards/ # 개발 표준 - "어떻게 코드를 작성할 것인가"
├── architecture/ # 아키텍처 - "왜 이렇게 설계하는가"
├── rules/ # 비즈니스 규칙 - "무엇이 유효한 데이터인가"
@@ -66,6 +68,7 @@ docs/
| 문서 | 설명 | 필수 확인 시점 |
|------|------|--------------|
| [README.md](rules/README.md) | 비즈니스 규칙 개요 | 도메인 로직 구현 전 |
| [pricing-policy.md](rules/pricing-policy.md) | 단가 정책 (원가/판매가 계산, 리비전 관리) | 단가 관련 작업 전 |
### specs/ - 기술 스펙
> 구현 명세, DB 스키마, 시스템 설정
@@ -150,16 +153,27 @@ docs/
4. **INDEX 업데이트**: 새 문서는 반드시 이 파일에 추가
### 폴더 선택 기준
- **"개발 계획/작업 예정"** → `plans/` (임시, 완료 후 삭제)
- **"어떻게 코드 작성?"** → `standards/`
- **"왜 이렇게 설계?"** → `architecture/`
- **"무엇이 유효한 데이터?"** → `rules/`
- **"무엇을 구현?"** → `specs/`
- **"어떻게 구현?"** → `guides/`
### plans/ 워크플로우
1. 개발 계획 문서를 `plans/`에 작성
2. 작업 진행
3. 완료 후 결과물을 해당 프로젝트 docs에 정리
4. plan 문서 삭제
---
## 🔄 문서 구조 변경 이력
- **2025-12-09**: `plans/` 폴더 추가
- 개발 계획 문서용 임시 폴더
- 작업 완료 후 정리 → 삭제 워크플로우
- **2025-12-05**: 폴더 구조 대폭 재정리
- `reference/``standards/`, `architecture/`, `quickstart/`로 분리
- `principles/``architecture/`로 통합

View File

@@ -1,358 +1,379 @@
# 단가관리 API 개선 요청서
# 단가관리 API 분석 및 구현 현황
> **작성일**: 2025-12-08
> **요청자**: 프론트엔드 개발팀
> **상**: sam-api 백엔드 팀
> **최종 업데이트**: 2025-12-08
> **상**: ✅ **백엔드 API 구현 완료**
---
## 1. 현황 요약
## 1. 구현 현황 요약
### 현재 API 구조
| Endpoint | Method | 상태 |
|----------|--------|------|
| `/api/v1/pricing` | GET | 목록 조회 |
| `/api/v1/pricing/show` | GET | 단일 가격 조회 |
| `/api/v1/pricing/bulk` | POST | 일괄 가격 조회 |
| `/api/v1/pricing/upsert` | POST | 등록/수정 |
| `/api/v1/pricing/{id}` | DELETE | 삭제 |
### 현재 API 구조 (구현 완료)
| 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}` | 삭제 (soft delete) | ✅ 완료 |
| `POST` | `/api/v1/pricing/{id}/finalize` | 확정 | ✅ 완료 |
| `GET` | `/api/v1/pricing/{id}/revisions` | 변경이력 조회 | ✅ 완료 |
| `POST` | `/api/v1/pricing/by-items` | 품목별 단가 현황 (다건) | ✅ 완료 |
| `GET` | `/api/v1/pricing/cost` | 원가 조회 (수입검사 > 표준원가) | ✅ 완료 |
### ✅ 이미 지원됨 (품목 정보)
- `item_type_code` (품목유형) - PriceHistory 테이블
- `item_code`, `item_name`, `specification`, `unit` - item 관계 JOIN으로 조회 가능
### ❌ 문제점 (단가 상세 정보)
- 프론트엔드 단가관리 화면에서 요구하는 **단가 계산 필드** 대부분 누락
- 현재 `price_histories` 테이블은 **단순 가격 이력**만 저장 (`price` 단일 필드)
- 프론트엔드는 **원가 계산, 마진 관리, 리비전 관리** 기능 필요
### ✅ 완료된 사항
- **새 테이블 구조**: `prices`, `price_revisions` 테이블 생성
- **기존 테이블 마이그레이션**: `price_histories``prices` 데이터 이관
- **모든 요청 필드**: 원가 계산, 마진 관리, 리비전 관리 기능 구현
---
## 2. 테이블 스키마 변경 요청
## 2. 테이블 스키마 (구현 완료)
### 2.1 `price_histories` 테이블 필드 추가
### 2.1 `prices` 테이블 (신규 생성) ✅
```sql
ALTER TABLE price_histories ADD COLUMN purchase_price DECIMAL(15,4) NULL COMMENT '매입단가(입고가)';
ALTER TABLE price_histories ADD COLUMN processing_cost DECIMAL(15,4) NULL COMMENT '가공비';
ALTER TABLE price_histories ADD COLUMN loss_rate DECIMAL(5,2) NULL COMMENT 'LOSS율(%)';
ALTER TABLE price_histories ADD COLUMN rounding_rule ENUM('round','ceil','floor') DEFAULT 'round' COMMENT '반올림 규칙';
ALTER TABLE price_histories ADD COLUMN rounding_unit INT DEFAULT 1 COMMENT '반올림 단위(1,10,100,1000)';
ALTER TABLE price_histories ADD COLUMN margin_rate DECIMAL(5,2) NULL COMMENT '마진율(%)';
ALTER TABLE price_histories ADD COLUMN sales_price DECIMAL(15,4) NULL COMMENT '판매단가';
ALTER TABLE price_histories ADD COLUMN supplier VARCHAR(255) NULL COMMENT '공급업체';
ALTER TABLE price_histories ADD COLUMN author VARCHAR(100) NULL COMMENT '작성자';
ALTER TABLE price_histories ADD COLUMN receive_date DATE NULL COMMENT '입고일';
ALTER TABLE price_histories ADD COLUMN note TEXT NULL COMMENT '비고';
ALTER TABLE price_histories ADD COLUMN revision_number INT DEFAULT 0 COMMENT '리비전 번호';
ALTER TABLE price_histories ADD COLUMN is_final BOOLEAN DEFAULT FALSE COMMENT '최종 확정 여부';
ALTER TABLE price_histories ADD COLUMN finalized_at DATETIME NULL COMMENT '확정일시';
ALTER TABLE price_histories ADD COLUMN finalized_by INT NULL COMMENT '확정자 ID';
ALTER TABLE price_histories ADD COLUMN status ENUM('draft','active','inactive','finalized') DEFAULT 'draft' COMMENT '상태';
CREATE TABLE prices (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
-- 품목 연결
item_type_code VARCHAR(20) NOT NULL, -- 'PRODUCT' | 'MATERIAL'
item_id BIGINT NOT NULL,
client_group_id BIGINT NULL, -- NULL=기본가
-- 원가 정보
purchase_price DECIMAL(15,4) NULL, -- 매입단가
processing_cost DECIMAL(15,4) NULL, -- 가공비
loss_rate DECIMAL(5,2) NULL, -- LOSS율 (%)
-- 판매가 정보
margin_rate DECIMAL(5,2) NULL, -- 마진율 (%)
sales_price DECIMAL(15,4) NULL, -- 판매단가
rounding_rule ENUM('round','ceil','floor') DEFAULT 'round',
rounding_unit INT DEFAULT 1, -- 1, 10, 100, 1000
-- 메타 정보
supplier VARCHAR(255) NULL,
effective_from DATE NOT NULL,
effective_to DATE NULL,
note TEXT NULL,
-- 상태 관리
status ENUM('draft','active','inactive','finalized') DEFAULT 'draft',
is_final BOOLEAN DEFAULT FALSE,
finalized_at DATETIME NULL,
finalized_by BIGINT NULL,
-- 감사 컬럼
created_by, updated_by, deleted_by, timestamps, soft_deletes
);
```
### 2.2 기존 `price` 필드 처리 방안
### 2.2 `price_revisions` 테이블 (변경 이력) ✅
**옵션 A (권장)**: `price` 필드를 `sales_price`로 마이그레이션
```sql
UPDATE price_histories SET sales_price = price WHERE price_type_code = 'SALE';
UPDATE price_histories SET purchase_price = price WHERE price_type_code = 'PURCHASE';
-- 이후 price 필드 deprecated 또는 삭제
CREATE TABLE price_revisions (
id BIGINT PRIMARY KEY,
tenant_id BIGINT NOT NULL,
price_id BIGINT NOT NULL,
revision_number INT NOT NULL,
changed_at DATETIME NOT NULL,
changed_by BIGINT NOT NULL,
change_reason VARCHAR(500) NULL,
before_snapshot JSON NULL,
after_snapshot JSON NOT NULL
);
```
**옵션 B**: `price` 필드 유지, 새 필드와 병행 사용
- 기존 로직 호환성 유지
- 점진적 마이그레이션
### 2.3 기존 `price_histories` 테이블 처리 ✅
- `prices` 테이블로 데이터 마이그레이션 완료
-`price_histories` 테이블 삭제됨
---
## 3. API 엔드포인트 수정 요청
## 3. API 엔드포인트 상세 (구현 완료)
### 3.1 `POST /api/v1/pricing/upsert` 수정
### 3.1 단가 등록 `POST /api/v1/pricing` ✅
**현재 Request Body:**
**Request Body:**
```json
{
"item_type_code": "PRODUCT",
"item_id": 10,
"price_type_code": "SALE",
"client_group_id": 1,
"price": 50000.00,
"started_at": "2025-01-01",
"ended_at": "2025-12-31"
}
```
"item_type_code": "MATERIAL",
"item_id": 123,
"client_group_id": null,
**요청 Request Body:**
```json
{
"item_type_code": "PRODUCT",
"item_id": 10,
"client_group_id": 1,
"purchase_price": 45000,
"processing_cost": 5000,
"loss_rate": 3.5,
"purchase_price": 1000,
"processing_cost": 100,
"loss_rate": 5,
"margin_rate": 20,
"rounding_rule": "round",
"rounding_unit": 100,
"margin_rate": 20.0,
"sales_price": 60000,
"rounding_unit": 10,
"supplier": "ABC공급",
"author": "홍길동",
"receive_date": "2025-01-01",
"started_at": "2025-01-01",
"ended_at": null,
"supplier": "ABC상사",
"effective_from": "2025-01-01",
"effective_to": null,
"note": "2025년 1분기 단가",
"is_revision": false,
"revision_reason": "가격 인상"
"status": "active"
}
```
### 3.2 `GET /api/v1/pricing` 수정 (목록 조회)
**자동 처리:**
- `sales_price` 자동 계산 (입력 안해도 됨)
- 기존 무기한 단가의 `effective_to` 자동 설정
- 최초 리비전 자동 생성
**현재 Response:**
```json
{
"data": {
"data": [
{
"id": 1,
"item_type_code": "PRODUCT",
"item_id": 10,
"price_type_code": "SALE",
"price": 50000,
"started_at": "2025-01-01"
}
]
}
}
```
**요청 Response:**
```json
{
"data": {
"data": [
{
"id": 1,
"item_type_code": "PRODUCT",
"item_id": 10,
"item_code": "SCREEN-001",
"item_name": "스크린 셔터 기본형",
"specification": "표준형",
"unit": "SET",
"purchase_price": 45000,
"processing_cost": 5000,
"loss_rate": 3.5,
"margin_rate": 20.0,
"sales_price": 60000,
"started_at": "2025-01-01",
"ended_at": null,
"status": "active",
"revision_number": 1,
"is_final": false,
"supplier": "ABC공급",
"created_at": "2025-01-01 10:00:00"
}
]
}
}
```
**핵심 변경**: 품목 정보 JOIN 필요 (`item_masters` 또는 `products`/`materials` 테이블)
---
## 4. 신규 API 엔드포인트 요청
### 4.1 품목 기반 단가 현황 조회 (신규)
**용도**: 품목 마스터 기준으로 단가 등록/미등록 현황 조회
**Endpoint**: `GET /api/v1/pricing/by-items`
### 3.2 목록 조회 `GET /api/v1/pricing` ✅
**Query Parameters:**
| 파라미터 | 타입 | 설명 |
|---------|------|------|
| `item_type_code` | string | 품목 유형 (FG, PT, SM, RM, CS) |
| `search` | string | 품목코드/품목명 검색 |
| `status` | string | `all`, `registered`, `not_registered` |
| `size` | int | 페이지당 항목 수 |
| `page` | int | 페이지 번호 |
| `size` | int | 페이지당 개수 (max 100) |
| `q` | string | 검색어 (공급업체, 비고) |
| `item_type_code` | string | `PRODUCT` / `MATERIAL` |
| `item_id` | int | 품목 ID |
| `client_group_id` | int/null | 고객그룹 ID |
| `status` | string | `draft`, `active`, `inactive`, `finalized` |
| `valid_at` | date | 특정 일자에 유효한 단가 |
**Response:**
```json
{
"success": true,
"message": "message.fetched",
"data": {
"current_page": 1,
"data": [
{
"item_id": 1,
"item_code": "SCREEN-001",
"item_name": "스크린 셔터 기본형",
"item_type": "FG",
"specification": "표준형",
"unit": "SET",
"pricing_id": null,
"has_pricing": false,
"purchase_price": null,
"sales_price": null,
"margin_rate": null,
"status": "not_registered"
},
{
"item_id": 2,
"item_code": "GR-001",
"item_name": "가이드레일 130×80",
"item_type": "PT",
"specification": "130×80×2438",
"unit": "EA",
"pricing_id": 5,
"has_pricing": true,
"purchase_price": 45000,
"sales_price": 60000,
"margin_rate": 20.0,
"effective_date": "2025-01-01",
"id": 1,
"item_type_code": "MATERIAL",
"item_id": 123,
"client_group_id": null,
"purchase_price": "1000.0000",
"processing_cost": "100.0000",
"loss_rate": "5.00",
"margin_rate": "20.00",
"sales_price": "1386.0000",
"rounding_rule": "round",
"rounding_unit": 10,
"supplier": "ABC상사",
"effective_from": "2025-01-01",
"effective_to": null,
"status": "active",
"revision_number": 1,
"is_final": false
"is_final": false,
"client_group": null
}
],
"stats": {
"total_items": 100,
"registered": 45,
"not_registered": 55,
"finalized": 10
}
"total": 1
}
}
```
### 4.2 단가 이력 조회 (신규)
---
**Endpoint**: `GET /api/v1/pricing/{id}/revisions`
## 4. 추가 API 엔드포인트 (구현 완료)
### 4.1 품목별 단가 현황 `POST /api/v1/pricing/by-items` ✅
**용도**: 여러 품목의 현재 유효한 단가를 한번에 조회
**Request Body:**
```json
{
"items": [
{ "item_type_code": "MATERIAL", "item_id": 123 },
{ "item_type_code": "MATERIAL", "item_id": 124 },
{ "item_type_code": "PRODUCT", "item_id": 10 }
],
"client_group_id": 1,
"date": "2025-12-08"
}
```
**조회 우선순위:**
1. 고객그룹별 단가 (`client_group_id` 지정 시)
2. 기본 단가 (`client_group_id = NULL`)
**Response:**
```json
{
"success": true,
"data": [
{
"revision_number": 2,
"revision_date": "2025-06-01",
"revision_by": "김철수",
"revision_reason": "원자재 가격 인상",
"previous_purchase_price": 40000,
"previous_sales_price": 55000,
"new_purchase_price": 45000,
"new_sales_price": 60000
"item_type_code": "MATERIAL",
"item_id": 123,
"price": { ... },
"has_price": true
},
{
"revision_number": 1,
"revision_date": "2025-01-01",
"revision_by": "홍길동",
"revision_reason": "최초 등록",
"previous_purchase_price": null,
"previous_sales_price": null,
"new_purchase_price": 40000,
"new_sales_price": 55000
"item_type_code": "MATERIAL",
"item_id": 124,
"price": null,
"has_price": false
}
]
}
```
### 4.3 단가 확정 (신규)
**Endpoint**: `POST /api/v1/pricing/{id}/finalize`
**Request Body:**
```json
{
"finalize_reason": "2025년 1분기 단가 확정"
}
```
### 4.2 변경 이력 조회 `GET /api/v1/pricing/{id}/revisions` ✅
**Response:**
```json
{
"success": true,
"data": {
"data": [
{
"id": 2,
"revision_number": 2,
"changed_at": "2025-12-08T11:00:00.000000Z",
"changed_by": 1,
"change_reason": "마진율 조정",
"before_snapshot": {
"purchase_price": "1000.0000",
"margin_rate": "15.00",
"sales_price": "1265.0000"
},
"after_snapshot": {
"purchase_price": "1000.0000",
"margin_rate": "20.00",
"sales_price": "1386.0000"
},
"changed_by_user": { "id": 1, "name": "홍길동" }
}
]
}
}
```
### 4.3 단가 확정 `POST /api/v1/pricing/{id}/finalize` ✅
**동작:**
- `is_final``true`
- `status``finalized`
- 확정 후 **수정/삭제 불가**
**Response:**
```json
{
"success": true,
"message": "message.pricing.finalized",
"data": {
"id": 5,
"is_final": true,
"finalized_at": "2025-12-08 14:30:00",
"finalized_at": "2025-12-08T14:30:00.000000Z",
"finalized_by": 1,
"status": "finalized"
},
"message": "단가가 최종 확정되었습니다."
}
}
```
---
### 4.4 원가 조회 `GET /api/v1/pricing/cost` ✅
## 5. 프론트엔드 타입 참조
**용도**: 수입검사 입고단가 > 표준원가 우선순위로 원가 조회
프론트엔드에서 사용하는 타입 정의 (`src/components/pricing/types.ts`):
**Query Parameters:**
| 파라미터 | 타입 | 필수 | 설명 |
|---------|------|------|------|
| `item_type_code` | string | ✅ | `PRODUCT` / `MATERIAL` |
| `item_id` | int | ✅ | 품목 ID |
| `date` | date | ❌ | 조회 기준일 (기본: 오늘) |
```typescript
interface PricingData {
id: string;
itemId: string;
itemCode: string;
itemName: string;
itemType: string;
specification?: string;
unit: string;
// 단가 정보
effectiveDate: string; // started_at
receiveDate?: string; // receive_date
author?: string; // author
purchasePrice?: number; // purchase_price
processingCost?: number; // processing_cost
loss?: number; // loss_rate
roundingRule?: RoundingRule; // rounding_rule
roundingUnit?: number; // rounding_unit
marginRate?: number; // margin_rate
salesPrice?: number; // sales_price
supplier?: string; // supplier
note?: string; // note
// 리비전 관리
currentRevision: number; // revision_number
isFinal: boolean; // is_final
revisions?: PricingRevision[];
finalizedDate?: string; // finalized_at
finalizedBy?: string; // finalized_by
status: PricingStatus; // status
**Response:**
```json
{
"success": true,
"data": {
"item_type_code": "MATERIAL",
"item_id": 123,
"date": "2025-12-08",
"cost_source": "receipt",
"purchase_price": 1050.00,
"receipt_id": 45,
"receipt_date": "2025-12-01",
"price_id": null
}
}
```
---
## 6. 우선순위
| 순위 | 항목 | 중요도 |
|------|------|--------|
| 1 | 테이블 스키마 변경 (필드 추가) | 🔴 필수 |
| 2 | `POST /pricing/upsert` 수정 | 🔴 필수 |
| 3 | `GET /pricing/by-items` 신규 | 🔴 필수 |
| 4 | `GET /pricing` 응답 확장 | 🟡 중요 |
| 5 | `GET /pricing/{id}/revisions` 신규 | 🟡 중요 |
| 6 | `POST /pricing/{id}/finalize` 신규 | 🟢 권장 |
| cost_source | 설명 |
|-------------|------|
| `receipt` | 수입검사 입고단가 |
| `standard` | 표준원가 (prices 테이블) |
| `not_found` | 단가 미등록 |
---
## 7. 질문/협의 사항
## 5. 비즈니스 로직
1. **기존 price 필드 처리**: 마이그레이션 vs 병행 사용?
2. **리비전 테이블 분리**: `price_history_revisions` 별도 테이블 vs 현재 테이블 확장?
3. **품목 연결**: `item_masters` 테이블 사용 vs `products`/`materials` 각각 JOIN?
### 5.1 판매단가 자동 계산
```
총원가 = (매입단가 + 가공비) × (1 + LOSS율/100)
판매단가 = 반올림(총원가 × (1 + 마진율/100), 반올림단위, 반올림규칙)
```
### 5.2 상태 흐름
```
draft → active → finalized
inactive
```
### 5.3 주요 검증 규칙
- 동일 품목+고객그룹+시작일 조합 중복 불가
- 확정된 단가는 수정/삭제 불가
- 마진율/LOSS율은 0~100% 범위
- 반올림단위는 1, 10, 100, 1000 중 하나
---
**연락처**: 프론트엔드 개발팀
**관련 파일**: `src/components/pricing/types.ts`
## 6. 프론트엔드 작업 현황
| 작업 | 상태 | 비고 |
|------|------|------|
| `usePricingList` 훅 생성 | ⬜ 미완료 | |
| 타입 정의 | ⬜ 미완료 | |
| 단가 목록 페이지 | ⬜ 미완료 | |
| 단가 등록/수정 페이지 | ⬜ 미완료 | |
| 단가 상세 페이지 (이력 포함) | ⬜ 미완료 | |
| 품목별 단가 현황 컴포넌트 | ⬜ 미완료 | |
---
## 7. 백엔드 참고 파일
### 컨트롤러/서비스
- `api/app/Http/Controllers/Api/V1/PricingController.php`
- `api/app/Services/PricingService.php`
### 모델
- `api/app/Models/Products/Price.php`
- `api/app/Models/Products/PriceRevision.php`
### 요청 클래스
- `api/app/Http/Requests/Pricing/PriceIndexRequest.php`
- `api/app/Http/Requests/Pricing/PriceStoreRequest.php`
- `api/app/Http/Requests/Pricing/PriceUpdateRequest.php`
- `api/app/Http/Requests/Pricing/PriceByItemsRequest.php`
- `api/app/Http/Requests/Pricing/PriceCostRequest.php`
### 마이그레이션
- `api/database/migrations/2025_12_08_154633_create_prices_table.php`
- `api/database/migrations/2025_12_08_154634_create_price_revisions_table.php`
- `api/database/migrations/2025_12_08_154635_migrate_price_histories_to_prices.php`
- `api/database/migrations/2025_12_08_154636_drop_price_histories_table.php`
### 라우트
- `api/routes/api.php` (Line 368-379)
---
## 8. 관련 문서
- [단가 정책](../rules/pricing-policy.md) - 상세 정책 및 계산 공식
---
**최종 업데이트**: 2025-12-08

View File

@@ -18,7 +18,7 @@
| 문서 | 설명 |
|------|------|
| *(작성 예정)* | |
| [pricing-policy.md](pricing-policy.md) | 단가 정책 (원가/판매가 계산, 리비전 관리) |
## 관련 폴더
- [standards/](../standards/) - 개발 표준 (어떻게 코드를 작성할 것인가)

220
rules/attendance-api.md Normal file
View File

@@ -0,0 +1,220 @@
# Attendance API (근태관리 API) 규칙
## 개요
근태관리 API는 테넌트 내 사용자의 출퇴근 및 근태 정보를 관리하는 API입니다.
`attendances` 테이블을 사용하며, 상세 출퇴근 정보는 `json_details` 필드에 저장합니다.
## 핵심 모델
### Attendance
- **위치**: `App\Models\Tenants\Attendance`
- **역할**: 일별 근태 기록
- **특징**:
- `BelongsToTenant` 트레이트 사용 (멀티테넌트 자동 스코핑)
- `SoftDeletes` 적용
- `json_details` 필드에 상세 출퇴근 정보 저장
## 엔드포인트
| Method | Path | 설명 |
|--------|------|------|
| GET | `/v1/attendances` | 근태 목록 조회 |
| GET | `/v1/attendances/{id}` | 근태 상세 조회 |
| POST | `/v1/attendances` | 근태 등록 |
| PATCH | `/v1/attendances/{id}` | 근태 수정 |
| DELETE | `/v1/attendances/{id}` | 근태 삭제 |
| DELETE | `/v1/attendances/bulk` | 근태 일괄 삭제 |
| POST | `/v1/attendances/check-in` | 출근 기록 |
| POST | `/v1/attendances/check-out` | 퇴근 기록 |
| GET | `/v1/attendances/monthly-stats` | 월간 통계 |
## 데이터 구조
### 기본 필드
| 필드 | 타입 | 설명 |
|------|------|------|
| `id` | int | PK |
| `tenant_id` | int | 테넌트 ID |
| `user_id` | int | 사용자 ID (FK → users) |
| `base_date` | date | 기준 일자 |
| `status` | string | 근태 상태 |
| `json_details` | json | 상세 출퇴근 정보 |
| `remarks` | string | 비고 (500자 제한) |
| `created_by` | int | 생성자 |
| `updated_by` | int | 수정자 |
| `deleted_by` | int | 삭제자 |
| `deleted_at` | timestamp | Soft Delete |
### 근태 상태 (status)
| 상태 | 설명 |
|------|------|
| `onTime` | 정상 출근 (기본값) |
| `late` | 지각 |
| `absent` | 결근 |
| `vacation` | 휴가 |
| `businessTrip` | 출장 |
| `fieldWork` | 외근 |
| `overtime` | 야근 |
| `remote` | 재택근무 |
### json_details 필드 구조
```json
{
"check_in": "09:00:00",
"check_out": "18:00:00",
"gps_data": {
"check_in": {
"lat": 37.5665,
"lng": 126.9780,
"accuracy": 10
},
"check_out": {
"lat": 37.5665,
"lng": 126.9780,
"accuracy": 10
}
},
"external_work": {
"location": "고객사",
"purpose": "미팅",
"start_time": "14:00:00",
"end_time": "16:00:00"
},
"multiple_entries": [
{ "in": "09:00:00", "out": "12:00:00" },
{ "in": "13:00:00", "out": "18:00:00" }
],
"work_minutes": 480,
"overtime_minutes": 60,
"late_minutes": 30,
"early_leave_minutes": 0,
"vacation_type": "annual|half|sick"
}
```
### 허용된 json_details 키
```php
$allowedKeys = [
'check_in', // 출근 시간 (HH:MM:SS)
'check_out', // 퇴근 시간 (HH:MM:SS)
'gps_data', // GPS 데이터 (출퇴근 위치)
'external_work', // 외근 정보
'multiple_entries', // 다중 출퇴근 기록
'work_minutes', // 총 근무 시간 (분)
'overtime_minutes', // 초과 근무 시간 (분)
'late_minutes', // 지각 시간 (분)
'early_leave_minutes',// 조퇴 시간 (분)
'vacation_type', // 휴가 유형
];
```
## 비즈니스 규칙
### 출근 기록 (check-in)
1. 오늘 기록이 있으면 업데이트, 없으면 새로 생성
2. `check_in` 시간과 GPS 데이터 저장
3. 출근 시간 기준으로 상태 자동 결정 (09:00 기준 지각 판단)
```php
// 상태 자동 결정 로직
if ($checkIn > '09:00:00') {
$status = 'late';
} else {
$status = 'onTime';
}
```
### 퇴근 기록 (check-out)
1. 오늘 출근 기록이 없으면 에러 반환
2. `check_out` 시간과 GPS 데이터 저장
3. 근무 시간(work_minutes) 자동 계산
```php
// 근무 시간 계산
$checkIn = Carbon::createFromFormat('H:i:s', $jsonDetails['check_in']);
$checkOut = Carbon::createFromFormat('H:i:s', $checkOutTime);
$jsonDetails['work_minutes'] = $checkOut->diffInMinutes($checkIn);
```
### 근태 등록 (store)
1. 같은 날 같은 사용자 기록이 있으면 에러 반환
2. `json_details` 직접 전달 또는 개별 필드에서 구성
```php
// json_details 처리 방식
$jsonDetails = isset($data['json_details']) && is_array($data['json_details'])
? $data['json_details']
: $this->buildJsonDetails($data);
```
### 월간 통계 (monthly-stats)
통계 항목:
- 총 근무일수
- 상태별 일수 (정상, 지각, 결근, 휴가, 출장, 외근, 야근, 재택)
- 총 근무 시간 (분)
- 총 초과 근무 시간 (분)
## 검색/필터 파라미터
| 파라미터 | 타입 | 설명 |
|----------|------|------|
| `user_id` | int | 사용자 필터 |
| `date` | date | 특정 날짜 필터 |
| `date_from` | date | 시작 날짜 |
| `date_to` | date | 종료 날짜 |
| `status` | string | 근태 상태 필터 |
| `department_id` | int | 부서 필터 (사용자의 부서) |
| `sort_by` | string | 정렬 기준 (기본: base_date) |
| `sort_dir` | string | 정렬 방향 (기본: desc) |
| `per_page` | int | 페이지당 항목 수 (기본: 20) |
## 관계 (Relationships)
```php
public function user(): BelongsTo // 사용자 정보
public function creator(): BelongsTo // 생성자
public function updater(): BelongsTo // 수정자
```
## 스코프 (Scopes)
```php
$query->onDate('2024-01-15'); // 특정 날짜
$query->betweenDates('2024-01-01', '2024-01-31'); // 날짜 범위
$query->forUser(123); // 특정 사용자
$query->withStatus('late'); // 특정 상태
```
## Accessor
```php
$attendance->check_in; // json_details['check_in']
$attendance->check_out; // json_details['check_out']
$attendance->gps_data; // json_details['gps_data']
$attendance->external_work; // json_details['external_work']
$attendance->multiple_entries; // json_details['multiple_entries']
$attendance->work_minutes; // json_details['work_minutes']
$attendance->overtime_minutes; // json_details['overtime_minutes']
$attendance->late_minutes; // json_details['late_minutes']
$attendance->early_leave_minutes;// json_details['early_leave_minutes']
$attendance->vacation_type; // json_details['vacation_type']
```
## 주의사항
1. **중복 방지**: 같은 날짜 + 같은 사용자 조합은 유일해야 함
2. **멀티테넌트**: BelongsToTenant 트레이트로 자동 스코핑
3. **Soft Delete**: deleted_by 기록 후 삭제
4. **Audit**: created_by/updated_by 자동 기록
5. **시간 형식**: check_in/check_out은 HH:MM:SS 형식
6. **표준 출근 시간**: 기본 09:00:00 (회사별 설정 필요)

View File

@@ -0,0 +1,258 @@
# Department Tree API (부서트리 조회 API) 규칙
## 개요
부서트리 API는 테넌트 내 조직도를 계층 구조로 조회하는 API입니다.
`departments` 테이블의 `parent_id`를 통한 자기참조 관계로 무한 depth 계층 구조를 지원합니다.
## 핵심 모델
### Department
- **위치**: `App\Models\Tenants\Department`
- **역할**: 부서/조직 정보
- **특징**:
- `parent_id` 자기참조로 계층 구조
- `HasRoles` 트레이트 (부서도 권한/역할 보유 가능)
- `ModelTrait` 적용 (is_active, 날짜 처리)
## 엔드포인트
### 부서 트리 전용
| Method | Path | 설명 |
|--------|------|------|
| GET | `/v1/departments/tree` | 부서 트리 조회 |
### 기본 CRUD (참고)
| Method | Path | 설명 |
|--------|------|------|
| GET | `/v1/departments` | 부서 목록 조회 |
| GET | `/v1/departments/{id}` | 부서 상세 조회 |
| POST | `/v1/departments` | 부서 생성 |
| PATCH | `/v1/departments/{id}` | 부서 수정 |
| DELETE | `/v1/departments/{id}` | 부서 삭제 |
### 부서-사용자 관리
| Method | Path | 설명 |
|--------|------|------|
| GET | `/v1/departments/{id}/users` | 부서 사용자 목록 |
| POST | `/v1/departments/{id}/users` | 사용자 배정 |
| DELETE | `/v1/departments/{id}/users/{user}` | 사용자 제거 |
| PATCH | `/v1/departments/{id}/users/{user}/primary` | 주부서 설정 |
## 데이터 구조
### 기본 필드
| 필드 | 타입 | 설명 |
|------|------|------|
| `id` | int | PK |
| `tenant_id` | int | 테넌트 ID |
| `parent_id` | int | 상위 부서 ID (nullable, 최상위는 null) |
| `code` | string | 부서 코드 (unique) |
| `name` | string | 부서명 |
| `description` | string | 부서 설명 |
| `is_active` | bool | 활성화 상태 |
| `sort_order` | int | 정렬 순서 |
| `created_by` | int | 생성자 |
| `updated_by` | int | 수정자 |
| `deleted_by` | int | 삭제자 |
### 트리 응답 구조
```json
[
{
"id": 1,
"tenant_id": 1,
"parent_id": null,
"code": "DEPT001",
"name": "경영지원본부",
"is_active": true,
"sort_order": 1,
"children": [
{
"id": 2,
"tenant_id": 1,
"parent_id": 1,
"code": "DEPT002",
"name": "인사팀",
"is_active": true,
"sort_order": 1,
"children": [],
"users": []
},
{
"id": 3,
"tenant_id": 1,
"parent_id": 1,
"code": "DEPT003",
"name": "재무팀",
"is_active": true,
"sort_order": 2,
"children": [],
"users": []
}
],
"users": [
{ "id": 1, "name": "홍길동", "email": "hong@example.com" }
]
}
]
```
## 트리 조회 로직
### tree() 메서드 구현
```php
public function tree(array $params = []): array
{
// 1. 파라미터 검증
$withUsers = filter_var($params['with_users'] ?? false, FILTER_VALIDATE_BOOLEAN);
// 2. 최상위 부서 조회 (parent_id가 null)
$query = Department::query()
->whereNull('parent_id')
->orderBy('sort_order')
->orderBy('name');
// 3. 재귀적으로 자식 부서 로드
$query->with(['children' => function ($q) use ($withUsers) {
$q->orderBy('sort_order')->orderBy('name');
$this->loadChildrenRecursive($q, $withUsers);
}]);
// 4. 사용자 포함 옵션
if ($withUsers) {
$query->with(['users:id,name,email']);
}
return $query->get()->toArray();
}
// 재귀 로딩 헬퍼
private function loadChildrenRecursive($query, bool $withUsers): void
{
$query->with(['children' => function ($q) use ($withUsers) {
$q->orderBy('sort_order')->orderBy('name');
$this->loadChildrenRecursive($q, $withUsers);
}]);
if ($withUsers) {
$query->with(['users:id,name,email']);
}
}
```
### 정렬 규칙
1. `sort_order` 오름차순
2. `name` 오름차순 (동일 sort_order일 때)
## 요청 파라미터
### GET /v1/departments/tree
| 파라미터 | 타입 | 기본값 | 설명 |
|----------|------|--------|------|
| `with_users` | bool | false | 부서별 사용자 목록 포함 |
### 예시
```bash
# 기본 트리 조회
GET /v1/departments/tree
# 사용자 포함 트리 조회
GET /v1/departments/tree?with_users=1
```
## 관계 (Relationships)
```php
public function parent(): BelongsTo // 상위 부서
public function children() // 하위 부서들 (HasMany)
public function users() // 소속 사용자들 (BelongsToMany)
public function departmentUsers() // 부서-사용자 pivot (HasMany)
public function permissionOverrides() // 권한 오버라이드 (MorphMany)
```
## 부서-사용자 관계 (Pivot)
### department_user 테이블
| 필드 | 타입 | 설명 |
|------|------|------|
| `department_id` | int | 부서 ID |
| `user_id` | int | 사용자 ID |
| `tenant_id` | int | 테넌트 ID |
| `is_primary` | bool | 주부서 여부 |
| `joined_at` | timestamp | 배정일 |
| `left_at` | timestamp | 해제일 |
| `deleted_at` | timestamp | Soft Delete |
### 주부서 규칙
- 한 사용자는 여러 부서에 소속 가능
- 주부서(`is_primary`)는 사용자당 1개만 가능
- 주부서 설정 시 기존 주부서는 자동 해제
## 권한 관리
### 부서 권한 시스템
부서는 Spatie Permission과 연동되어 권한을 가질 수 있습니다.
- **ALLOW**: `model_has_permissions` 테이블
- **DENY**: `permission_overrides` 테이블 (effect: -1)
### 관련 엔드포인트
| Method | Path | 설명 |
|--------|------|------|
| GET | `/v1/departments/{id}/permissions` | 부서 권한 목록 |
| POST | `/v1/departments/{id}/permissions` | 권한 부여/차단 |
| DELETE | `/v1/departments/{id}/permissions/{permission}` | 권한 제거 |
## 주의사항
1. **무한 재귀 방지**: Eloquent eager loading으로 처리, 별도 depth 제한 없음
2. **성능 고려**: 대규모 조직도의 경우 `with_users` 사용 시 응답 시간 증가
3. **정렬 일관성**: 모든 레벨에서 동일한 정렬 규칙 적용
4. **멀티테넌트**: tenant_id 기반 자동 스코핑
5. **주부서 제약**: 사용자당 주부서 1개만 허용
6. **Soft Delete**: department_user pivot도 Soft Delete 적용
## 트리 구축 예시
### 조직도 예시
```
경영지원본부 (parent_id: null)
├── 인사팀 (parent_id: 1)
│ ├── 채용파트 (parent_id: 2)
│ └── 교육파트 (parent_id: 2)
├── 재무팀 (parent_id: 1)
└── 총무팀 (parent_id: 1)
개발본부 (parent_id: null)
├── 프론트엔드팀 (parent_id: 4)
├── 백엔드팀 (parent_id: 4)
└── QA팀 (parent_id: 4)
```
### SQL 예시 (데이터 삽입)
```sql
-- 최상위 부서
INSERT INTO departments (tenant_id, parent_id, code, name, sort_order)
VALUES (1, NULL, 'HQ', '경영지원본부', 1);
-- 하위 부서
INSERT INTO departments (tenant_id, parent_id, code, name, sort_order)
VALUES (1, 1, 'HR', '인사팀', 1);
```

181
rules/employee-api.md Normal file
View File

@@ -0,0 +1,181 @@
# Employee API (사원관리 API) 규칙
## 개요
사원관리 API는 테넌트 내 사원 정보를 관리하는 API입니다.
`users` 테이블과 `tenant_user_profiles` 테이블을 조합하여 사원 정보를 구성합니다.
## 핵심 모델
### TenantUserProfile
- **위치**: `App\Models\Tenants\TenantUserProfile`
- **역할**: 테넌트별 사용자 프로필 (사원 정보)
- **특징**: `json_extra` 필드에 사원 상세 정보 저장
### User
- **위치**: `App\Models\Members\User`
- **역할**: 기본 사용자 계정 (이름, 이메일, 비밀번호)
## 엔드포인트
| Method | Path | 설명 |
|--------|------|------|
| GET | `/v1/employees` | 사원 목록 조회 |
| GET | `/v1/employees/{id}` | 사원 상세 조회 |
| POST | `/v1/employees` | 사원 등록 |
| PATCH | `/v1/employees/{id}` | 사원 수정 |
| DELETE | `/v1/employees/{id}` | 사원 삭제 (상태 변경) |
| DELETE | `/v1/employees/bulk` | 사원 일괄 삭제 |
| GET | `/v1/employees/stats` | 사원 통계 |
| POST | `/v1/employees/{id}/account` | 시스템 계정 생성 |
## 데이터 구조
### 기본 필드 (TenantUserProfile)
| 필드 | 타입 | 설명 |
|------|------|------|
| `tenant_id` | int | 테넌트 ID |
| `user_id` | int | 사용자 ID (FK → users) |
| `department_id` | int | 부서 ID (nullable) |
| `position_key` | string | 직위 코드 |
| `job_title_key` | string | 직책 코드 |
| `work_location_key` | string | 근무지 코드 |
| `employment_type_key` | string | 고용 형태 코드 |
| `employee_status` | string | 고용 상태 (active/leave/resigned) |
| `manager_user_id` | int | 상위 관리자 ID (nullable) |
| `profile_photo_path` | string | 프로필 사진 경로 |
| `display_name` | string | 표시명 |
| `json_extra` | json | 확장 사원 정보 |
### json_extra 필드 구조
```json
{
"employee_code": "EMP001",
"resident_number": "encrypted_value",
"gender": "male|female",
"address": "서울시 강남구...",
"salary": 5000000,
"hire_date": "2024-01-15",
"rank": "대리",
"bank_account": {
"bank": "국민은행",
"account": "123-456-789",
"holder": "홍길동"
},
"work_type": "regular|contract|part_time",
"contract_info": {
"start_date": "2024-01-15",
"end_date": "2025-01-14"
},
"emergency_contact": {
"name": "김부모",
"phone": "010-1234-5678",
"relation": "부모"
},
"education": [],
"certifications": []
}
```
### 허용된 json_extra 키
```php
$allowedKeys = [
'employee_code', // 사원번호
'resident_number', // 주민등록번호 (암호화 필수)
'gender', // 성별
'address', // 주소
'salary', // 급여
'hire_date', // 입사일
'rank', // 직급
'bank_account', // 급여계좌
'work_type', // 근무유형
'contract_info', // 계약 정보
'emergency_contact', // 비상연락처
'education', // 학력
'certifications', // 자격증
];
```
## 비즈니스 규칙
### 사원 등록 (store)
1. `users` 테이블에 사용자 생성
2. `user_tenants` pivot에 관계 추가 (is_default: true)
3. `tenant_user_profiles` 생성
4. `json_extra`에 사원 정보 설정
```php
// 자동 생성되는 user_id 형식
$userId = strtolower(explode('@', $email)[0] . '_' . Str::random(4));
```
### 사원 삭제 (destroy)
- **Hard Delete 하지 않음**
- `employee_status``resigned`로 변경
- 사용자 계정은 유지됨
### 사원 상태 (employee_status)
| 상태 | 설명 |
|------|------|
| `active` | 재직 중 |
| `leave` | 휴직 |
| `resigned` | 퇴사 |
### 시스템 계정 (has_account)
- 시스템 계정 = `users.password`가 NULL이 아닌 경우
- `POST /employees/{id}/account`로 비밀번호 설정 시 계정 생성
- 첫 로그인 시 비밀번호 변경 필요 (`must_change_password: true`)
## 검색/필터 파라미터
| 파라미터 | 타입 | 설명 |
|----------|------|------|
| `q` | string | 이름/이메일/사원코드 검색 |
| `status` | string | 고용 상태 필터 |
| `department_id` | int | 부서 필터 |
| `has_account` | bool | 시스템 계정 보유 여부 |
| `sort_by` | string | 정렬 기준 (기본: created_at) |
| `sort_dir` | string | 정렬 방향 (asc/desc) |
| `per_page` | int | 페이지당 항목 수 (기본: 20) |
## 관계 (Relationships)
```php
// TenantUserProfile
public function user(): BelongsTo // 기본 사용자 정보
public function department(): BelongsTo // 소속 부서
public function manager(): BelongsTo // 상위 관리자
```
## 스코프 (Scopes)
```php
$query->active(); // employee_status = 'active'
$query->onLeave(); // employee_status = 'leave'
$query->resigned(); // employee_status = 'resigned'
```
## Accessor
```php
$profile->employee_code; // json_extra['employee_code']
$profile->hire_date; // json_extra['hire_date']
$profile->address; // json_extra['address']
$profile->emergency_contact; // json_extra['emergency_contact']
```
## 주의사항
1. **주민등록번호**: 반드시 암호화하여 저장
2. **멀티테넌트**: tenant_id 자동 스코핑
3. **Audit**: created_by/updated_by 자동 기록
4. **삭제**: Hard Delete 금지, employee_status 변경으로 처리