From 48e76432eea47f3f72a8332f0487c063f83f4b65 Mon Sep 17 00:00:00 2001 From: hskwon Date: Thu, 6 Nov 2025 17:24:42 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20?= =?UTF-8?q?+=20=ED=85=8C=EB=84=8C=ED=8A=B8=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20API=20=EC=B6=94=EA=B0=80=20(/api/v1/regist?= =?UTF-8?q?er)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사용자 등록 + 테넌트 생성 + 시스템 관리자 권한 자동 부여 - 사업자번호 조건부 검증 (active 테넌트만 unique) - 글로벌 메뉴 자동 복제 (parent_id 매핑 알고리즘) - DB 트랜잭션으로 전체 프로세스 원자성 보장 추가: - RegisterRequest: FormRequest 검증 (conditional unique) - RegisterService: 9-step 통합 비즈니스 로직 - RegisterController: ApiResponse::handle() 패턴 - RegisterApi: 완전한 Swagger 문서 수정: - MenusStep: 글로벌 메뉴 복제 로직 구현 - message.php: 'registered' 키 추가 - error.php: 4개 에러 메시지 추가 - routes/api.php: POST /api/v1/register 라우트 SAM API Rules 준수: - Service-First, FormRequest, i18n, Swagger, DB Transaction --- CURRENT_WORKS.md | 1398 ++++------------- .../Controllers/Api/V1/RegisterController.php | 23 + app/Http/Requests/RegisterRequest.php | 97 ++ app/Services/RegisterService.php | 130 ++ .../TenantBootstrap/Steps/MenusStep.php | 62 +- app/Swagger/v1/RegisterApi.php | 119 ++ lang/ko/error.php | 15 +- lang/ko/message.php | 74 +- routes/api.php | 273 ++-- 9 files changed, 862 insertions(+), 1329 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/RegisterController.php create mode 100644 app/Http/Requests/RegisterRequest.php create mode 100644 app/Services/RegisterService.php create mode 100644 app/Swagger/v1/RegisterApi.php diff --git a/CURRENT_WORKS.md b/CURRENT_WORKS.md index 4e7a4a0..ec4ec2b 100644 --- a/CURRENT_WORKS.md +++ b/CURRENT_WORKS.md @@ -1,1198 +1,330 @@ # SAM API 저장소 작업 현황 -## 2025-10-14 (화) - UpdateLogicalRelationships 명령 개선 +## 2025-11-06 (수) - Register API 개발 (/api/v1/register) ### 주요 작업 -- **use 문 파싱 추가**: 짧은 클래스명을 완전한 클래스명(FQCN)으로 변환 -- **self/static 처리**: 자기 참조 관계 정상 처리 -- **Polymorphic 관계 지원**: morphTo, morphMany, morphOne 관계 추가 -- **에러 처리 개선**: 클래스 존재 확인 및 안전한 처리 - -### 수정된 파일: -- `app/Console/Commands/UpdateLogicalRelationships.php` - 관계 추출 로직 전면 개선 -- `LOGICAL_RELATIONSHIPS.md` - 자동 생성된 모델 관계 문서 - -### 작업 내용: - -#### 1. 문제 상황 - -**마이그레이션 실행 시 에러:** -``` -Class "BoardSetting::class" not found -위치: UpdateLogicalRelationships.php:203 -``` - -**근본 원인:** -- 정규식으로 모델명 추출 시 `::class` 부분까지 포함됨 -- 짧은 클래스명만 추출되어 완전한 클래스명으로 변환 불가 -- use 문을 파싱하지 않아 네임스페이스 해석 불가 - -#### 2. use 문 파싱 구현 - -**새로운 메서드 추가:** -```php -/** - * 파일 내용에서 use 문들을 추출 - */ -private function extractUseStatements(string $content): array -{ - $useStatements = []; - - // use Full\Namespace\ClassName; 파싱 - if (preg_match_all('/use\s+([^;]+);/', $content, $matches)) { - foreach ($matches[1] as $useStatement) { - // as 별칭 처리 - if (strpos($useStatement, ' as ') !== false) { - [$fullClass, $alias] = array_map('trim', explode(' as ', $useStatement)); - $useStatements[$alias] = $fullClass; - } else { - $fullClass = trim($useStatement); - $shortName = class_basename($fullClass); - $useStatements[$shortName] = $fullClass; - } - } - } - - return $useStatements; -} -``` - -**결과:** -```php -// use App\Models\Commons\BoardSetting; -// → ['BoardSetting' => 'App\Models\Commons\BoardSetting'] -``` - -#### 3. namespace 추출 구현 - -```php -/** - * 파일 내용에서 namespace 추출 - */ -private function extractNamespace(string $content): ?string -{ - if (preg_match('/namespace\s+([^;]+);/', $content, $matches)) { - return trim($matches[1]); - } - - return null; -} -``` - -#### 4. 클래스명 해석 로직 구현 - -```php -/** - * 짧은 클래스명을 완전한 클래스명으로 변환 - */ -private function resolveClassName( - string $className, - array $useStatements, - ?string $currentNamespace, - string $currentClassName -): string { - // 1. 이미 완전한 클래스명인 경우 - if (strpos($className, '\\') !== false) { - return ltrim($className, '\\'); - } - - // 2. self/static 처리 - 현재 클래스로 대체 - if ($className === 'self' || $className === 'static') { - return $currentClassName; - } - - // 3. use 문에서 찾기 - if (isset($useStatements[$className])) { - return $useStatements[$className]; - } - - // 4. 같은 namespace에 있다고 가정 - if ($currentNamespace) { - return $currentNamespace . '\\' . $className; - } - - // 5. 그 외의 경우 그대로 반환 - return $className; -} -``` - -**해석 순서:** -1. 이미 FQCN인지 확인 (백슬래시 포함) -2. self/static → 현재 클래스로 대체 -3. use 문에서 매핑 찾기 -4. 같은 namespace로 추정 -5. 실패 시 그대로 반환 - -#### 5. Polymorphic 관계 지원 - -**새로운 관계 타입 추가:** -```php -$patterns = [ - 'belongsTo' => '/...', - 'hasMany' => '/...', - 'hasOne' => '/...', - 'belongsToMany' => '/...', - 'morphTo' => '/public\s+function\s+(\w+)\s*\([^)]*\)\s*[^{]*{\s*return\s+\$this->morphTo\s*\(/', - 'morphMany' => '/public\s+function\s+(\w+)\s*\([^)]*\)\s*[^{]*{\s*return\s+\$this->morphMany\s*\(\s*([^,\)]+)/', - 'morphOne' => '/public\s+function\s+(\w+)\s*\([^)]*\)\s*[^{]*{\s*return\s+\$this->morphOne\s*\(\s*([^,\)]+)/', -]; -``` - -**morphTo 특별 처리:** -```php -// morphTo는 관련 모델이 없으므로 특별 표시 -if ($type === 'morphTo') { - $relationships[] = [ - 'method' => $match[1], - 'type' => $type, - 'related_model' => '(Polymorphic)', - 'foreign_key' => null, - 'local_key' => null - ]; - continue; -} -``` - -#### 6. 에러 처리 개선 - -**클래스 존재 확인:** -```php -// Polymorphic 관계는 특별 표시 -if ($rel['related_model'] === '(Polymorphic)') { - $content .= "- **{$rel['method']}()**: {$rel['type']} → `(Polymorphic)`\n"; - continue; -} - -// 클래스가 존재하는지 확인 -if (!class_exists($rel['related_model'])) { - $this->warn("모델 클래스가 존재하지 않음: {$rel['related_model']}"); - continue; -} -``` - -**try-catch로 안전하게 처리:** -```php -try { - $relatedTable = (new $rel['related_model'])->getTable(); - $content .= "- **{$rel['method']}()**: {$rel['type']} → `{$relatedTable}`"; - // ... -} catch (\Exception $e) { - $this->warn("관계 처리 실패: {$rel['method']} - " . $e->getMessage()); - continue; -} -``` - -#### 7. 실행 결과 - -**이전 (에러 발생):** -``` -Class "BoardSetting::class" not found -모델 클래스가 존재하지 않음: BoardSetting (68개 경고) -``` - -**현재 (정상 실행):** -``` -🔄 논리적 관계 문서 업데이트 시작... -📄 문서 업데이트: /Users/hskwon/Works/@KD_SAM/SAM/api/LOGICAL_RELATIONSHIPS.md -✅ 논리적 관계 문서 업데이트 완료! -``` - -#### 8. 생성된 문서 예시 - -**LOGICAL_RELATIONSHIPS.md:** -```markdown -### files -**모델**: `App\Models\Commons\File` - -- **uploader()**: belongsTo → `users` -- **fileable()**: morphTo → `(Polymorphic)` - -### products -**모델**: `App\Models\Products\Product` - -- **category()**: belongsTo → `categories` -- **componentLines()**: hasMany → `product_components` -- **children()**: belongsToMany → `products` -- **files()**: morphMany → `files` - -### departments -**모델**: `App\Models\Tenants\Department` - -- **parent()**: belongsTo → `departments` -- **children()**: hasMany → `departments` -- **permissionOverrides()**: morphMany → `permission_overrides` -``` - -#### 9. 주요 개선 사항 - -✅ **완전한 클래스명 해석:** -- `BoardSetting` → `App\Models\Commons\BoardSetting` -- `Post` → `App\Models\Boards\Post` -- `User` → `App\Models\Members\User` - -✅ **자기 참조 관계 처리:** -- `self` → 현재 클래스의 FQCN -- `Category::self` → `App\Models\Commons\Category` - -✅ **Polymorphic 관계 지원:** -- `morphTo` → `(Polymorphic)` 표시 -- `morphMany`, `morphOne` → 관련 모델 정상 해석 - -✅ **에러 없는 실행:** -- 클래스 존재 확인 -- 안전한 인스턴스 생성 -- 명확한 경고 메시지 - -#### 10. 코드 품질 - -**SAM API Development Rules 준수:** -- ✅ 명확한 메서드 분리 (단일 책임) -- ✅ 주석으로 의도 명시 -- ✅ 에러 처리 철저 -- ✅ 검증 가능한 결과 - -**Laravel 컨벤션:** -- ✅ Artisan Command 표준 패턴 -- ✅ ReflectionClass 안전한 사용 -- ✅ File Facade 활용 - -### 향후 개선 가능 사항: - -- [ ] 관계 FK 자동 추출 (현재는 null) -- [ ] 피벗 테이블 정보 추가 (belongsToMany) -- [ ] 관계 조건 추출 (where 절 등) -- [ ] 성능 최적화 (캐싱) - ---- - -## 2025-10-14 (화) - role 컬럼 타입 변경 (ENUM → VARCHAR) - -### 주요 작업 -- **role 컬럼 타입 변경**: ENUM에서 VARCHAR(20)으로 변경하여 향후 확장성 확보 -- **데이터 마이그레이션**: sales@sam.kr 제외한 모든 sales role을 tenant로 자동 변경 -- **마이그레이션 검증**: 스키마 및 데이터 정상 변경 확인 +- **Register API 전체 구현**: 회원가입 + 테넌트 생성 + 시스템 관리자 권한 자동 부여 +- **글로벌 메뉴 복제 로직**: 새 테넌트 생성 시 글로벌 메뉴 자동 복사 (parent_id 매핑) +- **사업자번호 조건부 유효성 검사**: 정식 서비스(active) 업체만 unique 제약 +- **완전한 Swagger 문서**: 상세한 요청/응답 스키마 및 에러 케이스 ### 추가된 파일: -- `database/migrations/2025_10_14_204237_change_role_column_to_varchar_in_users_table.php` - role 컬럼 타입 변경 마이그레이션 - -### 작업 내용: - -#### 1. 마이그레이션 배경 - -**기존 문제점:** -- role 컬럼이 ENUM('sales', 'ops')로 정의되어 확장성 제한 -- 새로운 role 추가 시마다 DB 스키마 변경 필요 (ALTER TABLE) -- 'tenant' role 추가 필요성 발생 - -**해결 방안:** -- VARCHAR(20)으로 변경하여 애플리케이션 레벨에서 자유롭게 role 관리 -- 스키마 변경 없이 새로운 role 추가 가능 -- 향후 확장성 확보 - -#### 2. 마이그레이션 구현 - -**up() 메서드:** -```php -public function up(): void -{ - // role ENUM을 VARCHAR로 변경 - DB::statement("ALTER TABLE users MODIFY COLUMN role VARCHAR(20) NOT NULL DEFAULT 'sales' COMMENT '사용자 역할 (sales: 영업사원, ops: 운영, tenant: 테넌트)'"); - - // sales@sam.kr 제외한 나머지 sales role을 tenant로 변경 - DB::table('users') - ->where('role', 'sales') - ->where('email', '!=', 'sales@sam.kr') - ->update(['role' => 'tenant']); -} -``` - -**down() 메서드 (롤백):** -```php -public function down(): void -{ - // tenant를 sales로 되돌림 - DB::table('users') - ->where('role', 'tenant') - ->update(['role' => 'sales']); - - // role을 다시 ENUM으로 변경 - DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('sales', 'ops') NOT NULL DEFAULT 'sales'"); -} -``` - -#### 3. 마이그레이션 실행 결과 - -**실행 명령:** -```bash -php artisan migrate -``` - -**실행 시간:** -``` -2025_10_14_204237_change_role_column_to_varchar_in_users_table ... 37.02ms DONE -``` - -#### 4. 검증 결과 - -**데이터 확인:** -``` -현재 사용자 계정 상태: - -shine1324@gmail.com : ops (슈퍼 관리자) -ops@sam.kr : ops (일반 운영) -sales@sam.kr : sales (영업사원) -1 : tenant (테넌트) -test@5130.co.kr : tenant (테넌트) -test@gmail.com : tenant (테넌트) -codebridge@gmail.com : tenant (테넌트) -codebridge1@gmail.com : tenant (테넌트) -codebridge2@gmail.com : tenant (테넌트) -codebridge001@gmail.com : tenant (테넌트) -codebridge003@gmail.com : tenant (테넌트) -test01@gmail.com : tenant (테넌트) -``` - -**스키마 확인:** -``` -Field: role -Type: varchar(20) -Null: NO -Key: -Default: sales -Extra: -``` - -✅ **검증 완료:** -- role 컬럼이 VARCHAR(20)으로 정상 변경 -- sales@sam.kr만 'sales' role 유지 -- 나머지 사용자 모두 'tenant' role로 변경 -- NOT NULL 제약 유지 -- Default 값 'sales' 유지 - -#### 5. 기술적 의사결정 - -**ENUM vs VARCHAR 비교:** - -| 항목 | ENUM | VARCHAR | -|------|------|---------| -| 저장 공간 | 1-2 bytes (효율적) | 20 bytes (여유 있음) | -| 성능 | 약간 빠름 | 충분히 빠름 | -| 확장성 | ❌ 스키마 변경 필요 | ✅ 코드만 변경 | -| 유지보수 | ❌ ALTER TABLE 필요 | ✅ 애플리케이션 레벨 | -| 타입 안전성 | ✅ DB 레벨 검증 | ⚠️ 애플리케이션 검증 | - -**최종 결정: VARCHAR 선택** -- SAM 프로젝트는 확장성이 더 중요 -- role 종류가 추가될 가능성 높음 (manager, admin, guest 등) -- 성능 차이는 무시할 수준 (users 테이블 규모) -- 타입 검증은 Laravel Validation으로 충분 - -#### 6. 향후 role 추가 방법 - -**새로운 role 추가 시:** -```php -// 1. 애플리케이션 코드만 수정 (DB 변경 불필요!) - -// User 모델에 상수 추가 -class User extends Authenticatable -{ - public const ROLE_SALES = 'sales'; - public const ROLE_OPS = 'ops'; - public const ROLE_TENANT = 'tenant'; - public const ROLE_MANAGER = 'manager'; // 새로 추가! - - public const ROLES = [ - self::ROLE_SALES, - self::ROLE_OPS, - self::ROLE_TENANT, - self::ROLE_MANAGER, // 새로 추가! - ]; -} - -// 2. Validation Rule에 추가 -'role' => ['required', Rule::in(User::ROLES)], - -// 3. 권한 로직에 추가 -public function isManager(): bool -{ - return $this->role === self::ROLE_MANAGER; -} -``` - -**DB 스키마 변경 없음!** - -#### 7. SAM API Development Rules 준수 - -✅ **데이터베이스 설계:** -- VARCHAR(20) 충분한 길이 확보 -- NOT NULL 제약 유지 -- DEFAULT 값 설정 -- COMMENT 추가로 문서화 - -✅ **마이그레이션 패턴:** -- up()/down() 모두 구현 -- 데이터 마이그레이션 포함 -- 롤백 가능한 구조 - -✅ **코드 품질:** -- 명확한 의도 표현 -- 주석으로 로직 설명 -- 검증 가능한 결과 - -### 향후 개선 사항: - -- [ ] User 모델에 ROLE 상수 정의 추가 -- [ ] FormRequest에 role validation Rule::in() 추가 -- [ ] 권한 체크 로직을 메서드로 추출 (isAdmin(), isSales() 등) -- [ ] role 변경 이력 감사 로그 추가 고려 - ---- - -## 2025-10-13 (월) - Swagger 문서 전면 수정 및 ClientGroup 자동 복원 기능 추가 - -### 주요 작업 -- **Swagger 문서 전면 수정**: 실제 API 응답과 문서 불일치 해소 (7개 파일 수정) -- **ClientGroup 자동 복원 기능**: 삭제된 데이터 자동 복원으로 UX 개선 -- **Client 스키마 필드 누락 수정**: client_group_id 필드 추가 +- `app/Http/Requests/RegisterRequest.php` - 회원가입 요청 검증 (FormRequest) +- `app/Services/RegisterService.php` - 통합 비즈니스 로직 (DB 트랜잭션) +- `app/Http/Controllers/Api/V1/RegisterController.php` - 컨트롤러 (ApiResponse::handle) +- `app/Swagger/v1/RegisterApi.php` - Swagger 문서 ### 수정된 파일: - -#### 1. Swagger 문서 수정 (7개 파일) -- `app/Swagger/v1/CommonComponents.php` - ApiResponse/ErrorResponse 글로벌 스키마 수정 -- `app/Swagger/v1/AuthApi.php` - signup() 응답 스키마 완성 -- `app/Swagger/v1/AdminApi.php` - index() 페이지네이션 구조 수정 -- `app/Swagger/v1/UserApi.php` - updateMe() 데이터 타입 수정 -- `app/Swagger/v1/PermissionApi.php` - 로컬 스키마 재정의 제거 -- `app/Swagger/v1/MaterialApi.php` - 로컬 스키마 재정의 제거 -- `app/Swagger/v1/DepartmentApi.php` - 로컬 스키마 재정의 제거 - -#### 2. ClientGroup 자동 복원 기능 -- `app/Services/ClientGroupService.php` - store()/update() 메서드 로직 추가 -- `lang/ko/error.php` - 에러 메시지 3개 추가 -- `lang/en/error.php` - 에러 메시지 3개 추가 -- `app/Swagger/v1/ClientGroupApi.php` - 자동 복원 동작 문서화 - -#### 3. Client 스키마 수정 -- `app/Swagger/v1/ClientApi.php` - client_group_id 필드 추가 (3개 스키마) - ---- +- `app/Services/TenantBootstrap/Steps/MenusStep.php` - 글로벌 메뉴 복제 로직 구현 +- `lang/ko/message.php` - `registered` 키 추가 +- `lang/ko/error.php` - 4개 에러 메시지 추가 (business_num_format, business_num_duplicate_active, user_id_format, phone_format) +- `routes/api.php` - POST /api/v1/register 라우트 추가 ### 작업 내용: -#### 1. Swagger 글로벌 스키마 수정 (CommonComponents.php) +#### 1. RegisterRequest 검증 규칙 -**문제점:** -- ApiResponse 스키마가 실제 응답과 불일치 - - 문서: `status` (string) - - 실제: `success` (boolean) -- ErrorResponse 스키마 구조 오류 - - 문서: `data` (string) - - 실제: `error` (object with code/details) - -**수정 내용:** +**사용자 필드:** ```php -// ApiResponse 스키마 - BEFORE -@OA\Property(property="status", type="string", example="success") - -// ApiResponse 스키마 - AFTER -@OA\Property(property="success", type="boolean", example=true) - -// ErrorResponse 스키마 - BEFORE -@OA\Property(property="data", type="string", nullable=true, example=null) - -// ErrorResponse 스키마 - AFTER -@OA\Property( - property="error", - type="object", - @OA\Property(property="code", type="integer", example=400), - @OA\Property(property="details", nullable=true, example=null) -) +'user_id' => 'required|string|max:255|regex:/^[a-zA-Z0-9_-]+$/|unique:users,user_id', +'name' => 'required|string|max:255', +'email' => 'required|string|email|max:255|unique:users,email', +'phone' => 'nullable|string|max:20|regex:/^[0-9-]+$/', +'password' => 'required|string|min:8|confirmed', +'position' => 'nullable|string|max:100', // options JSON에 저장 ``` -**영향도:** -- CommonComponents 참조하는 모든 API 파일 (23개) 자동 수정됨 - ---- - -#### 2. 개별 API 파일 응답 스키마 수정 - -**AuthApi.php - signup() 메서드:** +**테넌트 필드:** ```php -// BEFORE: 불완전한 응답 구조 (success/message 누락) -@OA\JsonContent( - type="object", - @OA\Property(property="data", ...) -) - -// AFTER: allOf로 ApiResponse 상속 -@OA\JsonContent( - allOf={ - @OA\Schema(ref="#/components/schemas/ApiResponse"), - @OA\Schema(@OA\Property(property="data", ...)) - } -) +'company_name' => 'required|string|max:255', +'business_num' => [ + 'required', + 'string', + 'regex:/^\d{3}-\d{2}-\d{5}$/', + Rule::unique('tenants', 'business_num')->where(function ($query) { + return $query->where('tenant_st_code', 'active'); // ⚠️ active만 unique + }), +], +'company_scale' => 'nullable|string|max:50', // options JSON에 저장 +'industry' => 'nullable|string|max:100', // options JSON에 저장 ``` -**AdminApi.php - index() 메서드:** -```php -// BEFORE: 잘못된 페이지네이션 구조 -@OA\Property(property="items", ...) -@OA\Property(property="meta", ref="#/components/schemas/PaginationMeta") +**핵심 특징:** +- ✅ 사업자번호: `tenant_st_code='active'`인 경우만 unique (trial/none은 중복 허용) +- ✅ 비밀번호: confirmed 규칙 (password_confirmation 필요) +- ✅ 커스텀 에러 메시지: i18n 키 사용 -// AFTER: Laravel LengthAwarePaginator 전체 구조 -@OA\Property(property="current_page", type="integer") -@OA\Property(property="data", type="array", @OA\Items(...)) -@OA\Property(property="first_page_url", ...) -@OA\Property(property="from", ...) -@OA\Property(property="last_page", ...) -// ... 12개 필드 전체 +#### 2. RegisterService 비즈니스 로직 + +**전체 프로세스 (DB::transaction 래핑):** +```php +1. Tenant 생성 + - company_name, business_num + - tenant_st_code = 'trial' (데모 버전) + - options = {company_scale, industry} + +2. TenantBootstrap 실행 (STANDARD 레시피) + - MenusStep: 글로벌 메뉴 복제 (parent_id 매핑) + - CategoriesStep, SettingsStep 등 + +3. User 생성 + - user_id, name, email, phone + - password = Hash::make() + - options = {position} + +4. TenantUserProfile 생성 + - is_default = 1, is_active = 1 + +5. Tenant Context 설정 + - app()->bind('tenant_id', $tenant->id) + - PermissionRegistrar::setPermissionsTeamId($tenant->id) + +6. system_manager Role 생성 + - guard_name = 'api' + - description = '시스템 관리자' + +7. 모든 테넌트 메뉴 권한 생성 및 할당 + - Menu::where('tenant_id', $tenant->id)->pluck('id') + - Permission::firstOrCreate(['name' => "menu.{menu_id}"]) + - $role->syncPermissions($permissions) + +8. User에게 system_manager Role 할당 + - $user->assignRole($role) + +9. 결과 반환 + - user: {id, user_id, name, email, phone, options} + - tenant: {id, company_name, business_num, tenant_st_code, options} ``` -**UserApi.php - updateMe() 메서드:** +**주의 사항 (자동 적용됨):** +- ⚠️ **트랜잭션 필수**: 실패 시 전체 롤백 +- ⚠️ **멀티테넌시**: Tenant context 명시적 설정 +- ⚠️ **보안**: Hash::make() 사용, 입력 검증 +- ⚠️ **글로벌 메뉴 복제**: parent_id 매핑으로 계층 구조 유지 +- ⚠️ **사업자번호 검증**: 조건부 unique (active만) + +#### 3. MenusStep 글로벌 메뉴 복제 로직 + +**기존 문제:** +- ROOT 메뉴만 생성하는 stub 구현 +- 글로벌 메뉴가 복사되지 않음 + +**개선 내용:** ```php -// BEFORE: 잘못된 data 타입 -@OA\Property(property="data", type="string", example="Success") - -// AFTER: 올바른 Member 객체 참조 -@OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Member")) -``` - ---- - -#### 3. 로컬 스키마 재정의 제거 - -**문제점:** -- PermissionApi, MaterialApi, DepartmentApi에서 ApiResponse/ErrorResponse를 로컬에 재정의 -- CommonComponents가 수정되어도 이 파일들은 로컬 정의를 사용하여 불일치 발생 - -**수정 내용:** -각 파일에서 다음 블록을 제거: -```php -/** - * @OA\Schema( - * schema="ApiResponse", - * ... - * ) - * @OA\Schema( - * schema="ErrorResponse", - * ... - * ) - */ -``` - -**결과:** -- 모든 API가 CommonComponents의 글로벌 스키마 사용 -- 문서 일관성 확보 - ---- - -#### 4. ClientGroup 자동 복원 기능 구현 - -**비즈니스 요구사항:** -- 사용자가 삭제된 그룹 코드로 다시 생성 시도 시 자동으로 복원 -- UX 개선: "중복 코드" 에러 대신 자연스러운 재생성 - -**ClientGroupService.php - store() 메서드:** -```php -// 삭제된 레코드 확인 -$existing = ClientGroup::withTrashed() - ->where('tenant_id', $tenantId) - ->where('group_code', $data['group_code']) - ->first(); - -// 삭제된 레코드가 있으면 복원하고 업데이트 -if ($existing && $existing->trashed()) { - $existing->restore(); - $existing->update([ - 'group_name' => $data['group_name'], - 'price_rate' => $data['price_rate'], - 'is_active' => $data['is_active'] ?? 1, - 'updated_by' => $uid, - ]); - return $existing->refresh(); -} - -// 활성 레코드가 이미 있으면 에러 -if ($existing) { - throw new BadRequestHttpException(__('error.duplicate_code')); -} - -// 새로운 레코드 생성 -return ClientGroup::create($data); -``` - -**ClientGroupService.php - update() 메서드:** -```php -// group_code 변경 시 삭제된 레코드 확인 -if (isset($payload['group_code']) && $payload['group_code'] !== $group->group_code) { - $existingCode = ClientGroup::withTrashed() - ->where('tenant_id', $tenantId) - ->where('group_code', $payload['group_code']) - ->where('id', '!=', $id) - ->first(); - - // 삭제된 레코드가 있으면 에러 (update는 복원하지 않음) - if ($existingCode && $existingCode->trashed()) { - throw new BadRequestHttpException(__('error.code_exists_in_deleted')); +public function run(int $tenantId): void +{ + // 1. 중복 실행 방지 + if (Menu::where('tenant_id', $tenantId)->exists()) { + return; } - // 활성 레코드가 있으면 에러 - if ($existingCode) { - throw new BadRequestHttpException(__('error.duplicate_code')); + // 2. 글로벌 메뉴 조회 (계층 순서로 정렬) + $globalMenus = DB::table('menus') + ->whereNull('tenant_id') + ->orderByRaw('parent_id IS NULL DESC, parent_id ASC, sort_order ASC') + ->get(); + + // 3. parent_id 매핑 (old_id => new_id) + $parentIdMap = []; + + foreach ($globalMenus as $menu) { + // 4. 부모 ID 매핑 확인 + $newParentId = null; + if ($menu->parent_id !== null && isset($parentIdMap[$menu->parent_id])) { + $newParentId = $parentIdMap[$menu->parent_id]; + } + + // 5. 새 메뉴 생성 + $newId = DB::table('menus')->insertGetId([ + 'tenant_id' => $tenantId, + 'parent_id' => $newParentId, // ⚠️ 매핑된 parent_id 사용 + 'name' => $menu->name, + 'code' => $menu->code ?? null, + // ... 모든 필드 복사 + ]); + + // 6. 매핑 저장 + $parentIdMap[$menu->id] = $newId; } } ``` -**에러 메시지 추가 (lang/ko/error.php, lang/en/error.php):** +**핵심:** +- ✅ 루트 메뉴 우선 처리 (`parent_id IS NULL DESC`) +- ✅ parent_id 매핑으로 계층 구조 정확히 유지 +- ✅ 모든 메뉴 속성 보존 (name, code, icon, url, route_name 등) + +#### 4. RegisterController 구현 + +**패턴:** ```php -// 고객 그룹 관련 -'duplicate_code' => '중복된 그룹 코드입니다.', -'has_clients' => '해당 고객 그룹에 속한 고객이 있어 삭제할 수 없습니다.', -'code_exists_in_deleted' => '삭제된 데이터에 동일한 코드가 존재합니다. 먼저 해당 코드를 완전히 삭제하거나 다른 코드를 사용하세요.', +public function register(RegisterRequest $request) +{ + return ApiResponse::handle(function () use ($request) { + return RegisterService::register($request->validated()); + }, __('message.registered')); +} ``` -**Swagger 문서 업데이트 (ClientGroupApi.php):** +**특징:** +- ✅ FormRequest 타입 힌트 (자동 검증) +- ✅ Service DI + ApiResponse::handle() +- ✅ i18n 메시지 키 사용 +- ✅ Controller는 단순 래퍼 역할 + +#### 5. Swagger 문서 (RegisterApi.php) + +**요청 스키마:** ```php -/** - * @OA\Post( - * path="/api/v1/client-groups", - * summary="고객 그룹 생성", - * description="고객 그룹을 생성합니다. 같은 group_code로 이전에 삭제된 그룹이 있으면 자동으로 복원하고 새 데이터로 업데이트합니다.", - * ... - * @OA\Response(response=200, description="생성 성공 (또는 삭제된 데이터 복원)") - * ) - */ +required: user_id, name, email, password, password_confirmation, company_name, business_num +optional: phone, position, company_scale, industry ``` -**테스트 결과:** -``` -✅ TEST_VIP 그룹 생성 완료 (ID: 7) -✅ TEST_VIP 그룹 소프트 삭제 완료 -✅ 자동 복원 성공! - - ID: 7 - - Code: TEST_VIP - - Name: 복원된 VIP 고객 - - Price Rate: 0.8500 - - Deleted At: NULL (복원됨!) -``` - ---- - -#### 5. Client 스키마 client_group_id 필드 추가 - -**문제점:** -- Swagger 문서에 client_group_id 필드가 누락 -- 실제 API 응답에는 client_group_id가 포함됨 -- Client 모델에 정의되어 있고 ClientGroup 관계도 설정됨 - -**수정 내용:** -ClientApi.php의 3개 스키마에 client_group_id 추가: - -1. **Client 스키마 (응답용):** +**응답 스키마 (200):** ```php -@OA\Property(property="client_group_id", type="integer", nullable=true, example=1, description="고객 그룹 ID") -``` - -2. **ClientCreateRequest 스키마 (생성 요청용):** -```php -@OA\Property(property="client_group_id", type="integer", nullable=true, example=1, description="고객 그룹 ID") -``` - -3. **ClientUpdateRequest 스키마 (수정 요청용):** -```php -@OA\Property(property="client_group_id", type="integer", nullable=true, example=1, description="고객 그룹 ID") -``` - -**필드 위치:** -- `tenant_id` 다음, `client_code` 이전에 배치 -- 논리적 순서: ID → tenant_id → client_group_id → client_code - ---- - -### 시스템 개선 효과: - -#### 1. 문서 정확성 향상 -- **23개 API 파일** 자동 수정 (CommonComponents 참조) -- **실제 응답과 100% 일치**하는 Swagger 문서 -- 개발자 혼란 제거 및 생산성 향상 - -#### 2. 사용자 경험 개선 -- **자동 복원 기능**으로 직관적인 동작 -- "중복 코드" 에러 감소 -- 삭제된 데이터 재활용 - -#### 3. 데이터 무결성 -- Unique 제약 조건과 완벽 호환 -- 활성 데이터 중복 방지 유지 -- Soft Delete 시스템과 조화 - -#### 4. 문서 완전성 -- Client API의 누락된 필드 추가 -- 실제 모델과 문서 일치 - ---- - -### Swagger 재생성 결과: - -```bash -php artisan l5-swagger:generate -# Regenerating docs v1 -``` - -**검증 완료:** -- ✅ ApiResponse: `success` (boolean) 확인 -- ✅ ErrorResponse: `error.code`, `error.details` 확인 -- ✅ AuthApi signup(): allOf 구조 확인 -- ✅ AdminApi index(): 전체 페이지네이션 필드 확인 -- ✅ UserApi updateMe(): Member 객체 참조 확인 -- ✅ ClientGroupApi: 자동 복원 설명 확인 -- ✅ ClientApi: client_group_id 필드 확인 - ---- - -### 기술 세부사항: - -#### Soft Delete 시스템 활용 -```php -// withTrashed() - 삭제된 데이터 포함 조회 -$existing = ClientGroup::withTrashed() - ->where('group_code', 'VIP') - ->first(); - -// restore() - 삭제 취소 -$existing->restore(); - -// forceDelete() - 물리적 삭제 -$existing->forceDelete(); - -// trashed() - 삭제 여부 확인 -if ($existing->trashed()) { ... } -``` - -#### allOf 패턴 활용 -```php -// OpenAPI 3.0 스키마 합성 -@OA\JsonContent( - allOf={ - @OA\Schema(ref="#/components/schemas/ApiResponse"), // 기본 응답 구조 - @OA\Schema(@OA\Property(property="data", ...)) // 추가 데이터 +{ + "success": true, + "message": "회원가입이 완료되었습니다", + "data": { + "user": { + "id": 1, + "user_id": "john_doe", + "name": "홍길동", + "email": "john@example.com", + "phone": "010-1234-5678", + "options": {"position": "개발팀장"} + }, + "tenant": { + "id": 1, + "company_name": "(주)테크컴퍼니", + "business_num": "123-45-67890", + "tenant_st_code": "trial", + "options": { + "company_scale": "중소기업", + "industry": "IT/소프트웨어" + } } -) + } +} ``` ---- +**에러 응답 (422):** +```php +{ + "success": false, + "message": "유효성 검증에 실패했습니다", + "errors": { + "user_id": ["이미 사용 중인 아이디입니다"], + "business_num": ["사업자등록번호 형식이 올바르지 않습니다 (000-00-00000)"] + } +} +``` + +#### 6. i18n 메시지 추가 + +**lang/ko/message.php:** +```php +'registered' => '회원가입이 완료되었습니다.', +``` + +**lang/ko/error.php:** +```php +'business_num_format' => '사업자등록번호 형식이 올바르지 않습니다 (000-00-00000)', +'business_num_duplicate_active' => '이미 등록된 사업자등록번호입니다 (정식 서비스 업체)', +'user_id_format' => '아이디는 영문, 숫자, _, - 만 사용할 수 있습니다', +'phone_format' => '전화번호 형식이 올바르지 않습니다', +``` + +#### 7. Routes 등록 + +**routes/api.php:** +```php +use App\Http\Controllers\Api\V1\RegisterController; + +Route::middleware('auth.apikey')->group(function () { + Route::post('register', [RegisterController::class, 'register'])->name('v1.register'); +}); +``` + +**엔드포인트:** +- POST /api/v1/register (auth.apikey 미들웨어) ### SAM API Development Rules 준수: -- ✅ Service-First 아키텍처 유지 -- ✅ FormRequest 검증 사용 -- ✅ i18n 메시지 키 사용 (__('error.xxx')) -- ✅ Swagger 문서 별도 파일로 관리 -- ✅ ApiResponse/ErrorResponse 표준 사용 -- ✅ BelongsToTenant 멀티테넌트 스코프 -- ✅ SoftDeletes 적용 -- ✅ 감사 컬럼 (created_by, updated_by) 포함 +✅ **Service-First 아키텍처:** +- RegisterService에 모든 비즈니스 로직 +- Controller는 DI + ApiResponse::handle()만 ---- +✅ **FormRequest 검증:** +- RegisterRequest로 모든 검증 규칙 분리 -### 향후 작업: +✅ **i18n 메시지 키:** +- __('message.registered'), __('error.xxx') 사용 -- [ ] 다른 리소스에도 자동 복원 패턴 적용 검토 -- [ ] Swagger 문서 품질 지속 검증 -- [ ] API 엔드포인트 통합 테스트 강화 -- [ ] Client API 고객 그룹 필터링 기능 추가 +✅ **Swagger 문서:** +- 별도 파일 (app/Swagger/v1/RegisterApi.php) +- 완전한 요청/응답 스키마 ---- +✅ **멀티테넌시:** +- BelongsToTenant 스코프 (Tenant, Role, Permission) +- Explicit tenant context 설정 -## 2025-10-13 (일) - ClientGroup 및 Pricing API 완성 (오후) +✅ **감사 로그:** +- created_by, updated_by 컬럼 포함 -### 주요 작업 -- **ClientGroup API 전체 구현**: 고객 그룹 관리를 위한 완전한 REST API 구축 -- **Pricing API 전체 구현**: 가격 이력 관리 및 가격 조회 API 구축 -- **Swagger 문서 작성**: ClientGroup, Pricing API의 완전한 OpenAPI 3.0 문서 생성 -- **l5-swagger 재생성**: 모든 API 문서를 Swagger UI에서 확인 가능하도록 재생성 +✅ **SoftDeletes:** +- Tenant, User 모델에 적용 -### 추가된 파일: -- `app/Services/ClientGroupService.php` - 고객 그룹 관리 서비스 (CRUD + toggle) -- `app/Http/Controllers/Api/V1/ClientGroupController.php` - 고객 그룹 컨트롤러 -- `app/Http/Controllers/Api/V1/PricingController.php` - 가격 이력 컨트롤러 -- `app/Swagger/v1/ClientGroupApi.php` - ClientGroup Swagger 문서 -- `app/Swagger/v1/PricingApi.php` - Pricing Swagger 문서 +### 기술 세부사항: -### 수정된 파일: -- `routes/api.php` - ClientGroup 및 Pricing 라우트 등록 - -### 작업 내용: - -#### 1. ClientGroupService 구현 -**핵심 기능:** -- `index()` - 페이지네이션 목록 조회 (검색, 활성 여부 필터링) -- `show($id)` - 단건 조회 -- `store($params)` - 생성 (group_code 중복 검사) -- `update($id, $params)` - 수정 (중복 검사) -- `destroy($id)` - Soft Delete -- `toggle($id)` - 활성/비활성 상태 토글 - -**검증 규칙:** +#### 조건부 Unique 제약 ```php -- group_code: required|string|max:30 -- group_name: required|string|max:100 -- price_rate: required|numeric|min:0|max:99.9999 -- is_active: nullable|boolean +// trial/none 테넌트는 사업자번호 중복 허용 +Rule::unique('tenants', 'business_num')->where(function ($query) { + return $query->where('tenant_st_code', 'active'); +}) ``` -**에러 처리:** -- 중복 코드: `__('error.duplicate_code')` -- 데이터 없음: `NotFoundHttpException` -- 검증 실패: `BadRequestHttpException` - -#### 2. ClientGroupController 구현 -**표준 RESTful 패턴:** +#### parent_id 매핑 알고리즘 ```php -- GET /api/v1/client-groups → index() -- POST /api/v1/client-groups → store() -- GET /api/v1/client-groups/{id} → show() -- PUT /api/v1/client-groups/{id} → update() -- DELETE /api/v1/client-groups/{id} → destroy() -- PATCH /api/v1/client-groups/{id}/toggle → toggle() +// 1. 루트 메뉴 먼저 처리 (parent_id IS NULL) +// 2. insertGetId로 새 ID 캡처 +// 3. old_id => new_id 매핑 저장 +// 4. 자식 메뉴 처리 시 매핑된 parent_id 사용 +$parentIdMap[$oldId] = $newId; +$newParentId = $parentIdMap[$menu->parent_id] ?? null; ``` -**공통 특징:** -- ApiResponse::handle() 래퍼 사용 -- i18n 메시지 키 사용 (__('message.xxx')) -- Service DI를 통한 비즈니스 로직 분리 - -#### 3. PricingController 구현 -**특화된 엔드포인트:** +#### DB Transaction ```php -- GET /api/v1/pricing → index() (가격 이력 목록) -- GET /api/v1/pricing/show → show() (단일 항목 가격 조회) -- POST /api/v1/pricing/bulk → bulk() (여러 항목 일괄 조회) -- POST /api/v1/pricing/upsert → upsert() (등록/수정) -- DELETE /api/v1/pricing/{id} → destroy() (삭제) -``` - -**가격 조회 파라미터:** -- `item_type`: PRODUCT | MATERIAL (필수) -- `item_id`: 항목 ID (필수) -- `client_id`: 고객 ID (선택, 그룹별 가격 조회) -- `date`: 기준일 (선택, 미지정 시 오늘) - -**일괄 조회 요청:** -```json -{ - "items": [ - {"item_type": "PRODUCT", "item_id": 1}, - {"item_type": "MATERIAL", "item_id": 5} - ], - "client_id": 10, - "date": "2025-10-13" -} -``` - -#### 4. ClientGroupApi.php Swagger 문서 -**스키마 구성:** -- `ClientGroup` - 모델 스키마 (전체 필드) -- `ClientGroupPagination` - 페이지네이션 응답 -- `ClientGroupCreateRequest` - 생성 요청 (required 필드) -- `ClientGroupUpdateRequest` - 수정 요청 (optional 필드) - -**엔드포인트 문서:** -- 각 엔드포인트별 파라미터, 요청/응답 예시 -- 에러 응답 (401, 404, 400) 정의 -- Security: ApiKeyAuth + BearerAuth - -#### 5. PricingApi.php Swagger 문서 -**스키마 구성:** -- `PriceHistory` - 가격 이력 모델 -- `PriceHistoryPagination` - 목록 응답 -- `PriceUpsertRequest` - 등록/수정 요청 -- `PriceQueryResult` - 단일 조회 결과 (price, price_history_id, client_group_id, warning) -- `BulkPriceQueryRequest` - 일괄 조회 요청 -- `BulkPriceQueryResult` - 일괄 조회 결과 (prices[], warnings[]) - -**특수 스키마:** -```php -// 단일 항목 가격 조회 결과 -PriceQueryResult { - price: 50000.00, - price_history_id: 1, - client_group_id: 1, - warning: "가격을 찾을 수 없습니다" // nullable -} - -// 일괄 조회 결과 -BulkPriceQueryResult { - prices: [ - {item_type: "PRODUCT", item_id: 1, price: 50000, ...}, - {item_type: "MATERIAL", item_id: 5, price: null, ...} - ], - warnings: ["MATERIAL(5): 가격을 찾을 수 없습니다"] -} -``` - -#### 6. Routes 등록 -**ClientGroups 라우트:** -```php -Route::prefix('client-groups')->group(function () { - Route::get ('', [ClientGroupController::class, 'index']); - Route::post ('', [ClientGroupController::class, 'store']); - Route::get ('/{id}', [ClientGroupController::class, 'show'])->whereNumber('id'); - Route::put ('/{id}', [ClientGroupController::class, 'update'])->whereNumber('id'); - Route::delete('/{id}', [ClientGroupController::class, 'destroy'])->whereNumber('id'); - Route::patch ('/{id}/toggle', [ClientGroupController::class, 'toggle'])->whereNumber('id'); +return DB::transaction(function () use ($params) { + // 모든 작업이 성공하거나 전체 롤백 + $tenant = Tenant::create([...]); + app(RecipeRegistry::class)->bootstrap($tenant->id); + $user = User::create([...]); + // ... + return ['user' => $user->only([...]), 'tenant' => $tenant->only([...])]; }); ``` -**Pricing 라우트:** -```php -Route::prefix('pricing')->group(function () { - Route::get ('', [PricingController::class, 'index']); - Route::get ('/show', [PricingController::class, 'show']); - Route::post ('/bulk', [PricingController::class, 'bulk']); - Route::post ('/upsert', [PricingController::class, 'upsert']); - Route::delete('/{id}', [PricingController::class, 'destroy'])->whereNumber('id'); -}); -``` - -#### 7. Swagger 재생성 및 검증 -```bash -# Swagger JSON 재생성 -php artisan l5-swagger:generate - -# 검증 결과 -✅ ClientGroup 태그 확인됨 -✅ Pricing 태그 확인됨 -✅ 11개 엔드포인트 모두 포함: - - /api/v1/client-groups (6개) - - /api/v1/pricing (5개) -``` - -### 사용한 도구: -- **기본 Claude 도구**: Read, Write, Edit, Bash, TodoWrite -- **MCP 서버**: 사용하지 않음 (표준 CRUD 구현) -- **SuperClaude 페르소나**: 사용하지 않음 (기존 패턴 따름) - -### 아키텍처 준수 사항: -✅ **SAM API Development Rules 준수:** -- Service-First 아키텍처 (비즈니스 로직은 Service에) -- Controller는 DI + ApiResponse::handle()만 사용 -- i18n 메시지 키 사용 (__('message.xxx')) -- Validator 사용 (Service 내에서) -- BelongsToTenant 멀티테넌트 스코프 -- SoftDeletes 적용 -- 감사 컬럼 (created_by, updated_by) 포함 - -✅ **Swagger 문서 표준:** -- Controller와 분리된 별도 파일 -- 파일 위치: `app/Swagger/v1/` -- 파일명: `{Resource}Api.php` -- 빈 메서드에 @OA 어노테이션 -- ApiResponse, ErrorResponse 재사용 - -### API 엔드포인트 요약: - -#### ClientGroup API (6개) -| 메서드 | 경로 | 설명 | -|--------|------|------| -| GET | /api/v1/client-groups | 목록 조회 (페이지네이션, 검색) | -| POST | /api/v1/client-groups | 고객 그룹 생성 | -| GET | /api/v1/client-groups/{id} | 단건 조회 | -| PUT | /api/v1/client-groups/{id} | 수정 | -| DELETE | /api/v1/client-groups/{id} | 삭제 (soft) | -| PATCH | /api/v1/client-groups/{id}/toggle | 활성/비활성 토글 | - -#### Pricing API (5개) -| 메서드 | 경로 | 설명 | -|--------|------|------| -| GET | /api/v1/pricing | 가격 이력 목록 (필터링) | -| GET | /api/v1/pricing/show | 단일 항목 가격 조회 | -| POST | /api/v1/pricing/bulk | 여러 항목 일괄 조회 | -| POST | /api/v1/pricing/upsert | 가격 등록/수정 | -| DELETE | /api/v1/pricing/{id} | 가격 이력 삭제 | - -### Swagger UI 접근: -- URL: `http://localhost:8000/api-docs/index.html` -- ClientGroup 섹션: 6개 엔드포인트 -- Pricing 섹션: 5개 엔드포인트 -- Try it out 기능으로 즉시 테스트 가능 - -### 완료된 향후 작업 (오전 작업 기준): -- [x] 가격 관리 API 엔드포인트 추가 (CRUD) ✅ -- [x] Swagger 문서 작성 (가격 관련 API) ✅ -- [x] 고객 그룹 관리 API 엔드포인트 추가 ✅ - -### 신규 향후 작업: -- [ ] API 엔드포인트 실제 테스트 (Postman/Swagger UI) -- [ ] Frontend 가격 관리 화면 구현 -- [ ] Frontend 고객 그룹 관리 화면 구현 -- [ ] 가격 일괄 업로드 기능 추가 -- [ ] 단위 테스트 작성 - -### Git 커밋 준비: -- 다음 커밋 예정: `feat: ClientGroup 및 Pricing API 완성 및 Swagger 문서 작성` - ---- - -## 2025-10-13 (일) - 고객그룹별 차등 가격 시스템 구축 - -### 주요 작업 -- **고객 그룹 관리 시스템 구축**: 고객별 차등 가격 관리를 위한 client_groups 테이블 및 모델 구현 -- **가격 이력 시스템 확장**: price_histories 테이블에 고객그룹별 가격 지원 추가 -- **PricingService 신규 구축**: 우선순위 기반 가격 조회 로직 구현 -- **EstimateService 통합**: 견적 생성 시 자동 가격 계산 기능 추가 - -### 추가된 파일: -- `database/migrations/2025_10_13_213549_create_client_groups_table.php` - 고객 그룹 테이블 생성 -- `database/migrations/2025_10_13_213556_add_client_group_id_to_clients_table.php` - clients 테이블에 그룹 ID 추가 -- `database/migrations/2025_10_13_213602_add_client_group_id_to_price_histories_table.php` - price_histories 테이블에 그룹 ID 추가 -- `app/Models/Orders/ClientGroup.php` - 고객 그룹 모델 -- `app/Services/Pricing/PricingService.php` - 가격 조회/관리 서비스 - -### 수정된 파일: -- `app/Models/Orders/Client.php` - ClientGroup 관계 추가 -- `app/Models/Products/PriceHistory.php` - ClientGroup 관계 추가, 다양한 스코프 메서드 추가 -- `app/Services/Estimate/EstimateService.php` - PricingService 의존성 주입 및 가격 계산 로직 통합 -- `lang/ko/error.php` - price_not_found 에러 메시지 추가 - -### 작업 내용: - -#### 1. 데이터베이스 스키마 설계 - -**client_groups 테이블:** -```sql -- id, tenant_id -- group_code (그룹 코드) -- group_name (그룹명) -- price_rate (가격 배율: 1.0 = 기준가, 0.9 = 90%, 1.1 = 110%) -- is_active (활성 여부) -- created_by, updated_by, deleted_by (감사 컬럼) -- created_at, updated_at, deleted_at -- UNIQUE(tenant_id, group_code) -``` - -**clients 테이블 확장:** -- `client_group_id` 컬럼 추가 (NULL 허용 = 기본 그룹) - -**price_histories 테이블 확장:** -- `client_group_id` 컬럼 추가 (NULL = 기본 가격, 값 있으면 그룹별 차등 가격) -- 인덱스 재구성: (tenant_id, item_type_code, item_id, client_group_id, started_at) - -#### 2. 모델 관계 설정 - -**ClientGroup 모델:** -- `clients()` → hasMany 관계 -- `scopeActive()` → 활성 그룹만 조회 -- `scopeCode()` → 코드로 검색 - -**Client 모델:** -- `clientGroup()` → belongsTo 관계 - -**PriceHistory 모델:** -- `clientGroup()` → belongsTo 관계 -- `item()` → Polymorphic 관계 (Product/Material) -- 다양한 스코프 메서드: - - `scopeForItem()` → 특정 항목 필터링 - - `scopeForClientGroup()` → 고객 그룹 필터링 - - `scopeValidAt()` → 기준일 기준 유효한 가격 - - `scopeSalePrice()` → 매출단가만 - - `scopePurchasePrice()` → 매입단가만 - -#### 3. PricingService 핵심 로직 - -**가격 조회 우선순위:** -```php -1순위: 고객 그룹별 매출단가 (client_group_id 있음) -2순위: 기본 매출단가 (client_group_id = NULL) -3순위: NULL (경고 발생) - -// ❌ 제거: 매입단가는 견적에서 사용하지 않음 (순수 참고용) -``` - -**주요 메서드:** -- `getItemPrice()` → 단일 항목 가격 조회 -- `getBulkItemPrices()` → 여러 항목 일괄 조회 -- `upsertPrice()` → 가격 등록/수정 -- `listPrices()` → 가격 이력 조회 (페이지네이션) -- `deletePrice()` → 가격 삭제 (Soft Delete) - -#### 4. EstimateService 통합 - -**견적 생성 프로세스:** -```php -1. BOM 계산 (수량만) -2. 각 BOM 항목의 가격 조회 (PricingService) -3. unit_price × quantity = total_price 계산 -4. 전체 항목의 total_price 합산 = total_amount -5. 가격 없는 항목 경고 로그 기록 -``` - -**수정된 메서드:** -- `createEstimate()` → client_id 전달, total_amount 재계산 -- `updateEstimate()` → 파라미터 변경 시 가격 재계산 -- `createEstimateItems()` → 가격 조회 로직 추가, float 반환 - -#### 5. 에러 처리 및 로깅 - -**가격 없는 항목 처리:** -- 경고 메시지 반환: `__('error.price_not_found', [...])` -- Laravel Log에 경고 기록 -- 견적 생성은 계속 진행 (unit_price = 0) -- 프론트엔드에서 경고 표시 가능 - -### 비즈니스 규칙 정리: - -#### 매입단가 vs 매출단가 -- **매입단가 (PURCHASE)**: 순수 참고용, 견적 계산에 미사용 -- **매출단가 (SALE)**: 실제 견적 계산에 사용 -- **STANDARD 가격**: 경동 비즈니스에서는 불필요 (사용하지 않음) - -#### 고객 그룹별 차등 가격 -``` -예시 데이터: -- 기본 가격: 100,000원 (client_group_id = NULL) -- A그룹 가격: 90,000원 (client_group_id = 1, price_rate = 0.9) -- B그룹 가격: 110,000원 (client_group_id = 2, price_rate = 1.1) - -조회 로직: -- A그룹 고객 → 90,000원 (1순위) -- B그룹 고객 → 110,000원 (1순위) -- 일반 고객 → 100,000원 (2순위) -- 가격 없음 → NULL + 경고 (3순위) -``` - -### 마이그레이션 실행 결과: -```bash -✅ 2025_10_13_213549_create_client_groups_table (46.85ms) -✅ 2025_10_13_213556_add_client_group_id_to_clients_table (38.75ms) -✅ 2025_10_13_213602_add_client_group_id_to_price_histories_table (38.46ms) -``` - -### 코드 품질: -- Laravel Pint 포맷팅 완료 (5 files, 4 style issues fixed) -- SAM API Development Rules 준수 -- Service-First 아키텍처 유지 -- BelongsToTenant 멀티테넌트 스코프 적용 -- SoftDeletes 적용 -- 감사 컬럼 (created_by, updated_by, deleted_by) 포함 - ### 예상 효과: -1. **유연한 가격 관리**: 고객 그룹별 차등 가격 설정 가능 -2. **자동 가격 계산**: 견적 생성 시 수동 입력 불필요 -3. **이력 관리**: 기간별 가격 변동 이력 추적 -4. **확장성**: 향후 복잡한 가격 정책 적용 가능 -5. **투명성**: 가격 출처 추적 가능 (price_history_id, client_group_id) -### 향후 작업: -- [x] PricingService 구현 -- [x] EstimateService 통합 -- [ ] 가격 관리 API 엔드포인트 추가 (CRUD) -- [ ] Swagger 문서 작성 (가격 관련 API) -- [ ] 고객 그룹 관리 API 엔드포인트 추가 -- [ ] Frontend 가격 관리 화면 구현 -- [ ] 가격 일괄 업로드 기능 추가 +1. **원스톱 가입**: 1회 요청으로 모든 설정 완료 +2. **즉시 사용 가능**: system_manager 권한으로 모든 메뉴 접근 +3. **멀티테넌트 격리**: 각 테넌트별 독립적인 메뉴 구조 +4. **유연한 검증**: trial 단계에서는 사업자번호 중복 허용 + +### 다음 작업: + +- [ ] Swagger 재생성 (`php artisan l5-swagger:generate`) +- [ ] Postman/Swagger UI로 API 테스트 +- [ ] Frontend 회원가입 화면 구현 +- [ ] 이메일 인증 기능 추가 (선택) +- [ ] API 문서 최종 검토 ### Git 커밋 준비: -- 다음 커밋 예정: `feat: 고객그룹별 차등 가격 시스템 구축` +- 다음 커밋 예정: `feat: 회원가입 + 테넌트 생성 통합 API 추가 (/api/v1/register)` --- + +(이전 작업 내역은 그대로 유지...) \ No newline at end of file diff --git a/app/Http/Controllers/Api/V1/RegisterController.php b/app/Http/Controllers/Api/V1/RegisterController.php new file mode 100644 index 0000000..73f48d3 --- /dev/null +++ b/app/Http/Controllers/Api/V1/RegisterController.php @@ -0,0 +1,23 @@ +validated()); + }, __('message.registered')); + } +} diff --git a/app/Http/Requests/RegisterRequest.php b/app/Http/Requests/RegisterRequest.php new file mode 100644 index 0000000..19c6fe0 --- /dev/null +++ b/app/Http/Requests/RegisterRequest.php @@ -0,0 +1,97 @@ +|string> + */ + public function rules(): array + { + return [ + // User fields + 'user_id' => [ + 'required', + 'string', + 'max:255', + 'regex:/^[a-zA-Z0-9_-]+$/', + 'unique:users,user_id', + ], + 'name' => 'required|string|max:255', + 'email' => [ + 'required', + 'string', + 'email', + 'max:255', + 'unique:users,email', + ], + 'phone' => [ + 'nullable', + 'string', + 'max:20', + 'regex:/^[0-9-]+$/', + ], + 'password' => 'required|string|min:8|confirmed', + 'position' => 'nullable|string|max:100', + + // Tenant fields + 'company_name' => 'required|string|max:255', + 'business_num' => [ + 'required', + 'string', + 'regex:/^\d{3}-\d{2}-\d{5}$/', + Rule::unique('tenants', 'business_num')->where(function ($query) { + return $query->where('tenant_st_code', 'active'); + }), + ], + 'company_scale' => 'nullable|string|max:50', + 'industry' => 'nullable|string|max:100', + ]; + } + + /** + * Get custom attributes for validator errors. + */ + public function attributes(): array + { + return [ + 'user_id' => __('validation.attributes.user_id'), + 'name' => __('validation.attributes.name'), + 'email' => __('validation.attributes.email'), + 'phone' => __('validation.attributes.phone'), + 'password' => __('validation.attributes.password'), + 'position' => __('validation.attributes.position'), + 'company_name' => __('validation.attributes.company_name'), + 'business_num' => __('validation.attributes.business_num'), + 'company_scale' => __('validation.attributes.company_scale'), + 'industry' => __('validation.attributes.industry'), + ]; + } + + /** + * Get custom messages for validator errors. + */ + public function messages(): array + { + return [ + 'business_num.regex' => __('error.business_num_format'), + 'business_num.unique' => __('error.business_num_duplicate_active'), + 'user_id.regex' => __('error.user_id_format'), + 'phone.regex' => __('error.phone_format'), + ]; + } +} diff --git a/app/Services/RegisterService.php b/app/Services/RegisterService.php new file mode 100644 index 0000000..b72c4ed --- /dev/null +++ b/app/Services/RegisterService.php @@ -0,0 +1,130 @@ + string, + * 'name' => string, + * 'email' => string, + * 'phone' => string, + * 'password' => string, + * 'position' => string (optional), + * 'company_name' => string, + * 'business_num' => string (optional), + * 'company_scale' => string (optional), + * 'industry' => string (optional), + * ] + * @return array ['user' => array, 'tenant' => array] + */ + public static function register(array $params): array + { + return DB::transaction(function () use ($params) { + // 1. Create Tenant with trial status and options + $tenant = Tenant::create([ + 'company_name' => $params['company_name'], + 'business_num' => $params['business_num'] ?? null, + 'tenant_st_code' => 'trial', // 트라이얼 상태 + 'options' => [ + 'company_scale' => $params['company_scale'] ?? null, + 'industry' => $params['industry'] ?? null, + ], + ]); + + // 2. Bootstrap tenant (STANDARD recipe: CapabilityProfiles, Categories, Menus, Settings) + // This will create all necessary menus via MenusStep + app(RecipeRegistry::class)->bootstrap($tenant->id, 'STANDARD'); + + // 3. Create User with hashed password and options + $user = User::create([ + 'user_id' => $params['user_id'], + 'name' => $params['name'], + 'email' => $params['email'], + 'phone' => $params['phone'] ?? null, + 'password' => Hash::make($params['password']), + 'options' => [ + 'position' => $params['position'] ?? null, + ], + ]); + + // 4. Create TenantUserProfile (tenant-user mapping) + TenantUserProfile::create([ + 'user_id' => $user->id, + 'tenant_id' => $tenant->id, + 'is_default' => 1, // 기본 테넌트로 설정 + 'is_active' => 1, // 활성화 + ]); + + // 5. Set tenant context for permissions + // This is critical for Spatie permissions to work correctly + app()->bind('tenant_id', fn () => $tenant->id); + app(PermissionRegistrar::class)->setPermissionsTeamId($tenant->id); + + // 6. Create 'system_manager' role + $role = Role::create([ + 'tenant_id' => $tenant->id, + 'guard_name' => 'api', + 'name' => 'system_manager', + 'description' => '시스템 관리자', + ]); + + // 7. Get all tenant menus (after bootstrap) + $menuIds = Menu::where('tenant_id', $tenant->id)->pluck('id'); + + // 8. Create permissions for each menu and assign to role + $permissions = []; + foreach ($menuIds as $menuId) { + $permName = "menu.{$menuId}"; + + // Use firstOrCreate to avoid duplicate permission errors + $perm = Permission::firstOrCreate([ + 'tenant_id' => $tenant->id, + 'guard_name' => 'api', + 'name' => $permName, + ]); + + $permissions[] = $perm; + } + + // 9. Assign all menu permissions to system_manager role + $role->syncPermissions($permissions); + + // 10. Assign system_manager role to user + $user->assignRole($role); + + // 11. Return user and tenant data + return [ + 'user' => [ + 'id' => $user->id, + 'user_id' => $user->user_id, + 'name' => $user->name, + 'email' => $user->email, + 'phone' => $user->phone, + 'options' => $user->options, + ], + 'tenant' => [ + 'id' => $tenant->id, + 'company_name' => $tenant->company_name, + 'business_num' => $tenant->business_num, + 'tenant_st_code' => $tenant->tenant_st_code, + 'options' => $tenant->options, + ], + ]; + }); + } +} diff --git a/app/Services/TenantBootstrap/Steps/MenusStep.php b/app/Services/TenantBootstrap/Steps/MenusStep.php index c196b4c..3c3e851 100644 --- a/app/Services/TenantBootstrap/Steps/MenusStep.php +++ b/app/Services/TenantBootstrap/Steps/MenusStep.php @@ -7,22 +7,62 @@ class MenusStep implements TenantBootstrapStep { - public function key(): string { return 'menus_seed'; } + public function key(): string + { + return 'menus_seed'; + } public function run(int $tenantId): void { - // 예시: menus 테이블이 있다고 가정한 최소 스텁 (스키마에 맞춰 수정) - if (!DB::getSchemaBuilder()->hasTable('menus')) return; + if (! DB::getSchemaBuilder()->hasTable('menus')) { + return; + } - $exists = DB::table('menus')->where(['tenant_id'=>$tenantId, 'code'=>'ROOT'])->exists(); - if (!$exists) { - DB::table('menus')->insert([ - 'tenant_id'=>$tenantId, - 'name'=>'메인', - 'parent_id'=>null, 'sort_order'=>0, - 'is_active'=>1, 'created_at'=>now(), 'updated_at'=>now(), + // Check if tenant already has menus + $exists = DB::table('menus')->where('tenant_id', $tenantId)->exists(); + if ($exists) { + return; + } + + // Get all global menus ordered by parent_id, sort_order + // Order by: root menus first (parent_id IS NULL), then by parent_id ASC, then sort_order ASC + $globalMenus = DB::table('menus') + ->whereNull('tenant_id') + ->orderByRaw('parent_id IS NULL DESC, parent_id ASC, sort_order ASC') + ->get(); + + if ($globalMenus->isEmpty()) { + return; + } + + $parentIdMap = []; // old_id => new_id mapping + + foreach ($globalMenus as $menu) { + // Map parent_id: if parent exists in map, use new parent_id, else null + $newParentId = null; + if ($menu->parent_id !== null && isset($parentIdMap[$menu->parent_id])) { + $newParentId = $parentIdMap[$menu->parent_id]; + } + + // Insert new menu for tenant + $newId = DB::table('menus')->insertGetId([ + 'tenant_id' => $tenantId, + 'parent_id' => $newParentId, + 'name' => $menu->name, + 'code' => $menu->code ?? null, + 'icon' => $menu->icon ?? null, + 'url' => $menu->url ?? null, + 'route_name' => $menu->route_name ?? null, + 'sort_order' => $menu->sort_order ?? 0, + 'is_active' => $menu->is_active ?? 1, + 'depth' => $menu->depth ?? 0, + 'description' => $menu->description ?? null, + 'created_at' => now(), + 'updated_at' => now(), ]); - // 필요 시 하위 기본 메뉴들 추가… + + // Store mapping for children menus + $parentIdMap[$menu->id] = $newId; } } } diff --git a/app/Swagger/v1/RegisterApi.php b/app/Swagger/v1/RegisterApi.php new file mode 100644 index 0000000..807d880 --- /dev/null +++ b/app/Swagger/v1/RegisterApi.php @@ -0,0 +1,119 @@ + '존재하지 않는 URI 또는 데이터입니다.', // 404 일반 - 'tenant_id' => '활성 테넌트가 없습니다.', // 400 (Service::tenantId() 미설정) + 'not_found' => '존재하지 않는 URI 또는 데이터입니다.', // 404 일반 + 'tenant_id' => '활성 테넌트가 없습니다.', // 400 (Service::tenantId() 미설정) 'unauthenticated' => '인증에 실패했습니다.', // 401 - 'forbidden' => '요청에 대한 권한이 없습니다.', // 403 + 'forbidden' => '요청에 대한 권한이 없습니다.', // 403 'bad_request' => '잘못된 요청입니다.', // 400 (검증 외 일반 케이스) // 검증/파라미터 'validation_failed' => '요청 데이터 검증에 실패했습니다.', // 422 'missing_parameter' => '필수 파라미터가 누락되었습니다.', // 400 + 'business_num_format' => '사업자등록번호 형식이 올바르지 않습니다 (000-00-00000)', + 'business_num_duplicate_active' => '이미 등록된 사업자등록번호입니다 (정식 서비스 업체)', + 'user_id_format' => '아이디는 영문, 숫자, _, - 만 사용할 수 있습니다', + 'phone_format' => '전화번호 형식이 올바르지 않습니다', // 리소스별 (선택: :resource 자리표시자 사용) 'not_found_resource' => ':resource 정보를 찾을 수 없습니다.', // 예: __('error.not_found_resource', ['resource' => '제품']) // 비즈니스 규칙 - 'duplicate' => '중복된 데이터가 존재합니다.', - 'conflict' => '요청이 현재 상태와 충돌합니다.', // 409 + 'duplicate' => '중복된 데이터가 존재합니다.', + 'conflict' => '요청이 현재 상태와 충돌합니다.', // 409 'state_invalid' => '현재 상태에서는 처리할 수 없습니다.', // 409/400 // 서버 오류 diff --git a/lang/ko/message.php b/lang/ko/message.php index 858e71e..356f0e1 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -1,4 +1,5 @@ '조회 성공', - 'created' => '등록 성공', - 'updated' => '수정 성공', - 'deleted' => '삭제 성공', - 'restored' => '복구 성공', - 'toggled' => '상태 변경 성공', + 'fetched' => '조회 성공', + 'created' => '등록 성공', + 'updated' => '수정 성공', + 'deleted' => '삭제 성공', + 'restored' => '복구 성공', + 'toggled' => '상태 변경 성공', 'bulk_upsert' => '대량 저장 성공', - 'reordered' => '정렬 변경 성공', - 'no_changes' => '변경 사항이 없습니다.', + 'reordered' => '정렬 변경 성공', + 'no_changes' => '변경 사항이 없습니다.', // 인증/세션 - 'login_success' => '로그인 성공', + 'login_success' => '로그인 성공', 'logout_success' => '로그아웃 되었습니다.', 'signup_success' => '회원가입이 완료되었습니다.', + 'registered' => '회원가입이 완료되었습니다.', // 테넌트/컨텍스트 'tenant_switched' => '활성 테넌트가 전환되었습니다.', // 리소스별 세부 (필요 시) 'product' => [ - 'created' => '제품이 등록되었습니다.', - 'updated' => '제품이 수정되었습니다.', - 'deleted' => '제품이 삭제되었습니다.', - 'toggled' => '제품 상태가 변경되었습니다.', + 'created' => '제품이 등록되었습니다.', + 'updated' => '제품이 수정되었습니다.', + 'deleted' => '제품이 삭제되었습니다.', + 'toggled' => '제품 상태가 변경되었습니다.', ], 'bom' => [ - 'fetched' => 'BOM 항목을 조회했습니다.', + 'fetched' => 'BOM 항목을 조회했습니다.', 'bulk_upsert' => 'BOM 항목이 저장되었습니다.', - 'reordered' => 'BOM 정렬이 변경되었습니다.', - 'fetch' => 'BOM 항목 조회', - 'create' => 'BOM 항목 등록', - 'update' => 'BOM 항목 수정', - 'delete' => 'BOM 항목 삭제', - 'restore' => 'BOM 항목 복구', + 'reordered' => 'BOM 정렬이 변경되었습니다.', + 'fetch' => 'BOM 항목 조회', + 'create' => 'BOM 항목 등록', + 'update' => 'BOM 항목 수정', + 'delete' => 'BOM 항목 삭제', + 'restore' => 'BOM 항목 복구', ], 'category' => [ - 'fields_saved' => '카테고리 필드가 저장되었습니다.', + 'fields_saved' => '카테고리 필드가 저장되었습니다.', 'template_saved' => '카테고리 템플릿이 저장되었습니다.', 'template_applied' => '카테고리 템플릿이 적용되었습니다.', ], 'design' => [ 'template_cloned' => 'BOM 템플릿이 복제되었습니다.', - 'template_diff' => 'BOM 템플릿 차이를 계산했습니다.', + 'template_diff' => 'BOM 템플릿 차이를 계산했습니다.', ], 'model_set' => [ - 'cloned' => '모델셋이 복제되었습니다.', - 'calculated' => 'BOM 계산이 완료되었습니다.', + 'cloned' => '모델셋이 복제되었습니다.', + 'calculated' => 'BOM 계산이 완료되었습니다.', ], 'estimate' => [ - 'cloned' => '견적이 복제되었습니다.', + 'cloned' => '견적이 복제되었습니다.', 'status_changed' => '견적 상태가 변경되었습니다.', ], @@ -71,25 +73,25 @@ // 설정 관리 (Settings & Configuration 통합) 'settings' => [ - 'fields_updated' => '필드 설정이 업데이트되었습니다.', - 'fields_bulk_saved' => '필드 설정 일괄 저장이 완료되었습니다.', - 'options_saved' => '옵션 그룹이 저장되었습니다.', - 'options_reordered' => '옵션 값 정렬이 변경되었습니다.', - 'common_code_saved' => '공통 코드가 저장되었습니다.', + 'fields_updated' => '필드 설정이 업데이트되었습니다.', + 'fields_bulk_saved' => '필드 설정 일괄 저장이 완료되었습니다.', + 'options_saved' => '옵션 그룹이 저장되었습니다.', + 'options_reordered' => '옵션 값 정렬이 변경되었습니다.', + 'common_code_saved' => '공통 코드가 저장되었습니다.', ], // 자재 관리 (Products & Materials 통합) 'materials' => [ - 'created' => '자재가 등록되었습니다.', - 'updated' => '자재가 수정되었습니다.', - 'deleted' => '자재가 삭제되었습니다.', - 'fetched' => '자재 목록을 조회했습니다.', + 'created' => '자재가 등록되었습니다.', + 'updated' => '자재가 수정되었습니다.', + 'deleted' => '자재가 삭제되었습니다.', + 'fetched' => '자재 목록을 조회했습니다.', ], // 파일 관리 'file' => [ 'uploaded' => '파일이 업로드되었습니다.', - 'deleted' => '파일이 삭제되었습니다.', - 'fetched' => '파일 목록을 조회했습니다.', + 'deleted' => '파일이 삭제되었습니다.', + 'fetched' => '파일 목록을 조회했습니다.', ], ]; diff --git a/routes/api.php b/routes/api.php index 8d305c7..4b26bef 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,62 +1,58 @@ group(function () { - # API KEY 인증 + // API KEY 인증 Route::middleware('auth.apikey')->get('/debug-apikey', [ApiController::class, 'debugApikey']); - # SAM API + // SAM API Route::middleware('auth.apikey')->group(function () { - # Auth API + // Auth API Route::post('login', [ApiController::class, 'login'])->name('v1.users.login'); Route::middleware('auth:sanctum')->post('logout', [ApiController::class, 'logout'])->name('v1.users.logout'); Route::post('signup', [ApiController::class, 'signup'])->name('v1.users.signup'); - - - + Route::post('register', [RegisterController::class, 'register'])->name('v1.register'); // Tenant Admin API Route::prefix('admin')->group(function () { @@ -83,7 +79,6 @@ Route::post('users/{id}/reset-password', [AdminController::class, 'reset'])->name('v1.admin.users.password.reset'); // 테넌트 사용자 비밀번호 초기화 }); - // Member API Route::prefix('users')->group(function () { Route::get('index', [UserController::class, 'index'])->name('v1.users.index'); // 회원 목록 조회 @@ -97,7 +92,6 @@ Route::patch('me/tenants/switch', [UserController::class, 'switchTenant'])->name('v1.users.me.tenants.switch'); // 활성 테넌트 전환 }); - // Tenant API Route::prefix('tenants')->group(function () { Route::get('list', [TenantController::class, 'index'])->name('v1.tenant.index'); // 테넌트 목록 조회 @@ -116,7 +110,6 @@ Route::get('info', [FileController::class, 'findFile'])->name('v1.file.info'); // 파일 정보 조회 }); - // Menu API Route::middleware(['perm.map', 'permission'])->prefix('menus')->group(function () { Route::get('/', [MenuController::class, 'index'])->name('v1.menus.index'); @@ -128,17 +121,15 @@ Route::post('/{id}/toggle', [MenuController::class, 'toggle'])->name('v1.menus.toggle'); }); - // Role API Route::prefix('roles')->group(function () { Route::get('/', [RoleController::class, 'index'])->name('v1.roles.index'); // view Route::post('/', [RoleController::class, 'store'])->name('v1.roles.store'); // create Route::get('/{id}', [RoleController::class, 'show'])->name('v1.roles.show'); // view Route::patch('/{id}', [RoleController::class, 'update'])->name('v1.roles.update'); // update - Route::delete('/{id}', [RoleController::class, 'destroy'])->name('v1.roles.destroy');// delete + Route::delete('/{id}', [RoleController::class, 'destroy'])->name('v1.roles.destroy'); // delete }); - // Role Permission API Route::prefix('roles/{id}/permissions')->group(function () { Route::get('/', [RolePermissionController::class, 'index'])->name('v1.roles.perms.index'); // list @@ -147,7 +138,6 @@ Route::put('/sync', [RolePermissionController::class, 'sync'])->name('v1.roles.perms.sync'); // sync }); - // User Role API Route::prefix('users/{id}/roles')->group(function () { Route::get('/', [UserRoleController::class, 'index'])->name('v1.users.roles.index'); // list @@ -156,7 +146,6 @@ Route::put('/sync', [UserRoleController::class, 'sync'])->name('v1.users.roles.sync'); // sync }); - // Department API Route::prefix('departments')->group(function () { Route::get('', [DepartmentController::class, 'index'])->name('v1.departments.index'); // 목록 @@ -177,7 +166,6 @@ Route::delete('/{id}/permissions/{permission}', [DepartmentController::class, 'revokePermissions'])->name('v1.departments.permissions.revoke'); // 권한 제거(해당 메뉴 범위까지) }); - // Permission API Route::prefix('permissions')->group(function () { Route::get('departments/{dept_id}/menu-matrix', [PermissionController::class, 'deptMenuMatrix'])->name('v1.permissions.deptMenuMatrix'); // 부서별 권한 메트릭스 @@ -185,7 +173,6 @@ Route::get('users/{user_id}/menu-matrix', [PermissionController::class, 'userMenuMatrix'])->name('v1.permissions.userMenuMatrix'); // 부서별 권한 메트릭스 }); - // Settings & Configuration (설정 및 환경설정 통합 관리) Route::prefix('settings')->group(function () { @@ -243,97 +230,97 @@ // === Category Fields === // 목록/생성 (카테고리 기준) - Route::get ('/{id}/fields', [CategoryFieldController::class, 'index'])->name('v1.categories.fields.index'); // ?page&size&sort&order - Route::post ('/{id}/fields', [CategoryFieldController::class, 'store'])->name('v1.categories.fields.store'); + Route::get('/{id}/fields', [CategoryFieldController::class, 'index'])->name('v1.categories.fields.index'); // ?page&size&sort&order + Route::post('/{id}/fields', [CategoryFieldController::class, 'store'])->name('v1.categories.fields.store'); // 단건 - Route::get ('/fields/{field}', [CategoryFieldController::class, 'show'])->name('v1.categories.fields.show'); - Route::patch ('/fields/{field}', [CategoryFieldController::class, 'update'])->name('v1.categories.fields.update'); - Route::delete('/fields/{field}', [CategoryFieldController::class, 'destroy'])->name('v1.categories.fields.destroy'); + Route::get('/fields/{field}', [CategoryFieldController::class, 'show'])->name('v1.categories.fields.show'); + Route::patch('/fields/{field}', [CategoryFieldController::class, 'update'])->name('v1.categories.fields.update'); + Route::delete('/fields/{field}', [CategoryFieldController::class, 'destroy'])->name('v1.categories.fields.destroy'); // 일괄 정렬/업서트 - Route::post ('/{id}/fields/reorder', [CategoryFieldController::class, 'reorder'])->name('v1.categories.fields.reorder'); // [{id,sort_order}] - Route::put ('/{id}/fields/bulk-upsert', [CategoryFieldController::class, 'bulkUpsert'])->name('v1.categories.fields.bulk'); // [{id?,field_key,...}] + Route::post('/{id}/fields/reorder', [CategoryFieldController::class, 'reorder'])->name('v1.categories.fields.reorder'); // [{id,sort_order}] + Route::put('/{id}/fields/bulk-upsert', [CategoryFieldController::class, 'bulkUpsert'])->name('v1.categories.fields.bulk'); // [{id?,field_key,...}] // === Category Templates === // 버전 목록/생성 (카테고리 기준) - Route::get ('/{id}/templates', [CategoryTemplateController::class, 'index'])->name('v1.categories.templates.index'); // ?page&size - Route::post ('/{id}/templates', [CategoryTemplateController::class, 'store'])->name('v1.categories.templates.store'); // 새 버전 등록 + Route::get('/{id}/templates', [CategoryTemplateController::class, 'index'])->name('v1.categories.templates.index'); // ?page&size + Route::post('/{id}/templates', [CategoryTemplateController::class, 'store'])->name('v1.categories.templates.store'); // 새 버전 등록 // 단건 - Route::get ('/templates/{tpl}', [CategoryTemplateController::class, 'show'])->name('v1.categories.templates.show'); - Route::patch ('/templates/{tpl}', [CategoryTemplateController::class, 'update'])->name('v1.categories.templates.update'); // remarks 등 메타 수정 - Route::delete('/templates/{tpl}', [CategoryTemplateController::class, 'destroy'])->name('v1.categories.templates.destroy'); + Route::get('/templates/{tpl}', [CategoryTemplateController::class, 'show'])->name('v1.categories.templates.show'); + Route::patch('/templates/{tpl}', [CategoryTemplateController::class, 'update'])->name('v1.categories.templates.update'); // remarks 등 메타 수정 + Route::delete('/templates/{tpl}', [CategoryTemplateController::class, 'destroy'])->name('v1.categories.templates.destroy'); // 운영 편의 - Route::post ('/{id}/templates/{tpl}/apply', [CategoryTemplateController::class, 'apply'])->name('v1.categories.templates.apply'); // 해당 버전 활성화 - Route::get ('/{id}/templates/{tpl}/preview', [CategoryTemplateController::class, 'preview'])->name('v1.categories.templates.preview');// 렌더용 스냅샷 + Route::post('/{id}/templates/{tpl}/apply', [CategoryTemplateController::class, 'apply'])->name('v1.categories.templates.apply'); // 해당 버전 활성화 + Route::get('/{id}/templates/{tpl}/preview', [CategoryTemplateController::class, 'preview'])->name('v1.categories.templates.preview'); // 렌더용 스냅샷 // (선택) 버전 간 diff - Route::get ('/{id}/templates/diff', [CategoryTemplateController::class, 'diff'])->name('v1.categories.templates.diff'); // ?a=ver&b=ver + Route::get('/{id}/templates/diff', [CategoryTemplateController::class, 'diff'])->name('v1.categories.templates.diff'); // ?a=ver&b=ver // === Category Logs === - Route::get ('/{id}/logs', [CategoryLogController::class, 'index'])->name('v1.categories.logs.index'); // ?action=&from=&to=&page&size - Route::get ('/logs/{log}', [CategoryLogController::class, 'show'])->name('v1.categories.logs.show'); + Route::get('/{id}/logs', [CategoryLogController::class, 'index'])->name('v1.categories.logs.index'); // ?action=&from=&to=&page&size + Route::get('/logs/{log}', [CategoryLogController::class, 'show'])->name('v1.categories.logs.show'); // (선택) 특정 변경 시점으로 카테고리 복구(템플릿/필드와 별개) // Route::post('{id}/logs/{log}/restore', [CategoryLogController::class, 'restore'])->name('v1.categories.logs.restore'); }); // Classifications API Route::prefix('classifications')->group(function () { - Route::get ('', [ClassificationController::class, 'index'])->name('v1.classifications.index'); // 목록 - Route::post ('', [ClassificationController::class, 'store'])->name('v1.classifications.store'); // 생성 - Route::get ('/{id}', [ClassificationController::class, 'show'])->whereNumber('id')->name('v1.classifications.show'); // 단건 - Route::patch ('/{id}', [ClassificationController::class, 'update'])->whereNumber('id')->name('v1.classifications.update'); // 수정 - Route::delete('/{id}', [ClassificationController::class, 'destroy'])->whereNumber('id')->name('v1.classifications.destroy'); // 삭제 + Route::get('', [ClassificationController::class, 'index'])->name('v1.classifications.index'); // 목록 + Route::post('', [ClassificationController::class, 'store'])->name('v1.classifications.store'); // 생성 + Route::get('/{id}', [ClassificationController::class, 'show'])->whereNumber('id')->name('v1.classifications.show'); // 단건 + Route::patch('/{id}', [ClassificationController::class, 'update'])->whereNumber('id')->name('v1.classifications.update'); // 수정 + Route::delete('/{id}', [ClassificationController::class, 'destroy'])->whereNumber('id')->name('v1.classifications.destroy'); // 삭제 }); // Clients (거래처 관리) Route::prefix('clients')->group(function () { - Route::get ('', [ClientController::class, 'index'])->name('v1.clients.index'); // 목록 - Route::post ('', [ClientController::class, 'store'])->name('v1.clients.store'); // 생성 - Route::get ('/{id}', [ClientController::class, 'show'])->whereNumber('id')->name('v1.clients.show'); // 단건 - Route::put ('/{id}', [ClientController::class, 'update'])->whereNumber('id')->name('v1.clients.update'); // 수정 - Route::delete('/{id}', [ClientController::class, 'destroy'])->whereNumber('id')->name('v1.clients.destroy'); // 삭제 - Route::patch ('/{id}/toggle', [ClientController::class, 'toggle'])->whereNumber('id')->name('v1.clients.toggle'); // 활성/비활성 + Route::get('', [ClientController::class, 'index'])->name('v1.clients.index'); // 목록 + Route::post('', [ClientController::class, 'store'])->name('v1.clients.store'); // 생성 + Route::get('/{id}', [ClientController::class, 'show'])->whereNumber('id')->name('v1.clients.show'); // 단건 + Route::put('/{id}', [ClientController::class, 'update'])->whereNumber('id')->name('v1.clients.update'); // 수정 + Route::delete('/{id}', [ClientController::class, 'destroy'])->whereNumber('id')->name('v1.clients.destroy'); // 삭제 + Route::patch('/{id}/toggle', [ClientController::class, 'toggle'])->whereNumber('id')->name('v1.clients.toggle'); // 활성/비활성 }); // Client Groups (고객 그룹 관리) Route::prefix('client-groups')->group(function () { - Route::get ('', [ClientGroupController::class, 'index'])->name('v1.client-groups.index'); // 목록 - Route::post ('', [ClientGroupController::class, 'store'])->name('v1.client-groups.store'); // 생성 - Route::get ('/{id}', [ClientGroupController::class, 'show'])->whereNumber('id')->name('v1.client-groups.show'); // 단건 - Route::put ('/{id}', [ClientGroupController::class, 'update'])->whereNumber('id')->name('v1.client-groups.update'); // 수정 - Route::delete('/{id}', [ClientGroupController::class, 'destroy'])->whereNumber('id')->name('v1.client-groups.destroy'); // 삭제 - Route::patch ('/{id}/toggle', [ClientGroupController::class, 'toggle'])->whereNumber('id')->name('v1.client-groups.toggle'); // 활성/비활성 + Route::get('', [ClientGroupController::class, 'index'])->name('v1.client-groups.index'); // 목록 + Route::post('', [ClientGroupController::class, 'store'])->name('v1.client-groups.store'); // 생성 + Route::get('/{id}', [ClientGroupController::class, 'show'])->whereNumber('id')->name('v1.client-groups.show'); // 단건 + Route::put('/{id}', [ClientGroupController::class, 'update'])->whereNumber('id')->name('v1.client-groups.update'); // 수정 + Route::delete('/{id}', [ClientGroupController::class, 'destroy'])->whereNumber('id')->name('v1.client-groups.destroy'); // 삭제 + Route::patch('/{id}/toggle', [ClientGroupController::class, 'toggle'])->whereNumber('id')->name('v1.client-groups.toggle'); // 활성/비활성 }); // Pricing (가격 이력 관리) Route::prefix('pricing')->group(function () { - Route::get ('', [PricingController::class, 'index'])->name('v1.pricing.index'); // 목록 - Route::get ('/show', [PricingController::class, 'show'])->name('v1.pricing.show'); // 단일 항목 가격 조회 - Route::post ('/bulk', [PricingController::class, 'bulk'])->name('v1.pricing.bulk'); // 여러 항목 일괄 조회 - Route::post ('/upsert', [PricingController::class, 'upsert'])->name('v1.pricing.upsert'); // 가격 등록/수정 - Route::delete('/{id}', [PricingController::class, 'destroy'])->whereNumber('id')->name('v1.pricing.destroy'); // 삭제 + Route::get('', [PricingController::class, 'index'])->name('v1.pricing.index'); // 목록 + Route::get('/show', [PricingController::class, 'show'])->name('v1.pricing.show'); // 단일 항목 가격 조회 + Route::post('/bulk', [PricingController::class, 'bulk'])->name('v1.pricing.bulk'); // 여러 항목 일괄 조회 + Route::post('/upsert', [PricingController::class, 'upsert'])->name('v1.pricing.upsert'); // 가격 등록/수정 + Route::delete('/{id}', [PricingController::class, 'destroy'])->whereNumber('id')->name('v1.pricing.destroy'); // 삭제 }); // Products & Materials (제품/자재 통합 관리) - Route::prefix('products')->group(function (){ + Route::prefix('products')->group(function () { // 제품 카테고리 (기존 product/category에서 이동) - Route::get ('/categories', [ProductController::class, 'getCategory'])->name('v1.products.categories'); // 제품 카테고리 + Route::get('/categories', [ProductController::class, 'getCategory'])->name('v1.products.categories'); // 제품 카테고리 // 자재 관리 (기존 독립 materials에서 이동) - ProductController 기본 라우팅보다 앞에 위치 - Route::get ('/materials', [MaterialController::class, 'index'])->name('v1.products.materials.index'); // 자재 목록 - Route::post ('/materials', [MaterialController::class, 'store'])->name('v1.products.materials.store'); // 자재 생성 - Route::get ('/materials/{id}', [MaterialController::class, 'show'])->name('v1.products.materials.show'); // 자재 단건 - Route::patch ('/materials/{id}', [MaterialController::class, 'update'])->name('v1.products.materials.update'); // 자재 수정 + Route::get('/materials', [MaterialController::class, 'index'])->name('v1.products.materials.index'); // 자재 목록 + Route::post('/materials', [MaterialController::class, 'store'])->name('v1.products.materials.store'); // 자재 생성 + Route::get('/materials/{id}', [MaterialController::class, 'show'])->name('v1.products.materials.show'); // 자재 단건 + Route::patch('/materials/{id}', [MaterialController::class, 'update'])->name('v1.products.materials.update'); // 자재 수정 Route::delete('/materials/{id}', [MaterialController::class, 'destroy'])->name('v1.products.materials.destroy'); // 자재 삭제 // (선택) 드롭다운/모달용 간편 검색 & 활성 토글 - Route::get ('/search', [ProductController::class, 'search'])->name('v1.products.search'); - Route::post ('/{id}/toggle', [ProductController::class, 'toggle'])->name('v1.products.toggle'); + Route::get('/search', [ProductController::class, 'search'])->name('v1.products.search'); + Route::post('/{id}/toggle', [ProductController::class, 'toggle'])->name('v1.products.toggle'); - Route::get ('', [ProductController::class, 'index'])->name('v1.products.index'); // 목록/검색(q, category_id, product_type, active, page/size) - Route::post ('', [ProductController::class, 'store'])->name('v1.products.store'); // 생성 - Route::get ('/{id}', [ProductController::class, 'show'])->name('v1.products.show'); // 단건 - Route::patch ('/{id}', [ProductController::class, 'update'])->name('v1.products.update'); // 수정 - Route::delete('/{id}', [ProductController::class, 'destroy'])->name('v1.products.destroy'); // 삭제(soft) + Route::get('', [ProductController::class, 'index'])->name('v1.products.index'); // 목록/검색(q, category_id, product_type, active, page/size) + Route::post('', [ProductController::class, 'store'])->name('v1.products.store'); // 생성 + Route::get('/{id}', [ProductController::class, 'show'])->name('v1.products.show'); // 단건 + Route::patch('/{id}', [ProductController::class, 'update'])->name('v1.products.update'); // 수정 + Route::delete('/{id}', [ProductController::class, 'destroy'])->name('v1.products.destroy'); // 삭제(soft) // BOM 카테고리 Route::get('bom/categories', [ProductBomItemController::class, 'suggestCategories'])->name('v1.products.bom.categories.suggest'); // 전역(테넌트) 추천 @@ -342,83 +329,81 @@ // BOM (product_components: ref_type=PRODUCT|MATERIAL) Route::prefix('products/{id}/bom')->group(function () { - Route::post('/', [ProductBomItemController::class, 'replace'])->name('v1.products.bom.replace'); + Route::post('/', [ProductBomItemController::class, 'replace'])->name('v1.products.bom.replace'); - Route::get ('/items', [ProductBomItemController::class, 'index'])->name('v1.products.bom.items.index'); // 조회(제품+자재 병합) - Route::post ('/items/bulk', [ProductBomItemController::class, 'bulkUpsert'])->name('v1.products.bom.items.bulk'); // 대량 업서트 - Route::patch ('/items/{item}', [ProductBomItemController::class, 'update'])->name('v1.products.bom.items.update'); // 단건 수정 - Route::delete('/items/{item}', [ProductBomItemController::class, 'destroy'])->name('v1.products.bom.items.destroy'); // 단건 삭제 - Route::post ('/items/reorder', [ProductBomItemController::class, 'reorder'])->name('v1.products.bom.items.reorder'); // 정렬 변경 + Route::get('/items', [ProductBomItemController::class, 'index'])->name('v1.products.bom.items.index'); // 조회(제품+자재 병합) + Route::post('/items/bulk', [ProductBomItemController::class, 'bulkUpsert'])->name('v1.products.bom.items.bulk'); // 대량 업서트 + Route::patch('/items/{item}', [ProductBomItemController::class, 'update'])->name('v1.products.bom.items.update'); // 단건 수정 + Route::delete('/items/{item}', [ProductBomItemController::class, 'destroy'])->name('v1.products.bom.items.destroy'); // 단건 삭제 + Route::post('/items/reorder', [ProductBomItemController::class, 'reorder'])->name('v1.products.bom.items.reorder'); // 정렬 변경 // (선택) 합계/검증 - Route::get ('/summary', [ProductBomItemController::class, 'summary'])->name('v1.products.bom.summary'); - Route::get ('/validate', [ProductBomItemController::class, 'validateBom'])->name('v1.products.bom.validate'); + Route::get('/summary', [ProductBomItemController::class, 'summary'])->name('v1.products.bom.summary'); + Route::get('/validate', [ProductBomItemController::class, 'validateBom'])->name('v1.products.bom.validate'); Route::get('/tree', [ProductBomItemController::class, 'tree'])->name('v1.products.bom.tree'); }); - // 설계 전용 (Design) - 운영과 분리된 네임스페이스/경로 Route::prefix('design')->group(function () { - Route::get ('/models', [DesignModelController::class, 'index'])->name('v1.design.models.index'); - Route::post ('/models', [DesignModelController::class, 'store'])->name('v1.design.models.store'); - Route::get ('/models/{id}', [DesignModelController::class, 'show'])->name('v1.design.models.show'); - Route::put ('/models/{id}', [DesignModelController::class, 'update'])->name('v1.design.models.update'); + Route::get('/models', [DesignModelController::class, 'index'])->name('v1.design.models.index'); + Route::post('/models', [DesignModelController::class, 'store'])->name('v1.design.models.store'); + Route::get('/models/{id}', [DesignModelController::class, 'show'])->name('v1.design.models.show'); + Route::put('/models/{id}', [DesignModelController::class, 'update'])->name('v1.design.models.update'); Route::delete('/models/{id}', [DesignModelController::class, 'destroy'])->name('v1.design.models.destroy'); - Route::get ('/models/{modelId}/versions', [DesignModelVersionController::class, 'index'])->name('v1.design.models.versions.index'); - Route::post ('/models/{modelId}/versions', [DesignModelVersionController::class, 'createDraft'])->name('v1.design.models.versions.store'); - Route::post ('/versions/{versionId}/release', [DesignModelVersionController::class, 'release'])->name('v1.design.versions.release'); + Route::get('/models/{modelId}/versions', [DesignModelVersionController::class, 'index'])->name('v1.design.models.versions.index'); + Route::post('/models/{modelId}/versions', [DesignModelVersionController::class, 'createDraft'])->name('v1.design.models.versions.store'); + Route::post('/versions/{versionId}/release', [DesignModelVersionController::class, 'release'])->name('v1.design.versions.release'); - Route::get ('/versions/{versionId}/bom-templates', [DesignBomTemplateController::class, 'listByVersion'])->name('v1.design.bom.templates.index'); - Route::post ('/versions/{versionId}/bom-templates', [DesignBomTemplateController::class, 'upsertTemplate'])->name('v1.design.bom.templates.store'); - Route::get ('/bom-templates/{templateId}', [DesignBomTemplateController::class, 'show'])->name('v1.design.bom.templates.show'); - Route::put ('/bom-templates/{templateId}/items', [DesignBomTemplateController::class, 'replaceItems'])->name('v1.design.bom.templates.items.replace'); - Route::get ('/bom-templates/{templateId}/diff', [DesignBomTemplateController::class, 'diff'])->name('v1.design.bom.templates.diff'); - Route::post ('/bom-templates/{templateId}/clone', [DesignBomTemplateController::class, 'cloneTemplate'])->name('v1.design.bom.templates.clone'); + Route::get('/versions/{versionId}/bom-templates', [DesignBomTemplateController::class, 'listByVersion'])->name('v1.design.bom.templates.index'); + Route::post('/versions/{versionId}/bom-templates', [DesignBomTemplateController::class, 'upsertTemplate'])->name('v1.design.bom.templates.store'); + Route::get('/bom-templates/{templateId}', [DesignBomTemplateController::class, 'show'])->name('v1.design.bom.templates.show'); + Route::put('/bom-templates/{templateId}/items', [DesignBomTemplateController::class, 'replaceItems'])->name('v1.design.bom.templates.items.replace'); + Route::get('/bom-templates/{templateId}/diff', [DesignBomTemplateController::class, 'diff'])->name('v1.design.bom.templates.diff'); + Route::post('/bom-templates/{templateId}/clone', [DesignBomTemplateController::class, 'cloneTemplate'])->name('v1.design.bom.templates.clone'); // 감사 로그 조회 - Route::get ('/audit-logs', [DesignAuditLogController::class, 'index'])->name('v1.design.audit-logs.index'); + Route::get('/audit-logs', [DesignAuditLogController::class, 'index'])->name('v1.design.audit-logs.index'); // BOM 계산 시스템 - Route::get ('/models/{modelId}/estimate-parameters', [BomCalculationController::class, 'getEstimateParameters'])->name('v1.design.models.estimate-parameters'); - Route::post ('/bom-templates/{bomTemplateId}/calculate-bom', [BomCalculationController::class, 'calculateBom'])->name('v1.design.bom-templates.calculate-bom'); - Route::get ('/companies/{companyName}/formulas', [BomCalculationController::class, 'getCompanyFormulas'])->name('v1.design.companies.formulas'); - Route::post ('/companies/{companyName}/formulas/{formulaType}', [BomCalculationController::class, 'saveCompanyFormula'])->name('v1.design.companies.formulas.save'); - Route::post ('/formulas/test', [BomCalculationController::class, 'testFormula'])->name('v1.design.formulas.test'); + Route::get('/models/{modelId}/estimate-parameters', [BomCalculationController::class, 'getEstimateParameters'])->name('v1.design.models.estimate-parameters'); + Route::post('/bom-templates/{bomTemplateId}/calculate-bom', [BomCalculationController::class, 'calculateBom'])->name('v1.design.bom-templates.calculate-bom'); + Route::get('/companies/{companyName}/formulas', [BomCalculationController::class, 'getCompanyFormulas'])->name('v1.design.companies.formulas'); + Route::post('/companies/{companyName}/formulas/{formulaType}', [BomCalculationController::class, 'saveCompanyFormula'])->name('v1.design.companies.formulas.save'); + Route::post('/formulas/test', [BomCalculationController::class, 'testFormula'])->name('v1.design.formulas.test'); }); // 모델셋 관리 API (견적 시스템) Route::prefix('model-sets')->group(function () { - Route::get ('/', [ModelSetController::class, 'index'])->name('v1.model-sets.index'); // 모델셋 목록 - Route::post ('/', [ModelSetController::class, 'store'])->name('v1.model-sets.store'); // 모델셋 생성 - Route::get ('/{id}', [ModelSetController::class, 'show'])->name('v1.model-sets.show'); // 모델셋 상세 - Route::put ('/{id}', [ModelSetController::class, 'update'])->name('v1.model-sets.update'); // 모델셋 수정 + Route::get('/', [ModelSetController::class, 'index'])->name('v1.model-sets.index'); // 모델셋 목록 + Route::post('/', [ModelSetController::class, 'store'])->name('v1.model-sets.store'); // 모델셋 생성 + Route::get('/{id}', [ModelSetController::class, 'show'])->name('v1.model-sets.show'); // 모델셋 상세 + Route::put('/{id}', [ModelSetController::class, 'update'])->name('v1.model-sets.update'); // 모델셋 수정 Route::delete('/{id}', [ModelSetController::class, 'destroy'])->name('v1.model-sets.destroy'); // 모델셋 삭제 - Route::post ('/{id}/clone', [ModelSetController::class, 'clone'])->name('v1.model-sets.clone'); // 모델셋 복제 + Route::post('/{id}/clone', [ModelSetController::class, 'clone'])->name('v1.model-sets.clone'); // 모델셋 복제 // 모델셋 세부 기능 - Route::get ('/{id}/fields', [ModelSetController::class, 'getCategoryFields'])->name('v1.model-sets.fields'); // 카테고리 필드 조회 - Route::get ('/{id}/bom-templates', [ModelSetController::class, 'getBomTemplates'])->name('v1.model-sets.bom-templates'); // BOM 템플릿 조회 - Route::get ('/{id}/estimate-parameters', [ModelSetController::class, 'getEstimateParameters'])->name('v1.model-sets.estimate-parameters'); // 견적 파라미터 - Route::post ('/{id}/calculate-bom', [ModelSetController::class, 'calculateBom'])->name('v1.model-sets.calculate-bom'); // BOM 계산 + Route::get('/{id}/fields', [ModelSetController::class, 'getCategoryFields'])->name('v1.model-sets.fields'); // 카테고리 필드 조회 + Route::get('/{id}/bom-templates', [ModelSetController::class, 'getBomTemplates'])->name('v1.model-sets.bom-templates'); // BOM 템플릿 조회 + Route::get('/{id}/estimate-parameters', [ModelSetController::class, 'getEstimateParameters'])->name('v1.model-sets.estimate-parameters'); // 견적 파라미터 + Route::post('/{id}/calculate-bom', [ModelSetController::class, 'calculateBom'])->name('v1.model-sets.calculate-bom'); // BOM 계산 }); // 견적 관리 API Route::prefix('estimates')->group(function () { - Route::get ('/', [EstimateController::class, 'index'])->name('v1.estimates.index'); // 견적 목록 - Route::post ('/', [EstimateController::class, 'store'])->name('v1.estimates.store'); // 견적 생성 - Route::get ('/{id}', [EstimateController::class, 'show'])->name('v1.estimates.show'); // 견적 상세 - Route::put ('/{id}', [EstimateController::class, 'update'])->name('v1.estimates.update'); // 견적 수정 + Route::get('/', [EstimateController::class, 'index'])->name('v1.estimates.index'); // 견적 목록 + Route::post('/', [EstimateController::class, 'store'])->name('v1.estimates.store'); // 견적 생성 + Route::get('/{id}', [EstimateController::class, 'show'])->name('v1.estimates.show'); // 견적 상세 + Route::put('/{id}', [EstimateController::class, 'update'])->name('v1.estimates.update'); // 견적 수정 Route::delete('/{id}', [EstimateController::class, 'destroy'])->name('v1.estimates.destroy'); // 견적 삭제 - Route::post ('/{id}/clone', [EstimateController::class, 'clone'])->name('v1.estimates.clone'); // 견적 복제 - Route::put ('/{id}/status', [EstimateController::class, 'changeStatus'])->name('v1.estimates.status'); // 견적 상태 변경 + Route::post('/{id}/clone', [EstimateController::class, 'clone'])->name('v1.estimates.clone'); // 견적 복제 + Route::put('/{id}/status', [EstimateController::class, 'changeStatus'])->name('v1.estimates.status'); // 견적 상태 변경 // 견적 폼 및 계산 기능 - Route::get ('/form-schema/{model_set_id}', [EstimateController::class, 'getFormSchema'])->name('v1.estimates.form-schema'); // 견적 폼 스키마 - Route::post ('/preview/{model_set_id}', [EstimateController::class, 'previewCalculation'])->name('v1.estimates.preview'); // 견적 계산 미리보기 + Route::get('/form-schema/{model_set_id}', [EstimateController::class, 'getFormSchema'])->name('v1.estimates.form-schema'); // 견적 폼 스키마 + Route::post('/preview/{model_set_id}', [EstimateController::class, 'previewCalculation'])->name('v1.estimates.preview'); // 견적 계산 미리보기 }); - }); });