From 5d1190a0d3319acd96a60c106b65cc6fd4a84221 Mon Sep 17 00:00:00 2001 From: hskwon Date: Tue, 9 Dec 2025 14:44:39 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20plans=20=ED=8F=B4=EB=8D=94=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20HR=20API=20=EA=B7=9C=EC=B9=99=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - plans/ 폴더 신규 생성 (개발 계획 임시 문서용) - hr-api-react-sync-plan.md를 specs → plans로 이동 - INDEX.md 업데이트 (폴더 구조, 워크플로우) - rules/ HR API 규칙 문서 추가 (employee, attendance, department-tree) - pricing API 요청 문서 업데이트 --- INDEX.md | 14 + ...-12-08] pricing-api-enhancement-request.md | 553 +++++++++--------- {specs => plans}/hr-api-react-sync-plan.md | 0 rules/README.md | 2 +- rules/attendance-api.md | 220 +++++++ rules/department-tree-api.md | 258 ++++++++ rules/employee-api.md | 181 ++++++ 7 files changed, 961 insertions(+), 267 deletions(-) rename {specs => plans}/hr-api-react-sync-plan.md (100%) create mode 100644 rules/attendance-api.md create mode 100644 rules/department-tree-api.md create mode 100644 rules/employee-api.md diff --git a/INDEX.md b/INDEX.md index 0728fc7..1fc00bb 100644 --- a/INDEX.md +++ b/INDEX.md @@ -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/`로 통합 diff --git a/front/[API-2025-12-08] pricing-api-enhancement-request.md b/front/[API-2025-12-08] pricing-api-enhancement-request.md index e717957..a962ef1 100644 --- a/front/[API-2025-12-08] pricing-api-enhancement-request.md +++ b/front/[API-2025-12-08] pricing-api-enhancement-request.md @@ -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` \ No newline at end of file +## 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 \ No newline at end of file diff --git a/specs/hr-api-react-sync-plan.md b/plans/hr-api-react-sync-plan.md similarity index 100% rename from specs/hr-api-react-sync-plan.md rename to plans/hr-api-react-sync-plan.md diff --git a/rules/README.md b/rules/README.md index fc85d96..9754057 100644 --- a/rules/README.md +++ b/rules/README.md @@ -18,7 +18,7 @@ | 문서 | 설명 | |------|------| -| *(작성 예정)* | | +| [pricing-policy.md](pricing-policy.md) | 단가 정책 (원가/판매가 계산, 리비전 관리) | ## 관련 폴더 - [standards/](../standards/) - 개발 표준 (어떻게 코드를 작성할 것인가) diff --git a/rules/attendance-api.md b/rules/attendance-api.md new file mode 100644 index 0000000..e3bf744 --- /dev/null +++ b/rules/attendance-api.md @@ -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 (회사별 설정 필요) diff --git a/rules/department-tree-api.md b/rules/department-tree-api.md new file mode 100644 index 0000000..1eb4101 --- /dev/null +++ b/rules/department-tree-api.md @@ -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); +``` diff --git a/rules/employee-api.md b/rules/employee-api.md new file mode 100644 index 0000000..b901c10 --- /dev/null +++ b/rules/employee-api.md @@ -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 변경으로 처리