feat: 회원가입 + 테넌트 생성 통합 API 추가 (/api/v1/register)

- 사용자 등록 + 테넌트 생성 + 시스템 관리자 권한 자동 부여
- 사업자번호 조건부 검증 (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
This commit is contained in:
2025-11-06 17:24:42 +09:00
parent b7cf045a81
commit 48e76432ee
9 changed files with 862 additions and 1329 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\RegisterRequest;
use App\Http\Resources\ApiResponse;
use App\Services\RegisterService;
class RegisterController extends Controller
{
/**
* Register a new user with tenant creation
*
* @return \Illuminate\Http\JsonResponse
*/
public function register(RegisterRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return RegisterService::register($request->validated());
}, __('message.registered'));
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class RegisterRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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'),
];
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace App\Services;
use App\Models\Commons\Menu;
use App\Models\Members\User;
use App\Models\Tenants\Tenant;
use App\Models\Tenants\TenantUserProfile;
use App\Services\TenantBootstrap\RecipeRegistry;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
class RegisterService
{
/**
* 회원가입 처리 (테넌트 생성 + 사용자 생성 + 시스템 관리자 역할 부여)
*
* @param array $params [
* 'user_id' => 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,
],
];
});
}
}

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Post(
* path="/api/v1/register",
* tags={"Auth"},
* summary="회원가입 및 테넌트 생성",
* description="신규 회원가입과 동시에 새로운 테넌트(회사)를 생성합니다. 사용자는 자동으로 system_manager 역할이 부여되며 모든 테넌트 메뉴 권한을 갖습니다.",
* operationId="register",
*
* @OA\RequestBody(
* required=true,
* description="회원가입 정보",
*
* @OA\JsonContent(
* required={"user_id", "name", "email", "password", "password_confirmation", "company_name", "business_num"},
*
* @OA\Property(property="user_id", type="string", example="john_doe", description="사용자 아이디 (영문, 숫자, _, - 만 허용)"),
* @OA\Property(property="name", type="string", example="홍길동", description="사용자 이름"),
* @OA\Property(property="email", type="string", format="email", example="john@example.com", description="이메일 주소"),
* @OA\Property(property="phone", type="string", example="010-1234-5678", description="전화번호 (선택)", nullable=true),
* @OA\Property(property="password", type="string", format="password", example="password123!", description="비밀번호 (최소 8자)"),
* @OA\Property(property="password_confirmation", type="string", format="password", example="password123!", description="비밀번호 확인"),
* @OA\Property(property="position", type="string", example="개발팀장", description="직책 (선택)", nullable=true),
* @OA\Property(property="company_name", type="string", example="(주)테크컴퍼니", description="회사명"),
* @OA\Property(property="business_num", type="string", example="123-45-67890", description="사업자등록번호 (000-00-00000 형식)"),
* @OA\Property(property="company_scale", type="string", example="중소기업", description="회사 규모 (선택)", nullable=true),
* @OA\Property(property="industry", type="string", example="IT/소프트웨어", description="업종 (선택)", nullable=true)
* )
* ),
*
* @OA\Response(
* response=200,
* description="회원가입 성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="회원가입이 완료되었습니다"),
* @OA\Property(
* property="data",
* type="object",
* @OA\Property(
* property="user",
* type="object",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="user_id", type="string", example="john_doe"),
* @OA\Property(property="name", type="string", example="홍길동"),
* @OA\Property(property="email", type="string", example="john@example.com"),
* @OA\Property(property="phone", type="string", example="010-1234-5678", nullable=true),
* @OA\Property(
* property="options",
* type="object",
* @OA\Property(property="position", type="string", example="개발팀장"),
* nullable=true
* )
* ),
* @OA\Property(
* property="tenant",
* type="object",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="company_name", type="string", example="(주)테크컴퍼니"),
* @OA\Property(property="business_num", type="string", example="123-45-67890"),
* @OA\Property(property="tenant_st_code", type="string", example="trial", description="테넌트 상태 (trial: 데모, active: 정식, none: 비활성)"),
* @OA\Property(
* property="options",
* type="object",
* @OA\Property(property="company_scale", type="string", example="중소기업"),
* @OA\Property(property="industry", type="string", example="IT/소프트웨어"),
* nullable=true
* )
* )
* )
* )
* ),
*
* @OA\Response(
* response=422,
* description="유효성 검증 실패",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=false),
* @OA\Property(property="message", type="string", example="유효성 검증에 실패했습니다"),
* @OA\Property(
* property="errors",
* type="object",
* @OA\Property(
* property="user_id",
* type="array",
*
* @OA\Items(type="string", example="이미 사용 중인 아이디입니다")
* ),
*
* @OA\Property(
* property="business_num",
* type="array",
*
* @OA\Items(type="string", example="사업자등록번호 형식이 올바르지 않습니다 (000-00-00000)")
* )
* )
* )
* ),
*
* @OA\Response(
* response=500,
* description="서버 오류",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=false),
* @OA\Property(property="message", type="string", example="회원가입 처리 중 오류가 발생했습니다")
* )
* )
* )
*/
class RegisterApi {}

View File

@@ -1,4 +1,5 @@
<?php
/**
* 시스템/도메인 에러 메시지 (클라이언트 피드백용 고정 문자열)
* - DB 데이터 아님
@@ -8,22 +9,26 @@
return [
// 4xx 공통
'not_found' => '존재하지 않는 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
// 서버 오류

View File

@@ -1,4 +1,5 @@
<?php
/**
* 성공/안내/일반 메시지 (비에러)
* - API 정상 응답의 message 필드용
@@ -8,61 +9,62 @@
return [
// 공통 (CRUD/편의)
'fetched' => '조회 성공',
'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' => '파일 목록을 조회했습니다.',
],
];

View File

@@ -1,62 +1,58 @@
<?php
use App\Http\Controllers\Api\V1\CategoryLogController;
use App\Http\Controllers\Api\V1\ProductBomItemController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\V1\CommonController;
use App\Http\Controllers\Api\V1\ApiController;
use App\Http\Controllers\Api\V1\FileController;
use App\Http\Controllers\Api\V1\ProductController;
use App\Http\Controllers\Api\V1\MaterialController;
use App\Http\Controllers\Api\V1\UserController;
use App\Http\Controllers\Api\V1\TenantController;
use App\Http\Controllers\Api\V1\AdminController;
use App\Http\Controllers\Api\V1\MenuController;
use App\Http\Controllers\Api\V1\RoleController;
use App\Http\Controllers\Api\V1\RolePermissionController;
use App\Http\Controllers\Api\V1\UserRoleController;
use App\Http\Controllers\Api\V1\DepartmentController;
use App\Http\Controllers\Api\V1\PermissionController;
use App\Http\Controllers\Api\V1\TenantFieldSettingController;
use App\Http\Controllers\Api\V1\TenantOptionGroupController;
use App\Http\Controllers\Api\V1\TenantOptionValueController;
use App\Http\Controllers\Api\V1\TenantUserProfileController;
use App\Http\Controllers\Api\V1\ApiController;
use App\Http\Controllers\Api\V1\CategoryController;
use App\Http\Controllers\Api\V1\CategoryFieldController;
use App\Http\Controllers\Api\V1\CategoryLogController;
use App\Http\Controllers\Api\V1\CategoryTemplateController;
use App\Http\Controllers\Api\V1\ClassificationController;
use App\Http\Controllers\Api\V1\ClientController;
use App\Http\Controllers\Api\V1\ClientGroupController;
use App\Http\Controllers\Api\V1\PricingController;
// 설계 전용 (디자인 네임스페이스)
use App\Http\Controllers\Api\V1\Design\DesignModelController as DesignModelController;
use App\Http\Controllers\Api\V1\Design\ModelVersionController as DesignModelVersionController;
use App\Http\Controllers\Api\V1\Design\BomTemplateController as DesignBomTemplateController;
use App\Http\Controllers\Api\V1\CommonController;
use App\Http\Controllers\Api\V1\DepartmentController;
use App\Http\Controllers\Api\V1\Design\AuditLogController as DesignAuditLogController;
use App\Http\Controllers\Api\V1\Design\BomCalculationController;
// 모델셋 관리 (견적 시스템)
use App\Http\Controllers\Api\V1\ModelSetController;
use App\Http\Controllers\Api\V1\Design\BomTemplateController as DesignBomTemplateController;
use App\Http\Controllers\Api\V1\Design\DesignModelController;
use App\Http\Controllers\Api\V1\Design\ModelVersionController as DesignModelVersionController;
use App\Http\Controllers\Api\V1\EstimateController;
use App\Http\Controllers\Api\V1\FileController;
use App\Http\Controllers\Api\V1\MaterialController;
use App\Http\Controllers\Api\V1\MenuController;
use App\Http\Controllers\Api\V1\ModelSetController;
use App\Http\Controllers\Api\V1\PermissionController;
use App\Http\Controllers\Api\V1\PricingController;
use App\Http\Controllers\Api\V1\ProductBomItemController;
use App\Http\Controllers\Api\V1\ProductController;
use App\Http\Controllers\Api\V1\RegisterController;
use App\Http\Controllers\Api\V1\RoleController;
use App\Http\Controllers\Api\V1\RolePermissionController;
use App\Http\Controllers\Api\V1\TenantController;
// 설계 전용 (디자인 네임스페이스)
use App\Http\Controllers\Api\V1\TenantFieldSettingController;
use App\Http\Controllers\Api\V1\TenantOptionGroupController;
use App\Http\Controllers\Api\V1\TenantOptionValueController;
use App\Http\Controllers\Api\V1\TenantUserProfileController;
use App\Http\Controllers\Api\V1\UserController;
// 모델셋 관리 (견적 시스템)
use App\Http\Controllers\Api\V1\UserRoleController;
use Illuminate\Support\Facades\Route;
// V1 초기 개발
Route::prefix('v1')->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'); // 견적 계산 미리보기
});
});
});