fix : 테넌트별 옵션설정 작업

- Tenant Fields
- Tenant Option Groups
- Tenant Option Values
- Tenant Profiles
This commit is contained in:
2025-08-18 19:03:46 +09:00
parent 97a581f3ae
commit aa190bf48d
16 changed files with 1793 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers\Api\V1;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Services\TenantFieldSettingService;
use App\Helpers\ApiResponse;
class TenantFieldSettingController extends Controller
{
// GET /v1/fields
public function index(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return TenantFieldSettingService::index($request->all());
}, '테넌트 필드 효과값 목록 조회');
}
// PUT /v1/fields/bulk
public function bulkUpsert(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return TenantFieldSettingService::bulkUpsert($request->all());
}, '테넌트 필드 설정 대량 저장');
}
// PATCH /v1/fields/{key}
public function updateOne(Request $request, string $key)
{
return ApiResponse::handle(function () use ($request, $key) {
return TenantFieldSettingService::updateOne($key, $request->all());
}, '테넌트 필드 설정 단건 수정');
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Api\V1;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Services\TenantOptionGroupService;
use App\Helpers\ApiResponse;
class TenantOptionGroupController extends Controller
{
// GET /v1/opt-groups
public function index(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return TenantOptionGroupService::index($request->all());
}, '옵션 그룹 목록 조회');
}
// POST /v1/opt-groups
public function store(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return TenantOptionGroupService::store($request->all());
}, '옵션 그룹 생성');
}
// GET /v1/opt-groups/{id}
public function show($id)
{
return ApiResponse::handle(function () use ($id) {
return TenantOptionGroupService::show($id);
}, '옵션 그룹 단건 조회');
}
// PATCH /v1/opt-groups/{id}
public function update(Request $request, $id)
{
return ApiResponse::handle(function () use ($request, $id) {
return TenantOptionGroupService::update($id, $request->all());
}, '옵션 그룹 수정');
}
// DELETE /v1/opt-groups/{id}
public function destroy($id)
{
return ApiResponse::handle(function () use ($id) {
return TenantOptionGroupService::destroy($id);
}, '옵션 그룹 삭제');
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\Api\V1;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Services\TenantOptionValueService;
use App\Helpers\ApiResponse;
class TenantOptionValueController extends Controller
{
// GET /v1/opt-groups/{gid}/values
public function index(Request $request, $gid)
{
return ApiResponse::handle(function () use ($request, $gid) {
return TenantOptionValueService::index($gid, $request->all());
}, '옵션 값 목록 조회');
}
// POST /v1/opt-groups/{gid}/values
public function store(Request $request, $gid)
{
return ApiResponse::handle(function () use ($request, $gid) {
return TenantOptionValueService::store($gid, $request->all());
}, '옵션 값 생성');
}
// GET /v1/opt-groups/{gid}/values/{id}
public function show($gid, $id)
{
return ApiResponse::handle(function () use ($gid, $id) {
return TenantOptionValueService::show($gid, $id);
}, '옵션 값 단건 조회');
}
// PATCH /v1/opt-groups/{gid}/values/{id}
public function update(Request $request, $gid, $id)
{
return ApiResponse::handle(function () use ($request, $gid, $id) {
return TenantOptionValueService::update($gid, $id, $request->all());
}, '옵션 값 수정');
}
// DELETE /v1/opt-groups/{gid}/values/{id}
public function destroy($gid, $id)
{
return ApiResponse::handle(function () use ($gid, $id) {
return TenantOptionValueService::destroy($gid, $id);
}, '옵션 값 삭제');
}
// PATCH /v1/opt-groups/{gid}/values/reorder
public function reorder(Request $request, $gid)
{
return ApiResponse::handle(function () use ($request, $gid) {
return TenantOptionValueService::reorder($gid, $request->all());
}, '옵션 값 정렬 순서 변경');
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Api\V1;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Services\TenantUserProfileService;
use App\Helpers\ApiResponse;
class TenantUserProfileController extends Controller
{
// GET /v1/profiles
public function index(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return TenantUserProfileService::index($request->all());
}, '회원 프로필 목록 조회');
}
// GET /v1/profiles/{userId}
public function show($userId)
{
return ApiResponse::handle(function () use ($userId) {
return TenantUserProfileService::show($userId);
}, '회원 프로필 단건 조회');
}
// PATCH /v1/profiles/{userId}
public function update(Request $request, $userId)
{
return ApiResponse::handle(function () use ($request, $userId) {
return TenantUserProfileService::update($userId, $request->all());
}, '회원 프로필 수정(관리자)');
}
// GET /v1/profiles/me
public function me(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return TenantUserProfileService::me($request->all());
}, '내 프로필 조회');
}
// PATCH /v1/profiles/me
public function updateMe(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return TenantUserProfileService::updateMe($request->all());
}, '내 프로필 수정');
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Models\Tenants;
use Illuminate\Database\Eloquent\Model;
class SettingFieldDef extends Model
{
protected $casts = [
'option_payload' => 'array',
'is_core' => 'boolean',
];
protected $fillable = [
'field_key',
'label',
'data_type',
'input_type',
'option_source',
'option_payload',
'comment',
'created_at',
'updated_at',
'storage_area',
'storage_key',
'is_core',
];
// 관계 (테넌트 설정)
public function tenantSettings()
{
return $this->hasMany(TenantFieldSetting::class, 'field_key', 'field_key');
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Models\Tenants;
use Illuminate\Database\Eloquent\Model;
class TenantFieldSetting extends Model
{
public $timestamps = false;
protected $casts = [
'enabled' => 'boolean',
'required' => 'boolean',
];
protected $fillable = [
'tenant_id',
'field_key',
'enabled',
'required',
'sort_order',
'option_group_id',
'code_group',
];
public function fieldDef()
{
return $this->belongsTo(SettingFieldDef::class, 'field_key', 'field_key');
}
public function optionGroup()
{
return $this->belongsTo(TenantOptionGroup::class, 'option_group_id');
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Models\Tenants;
use Illuminate\Database\Eloquent\Model;
class TenantOptionGroup extends Model
{
public $timestamps = false;
protected $fillable = [
'tenant_id',
'group_key',
'name',
'description',
];
public function values()
{
return $this->hasMany(TenantOptionValue::class, 'group_id');
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Models\Tenants;
use Illuminate\Database\Eloquent\Model;
class TenantOptionValue extends Model
{
public $timestamps = false;
protected $casts = [
'is_active' => 'boolean',
];
protected $fillable = [
'group_id',
'value_key',
'value_label',
'sort_order',
'is_active',
];
public function group()
{
return $this->belongsTo(TenantOptionGroup::class, 'group_id');
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Models\Tenants;
use App\Models\Members\User;
use App\Models\Commons\Department;
use Illuminate\Database\Eloquent\Model;
class TenantUserProfile extends Model
{
protected $casts = [
'json_extra' => 'array',
];
protected $fillable = [
'tenant_id',
'user_id',
'department_id',
'position_key',
'job_title_key',
'work_location_key',
'employment_type_key',
'manager_user_id',
'json_extra',
'created_at',
'updated_at',
'profile_photo_path',
'display_name',
];
// 관계: users 테이블은 전역이라 App\Models\User 로 연결
public function user()
{
return $this->belongsTo(User::class, 'user_id');
}
// 조직, 직급 등은 옵션/코드 참조 가능 (필요시 추가)
public function department()
{
return $this->belongsTo(Department::class, 'department_id');
}
}

View File

@@ -0,0 +1,213 @@
<?php
namespace App\Services;
use App\Helpers\ApiResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use App\Models\Tenants\SettingFieldDef;
use App\Models\Tenants\TenantFieldSetting;
use App\Models\Tenants\TenantOptionGroup;
class TenantFieldSettingService
{
/** 활성 테넌트 ID 조회 (공통) */
protected static function tenantId(): ?int
{
return app('tenant_id'); // 미들웨어에서 주입된 테넌트 ID 가정
}
/** 효과값 1개 빌드: 전역정의 + 테넌트설정 merge */
protected static function buildEffectiveRow(SettingFieldDef $def, ?TenantFieldSetting $s): array
{
$enabled = $s ? (bool)$s->enabled : (bool)$def->is_core;
return [
'field_key' => $def->field_key,
'label' => $def->label,
'data_type' => $def->data_type,
'input_type' => $def->input_type,
'option_source' => $def->option_source,
'option_payload' => $def->option_payload,
'storage_area' => $def->storage_area,
'storage_key' => $def->storage_key,
'is_core' => (bool)$def->is_core,
// tenant overlay
'enabled' => $enabled,
'required' => $s ? (bool)$s->required : false,
'sort_order' => $s ? (int)$s->sort_order : 0,
'option_group_id' => $s ? $s->option_group_id : null,
'code_group' => $s ? $s->code_group : null,
];
}
/**
* [GET] 효과값 목록
* - 코어(is_core=1)는 기본 enabled=true
* - enabled=false는 제외
* - sort_order → field_key 순 정렬
*/
public static function index(array $params = [])
{
$tenantId = self::tenantId();
if (!$tenantId) {
return ApiResponse::error('활성 테넌트가 없습니다.', 400);
}
$defs = SettingFieldDef::query()
->orderByDesc('is_core')
->orderBy('field_key')
->get()
->keyBy('field_key');
$settings = TenantFieldSetting::where('tenant_id', $tenantId)
->get()
->keyBy('field_key');
$rows = [];
foreach ($defs as $key => $def) {
$s = $settings->get($key);
$effective = self::buildEffectiveRow($def, $s);
if ($effective['enabled'] !== true) {
continue;
}
$rows[] = $effective;
}
usort($rows, fn ($a, $b) =>
($a['sort_order'] <=> $b['sort_order']) ?: strcmp($a['field_key'], $b['field_key'])
);
return ApiResponse::response('result', array_values($rows));
}
/**
* [PUT] 대량 업서트
* params:
* items: [{field_key, enabled?, required?, sort_order?, option_group_id?, code_group?}, ...]
*/
public static function bulkUpsert(array $params = [])
{
$tenantId = self::tenantId();
if (!$tenantId) {
return ApiResponse::error('활성 테넌트가 없습니다.', 400);
}
$v = Validator::make($params, [
'items' => ['required','array','min:1'],
'items.*.field_key' => ['required','string','max:64', Rule::exists('setting_field_defs','field_key')],
'items.*.enabled' => ['nullable','boolean'],
'items.*.required' => ['nullable','boolean'],
'items.*.sort_order' => ['nullable','integer'],
'items.*.option_group_id' => ['nullable','integer'],
'items.*.code_group' => ['nullable','string','max:50'],
]);
if ($v->fails()) {
return ApiResponse::error($v->errors()->first(), 422);
}
$payload = $v->validated();
try {
DB::transaction(function () use ($payload, $tenantId) {
foreach ($payload['items'] as $row) {
// option_group_id 유효성(해당 테넌트 소유인지)
if (!empty($row['option_group_id'])) {
$exists = TenantOptionGroup::where('tenant_id', $tenantId)
->where('id', $row['option_group_id'])
->exists();
if (!$exists) {
throw new \RuntimeException('option_group_id is invalid for this tenant');
}
}
TenantFieldSetting::updateOrCreate(
['tenant_id' => $tenantId, 'field_key' => $row['field_key']],
[
'enabled' => isset($row['enabled']) ? (int)$row['enabled'] : 0,
'required' => isset($row['required']) ? (int)$row['required'] : 0,
'sort_order' => isset($row['sort_order']) ? (int)$row['sort_order'] : 0,
'option_group_id' => $row['option_group_id'] ?? null,
'code_group' => $row['code_group'] ?? null,
]
);
}
});
} catch (\RuntimeException $e) {
return ApiResponse::error($e->getMessage(), 422);
} catch (\Throwable $e) {
return ApiResponse::error('저장 중 오류가 발생했습니다.', 500);
}
return ApiResponse::response('result', ['updated' => true]);
}
/**
* [PATCH] 단건 수정
* - 응답은 FieldEffective 포맷으로 반환
*/
public static function updateOne(string $fieldKey, array $params = [])
{
$tenantId = self::tenantId();
if (!$tenantId) {
return ApiResponse::error('활성 테넌트가 없습니다.', 400);
}
// 전역 key 존재 확인
$def = SettingFieldDef::where('field_key', $fieldKey)->first();
if (!$def) {
return ApiResponse::error('field_key not found', 404);
}
$v = Validator::make($params, [
'enabled' => ['nullable','boolean'],
'required' => ['nullable','boolean'],
'sort_order' => ['nullable','integer'],
'option_group_id' => ['nullable','integer'],
'code_group' => ['nullable','string','max:50'],
]);
if ($v->fails()) {
return ApiResponse::error($v->errors()->first(), 422);
}
$data = $v->validated();
// option_group_id 테넌트 소유 검증
if (!empty($data['option_group_id'])) {
$ok = TenantOptionGroup::where('tenant_id', $tenantId)
->where('id', $data['option_group_id'])
->exists();
if (!$ok) {
return ApiResponse::error('option_group_id is invalid for this tenant', 422);
}
}
try {
$item = TenantFieldSetting::firstOrNew([
'tenant_id' => $tenantId,
'field_key' => $fieldKey,
]);
// 제공된 키만 반영
foreach (['enabled','required','sort_order','option_group_id','code_group'] as $k) {
if (array_key_exists($k, $data)) {
$item->{$k} = $data[$k];
}
}
$item->save();
// 최신 설정 다시 로드하여 효과값으로 리턴
$s = TenantFieldSetting::where('tenant_id', $tenantId)
->where('field_key', $fieldKey)
->first();
$effective = self::buildEffectiveRow($def, $s);
return ApiResponse::response('result', $effective);
} catch (\Throwable $e) {
return ApiResponse::error('수정 중 오류가 발생했습니다.', 500);
}
}
}

View File

@@ -0,0 +1,185 @@
<?php
namespace App\Services;
use App\Helpers\ApiResponse;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use App\Models\Tenants\TenantOptionGroup;
use App\Models\Tenants\TenantOptionValue;
use App\Models\Tenants\TenantFieldSetting;
class TenantOptionGroupService
{
/** 활성 테넌트 ID */
protected static function tenantId(): ?int
{
return app('tenant_id');
}
/**
* [GET] 옵션 그룹 목록 (페이징)
* params: page?, per_page?/size?, q?
*/
public static function index(array $params = [])
{
$tenantId = self::tenantId();
if (!$tenantId) {
return ApiResponse::error('활성 테넌트가 없습니다.', 400);
}
$per = (int)($params['per_page'] ?? $params['size'] ?? 20);
$page = isset($params['page']) ? (int)$params['page'] : null;
$q = TenantOptionGroup::where('tenant_id', $tenantId)->orderBy('group_key');
if (!empty($params['q'])) {
$kw = $params['q'];
$q->where(function ($w) use ($kw) {
$w->where('group_key', 'like', "%{$kw}%")
->orWhere('name', 'like', "%{$kw}%");
});
}
$data = $q->paginate($per, ['*'], 'page', $page);
return ApiResponse::response('result', $data);
}
/**
* [POST] 옵션 그룹 생성
*/
public static function store(array $params = [])
{
$tenantId = self::tenantId();
if (!$tenantId) {
return ApiResponse::error('활성 테넌트가 없습니다.', 400);
}
$v = Validator::make($params, [
'group_key' => [
'required','string','max:64','alpha_dash',
Rule::unique('tenant_option_groups', 'group_key')
->where(fn ($q) => $q->where('tenant_id', $tenantId)),
],
'name' => ['required','string','max:100'],
'description' => ['nullable','string','max:255'],
]);
if ($v->fails()) {
return ApiResponse::error($v->errors()->first(), 422);
}
$data = $v->validated();
$data['tenant_id'] = $tenantId;
$item = TenantOptionGroup::create($data);
return ApiResponse::response('result', $item->toArray());
}
/**
* [GET] 옵션 그룹 단건 조회
*/
public static function show(int $id)
{
$tenantId = self::tenantId();
if (!$tenantId) {
return ApiResponse::error('활성 테넌트가 없습니다.', 400);
}
if (!$id) {
return ApiResponse::error('id가 올바르지 않습니다.', 422);
}
$item = TenantOptionGroup::where('tenant_id', $tenantId)->find($id);
if (!$item) {
return ApiResponse::error('옵션 그룹을 찾을 수 없습니다.', 404);
}
return ApiResponse::response('result', $item->toArray());
}
/**
* [PATCH] 옵션 그룹 수정
*/
public static function update(int $id, array $params = [])
{
$tenantId = self::tenantId();
if (!$tenantId) {
return ApiResponse::error('활성 테넌트가 없습니다.', 400);
}
if (!$id) {
return ApiResponse::error('id가 올바르지 않습니다.', 422);
}
$item = TenantOptionGroup::where('tenant_id', $tenantId)->find($id);
if (!$item) {
return ApiResponse::error('옵션 그룹을 찾을 수 없습니다.', 404);
}
$v = Validator::make($params, [
'group_key' => [
'sometimes','string','max:64','alpha_dash',
Rule::unique('tenant_option_groups', 'group_key')
->where(fn ($q) => $q->where('tenant_id', $tenantId))
->ignore($item->id),
],
'name' => ['sometimes','string','max:100'],
'description' => ['nullable','string','max:255'],
]);
if ($v->fails()) {
return ApiResponse::error($v->errors()->first(), 422);
}
$data = $v->validated();
if (empty($data)) {
return ApiResponse::error('수정할 항목이 없습니다.', 422);
}
$item->fill($data)->save();
return ApiResponse::response('result', $item->toArray());
}
/**
* [DELETE] 옵션 그룹 삭제
* - 참조 무결성 검사:
* 1) tenant_option_values에 값이 남아있으면 삭제 불가
* 2) tenant_field_settings.option_group_id에서 참조 중이면 삭제 불가
*/
public static function destroy(int $id)
{
$tenantId = self::tenantId();
if (!$tenantId) {
return ApiResponse::error('활성 테넌트가 없습니다.', 400);
}
if (!$id) {
return ApiResponse::error('id가 올바르지 않습니다.', 422);
}
$item = TenantOptionGroup::where('tenant_id', $tenantId)->find($id);
if (!$item) {
return ApiResponse::error('옵션 그룹을 찾을 수 없습니다.', 404);
}
// 1) 옵션 값 존재 여부
$hasValues = TenantOptionValue::where('group_id', $item->id)->exists();
if ($hasValues) {
return ApiResponse::error('해당 그룹에 옵션 값이 존재하여 삭제할 수 없습니다.', 409);
}
// 2) 필드 설정에서 참조 여부
$isReferenced = TenantFieldSetting::where('tenant_id', $tenantId)
->where('option_group_id', $item->id)
->exists();
if ($isReferenced) {
return ApiResponse::error('필드 설정에서 참조 중인 그룹은 삭제할 수 없습니다.', 409);
}
$item->delete();
return ApiResponse::response('result', ['deleted' => true]);
}
}

View File

@@ -0,0 +1,231 @@
<?php
namespace App\Services;
use App\Helpers\ApiResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use App\Models\Tenants\TenantOptionGroup;
use App\Models\Tenants\TenantOptionValue;
class TenantOptionValueService
{
/** 활성 테넌트 ID */
protected static function tenantId(): ?int
{
return app('tenant_id');
}
/** 테넌트 소유 그룹 로드 (없으면 null) */
protected static function loadGroup(int $tenantId, int $groupId): ?TenantOptionGroup
{
return TenantOptionGroup::where('tenant_id', $tenantId)->find($groupId);
}
/**
* [GET] 옵션 값 목록
* params: active_only?(bool)
*/
public static function index(int $groupId, array $params = [])
{
$tenantId = self::tenantId();
if (!$tenantId) {
return ApiResponse::error('활성 테넌트가 없습니다.', 400);
}
$group = self::loadGroup($tenantId, $groupId);
if (!$group) {
return ApiResponse::error('옵션 그룹을 찾을 수 없습니다.', 404);
}
$q = TenantOptionValue::where('group_id', $group->id)
->orderBy('sort_order')
->orderBy('value_label');
if (!empty($params['active_only'])) {
$q->where('is_active', 1);
}
$list = $q->get();
return ApiResponse::response('result', $list);
}
/**
* [POST] 옵션 값 생성
*/
public static function store(int $groupId, array $params = [])
{
$tenantId = self::tenantId();
if (!$tenantId) {
return ApiResponse::error('활성 테넌트가 없습니다.', 400);
}
$group = self::loadGroup($tenantId, $groupId);
if (!$group) {
return ApiResponse::error('옵션 그룹을 찾을 수 없습니다.', 404);
}
$v = Validator::make($params, [
'value_key' => [
'required','string','max:64','alpha_dash',
Rule::unique('tenant_option_values', 'value_key')
->where(fn ($q) => $q->where('group_id', $group->id)),
],
'value_label' => ['required','string','max:100'],
'sort_order' => ['nullable','integer'],
'is_active' => ['nullable','boolean'],
]);
if ($v->fails()) {
return ApiResponse::error($v->errors()->first(), 422);
}
$data = $v->validated();
$data['group_id'] = $group->id;
$item = TenantOptionValue::create($data);
return ApiResponse::response('result', $item->toArray());
}
/**
* [GET] 옵션 값 단건 조회
*/
public static function show(int $groupId, int $id)
{
$tenantId = self::tenantId();
if (!$tenantId) {
return ApiResponse::error('활성 테넌트가 없습니다.', 400);
}
$group = self::loadGroup($tenantId, $groupId);
if (!$group) {
return ApiResponse::error('옵션 그룹을 찾을 수 없습니다.', 404);
}
$item = TenantOptionValue::where('group_id', $group->id)->find($id);
if (!$item) {
return ApiResponse::error('옵션 값을 찾을 수 없습니다.', 404);
}
return ApiResponse::response('result', $item->toArray());
}
/**
* [PATCH] 옵션 값 수정
*/
public static function update(int $groupId, int $id, array $params = [])
{
$tenantId = self::tenantId();
if (!$tenantId) {
return ApiResponse::error('활성 테넌트가 없습니다.', 400);
}
$group = self::loadGroup($tenantId, $groupId);
if (!$group) {
return ApiResponse::error('옵션 그룹을 찾을 수 없습니다.', 404);
}
$item = TenantOptionValue::where('group_id', $group->id)->find($id);
if (!$item) {
return ApiResponse::error('옵션 값을 찾을 수 없습니다.', 404);
}
$v = Validator::make($params, [
'value_key' => [
'sometimes','string','max:64','alpha_dash',
Rule::unique('tenant_option_values', 'value_key')
->where(fn ($q) => $q->where('group_id', $group->id))
->ignore($item->id),
],
'value_label' => ['sometimes','string','max:100'],
'sort_order' => ['nullable','integer'],
'is_active' => ['nullable','boolean'],
]);
if ($v->fails()) {
return ApiResponse::error($v->errors()->first(), 422);
}
$data = $v->validated();
if (empty($data)) {
return ApiResponse::error('수정할 항목이 없습니다.', 422);
}
$item->fill($data)->save();
return ApiResponse::response('result', $item->toArray());
}
/**
* [DELETE] 옵션 값 삭제
* - TODO: 실제 프로필/필드설정에서 value_key 참조 여부 체크가 필요하면 여기서 차단
*/
public static function destroy(int $groupId, int $id)
{
$tenantId = self::tenantId();
if (!$tenantId) {
return ApiResponse::error('활성 테넌트가 없습니다.', 400);
}
$group = self::loadGroup($tenantId, $groupId);
if (!$group) {
return ApiResponse::error('옵션 그룹을 찾을 수 없습니다.', 404);
}
$item = TenantOptionValue::where('group_id', $group->id)->find($id);
if (!$item) {
return ApiResponse::error('옵션 값을 찾을 수 없습니다.', 404);
}
// TODO: 참조 무결성 검사(필요 시 구현)
$item->delete();
return ApiResponse::response('result', ['deleted' => true]);
}
/**
* [PATCH] 정렬 순서 일괄 변경
* params: items: [{id, sort_order}, ...]
*/
public static function reorder(int $groupId, array $params = [])
{
$tenantId = self::tenantId();
if (!$tenantId) {
return ApiResponse::error('활성 테넌트가 없습니다.', 400);
}
$group = self::loadGroup($tenantId, $groupId);
if (!$group) {
return ApiResponse::error('옵션 그룹을 찾을 수 없습니다.', 404);
}
$v = Validator::make($params, [
'items' => ['required','array','min:1'],
'items.*.id' => ['required','integer'],
'items.*.sort_order' => ['required','integer'],
]);
if ($v->fails()) {
return ApiResponse::error($v->errors()->first(), 422);
}
$rows = $v->validated()['items'];
try {
DB::transaction(function () use ($group, $rows) {
foreach ($rows as $r) {
TenantOptionValue::where('group_id', $group->id)
->where('id', $r['id'])
->update(['sort_order' => (int) $r['sort_order']]);
}
});
} catch (\Throwable $e) {
return ApiResponse::error('정렬 순서 저장 중 오류가 발생했습니다.', 500);
}
return ApiResponse::response('result', ['reordered' => true]);
}
}

View File

@@ -0,0 +1,194 @@
<?php
namespace App\Services;
use App\Helpers\ApiResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use App\Models\Tenants\SettingFieldDef;
use App\Models\Tenants\TenantFieldSetting;
use App\Models\Tenants\TenantUserProfile;
class TenantUserProfileService
{
/** 활성 테넌트 ID */
protected static function tenantId(): ?int
{
return app('tenant_id');
}
/** 효과값 필드(Enabled) 맵: field_key => [def, setting] */
protected static function effectiveFieldMap(int $tenantId): array
{
$defs = SettingFieldDef::all()->keyBy('field_key');
$settings = TenantFieldSetting::where('tenant_id', $tenantId)->get()->keyBy('field_key');
$map = [];
foreach ($defs as $key => $def) {
$s = $settings->get($key);
$enabled = $s ? (bool)$s->enabled : (bool)$def->is_core;
if (!$enabled) continue;
$map[$key] = ['def' => $def, 'setting' => $s];
}
return $map;
}
/**
* [GET] 프로필 목록 (페이징)
* params: per_page?, q?
*/
public static function index(array $params = [])
{
$tenantId = self::tenantId();
if (!$tenantId) {
return ApiResponse::error('활성 테넌트가 없습니다.', 400);
}
$per = (int)($params['per_page'] ?? $params['size'] ?? 20);
$q = TenantUserProfile::where('tenant_id', $tenantId)
->with(['user:id,name,email']);
if (!empty($params['q'])) {
$kw = $params['q'];
$q->where(function ($w) use ($kw) {
$w->where('display_name', 'like', "%{$kw}%")
->orWhere('json_extra->employee_no', 'like', "%{$kw}%");
});
}
$page = isset($params['page']) ? (int)$params['page'] : null;
$data = $q->orderByDesc('id')->paginate($per, ['*'], 'page', $page);
return ApiResponse::response('result', $data);
}
/**
* [GET] 프로필 단건 조회
*/
public static function show(int $userId)
{
$tenantId = self::tenantId();
if (!$tenantId) {
return ApiResponse::error('활성 테넌트가 없습니다.', 400);
}
if (!$userId) {
return ApiResponse::error('userId가 올바르지 않습니다.', 422);
}
$item = TenantUserProfile::where('tenant_id', $tenantId)
->where('user_id', $userId)
->with(['user:id,name,email'])
->first();
if (!$item) {
return ApiResponse::error('프로필을 찾을 수 없습니다.', 404);
}
return ApiResponse::response('result', $item->toArray());
}
/**
* [PATCH] 관리자 수정: enabled 필드만 write-back
* - 입력 payload는 field_key: value 포맷(예: {"position":"manager","employee_no":"A-001"})
*/
public static function update(int $userId, array $params = [])
{
$tenantId = self::tenantId();
if (!$tenantId) {
return ApiResponse::error('활성 테넌트가 없습니다.', 400);
}
if (!$userId) {
return ApiResponse::error('userId가 올바르지 않습니다.', 422);
}
// (선택) 간단 유효성: 최소 1개 키 존재
if (empty($params) || !is_array($params)) {
return ApiResponse::error('수정할 항목이 없습니다.', 422);
}
$fields = self::effectiveFieldMap($tenantId);
$profile = TenantUserProfile::firstOrCreate(['tenant_id' => $tenantId, 'user_id' => $userId]);
try {
DB::transaction(function () use ($fields, $profile, $params) {
foreach ($fields as $key => $meta) {
if (!array_key_exists($key, $params)) continue; // 입력이 없으면 스킵
$def = $meta['def'];
$value = $params[$key];
switch ($def->storage_area) {
case 'tenant_profile':
if ($def->storage_key) {
$profile->{$def->storage_key} = $value;
}
break;
case 'tenant_profile_json':
$jsonKey = $def->storage_key ?: $key;
$extra = is_array($profile->json_extra) ? $profile->json_extra : (array) json_decode((string)$profile->json_extra, true);
$extra[$jsonKey] = $value;
$profile->json_extra = $extra;
break;
case 'users':
// 정책상 기본 비권장: 무시
// 필요 시 UsersService 등으로 화이트리스트 업데이트 구현
break;
}
}
$profile->save();
});
} catch (\Throwable $e) {
return ApiResponse::error('프로필 저장 중 오류가 발생했습니다.', 500);
}
$fresh = TenantUserProfile::where('tenant_id', $tenantId)
->where('user_id', $userId)
->with(['user:id,name,email'])
->first();
return ApiResponse::response('result', $fresh ? $fresh->toArray() : null);
}
/**
* [GET] 내 프로필 조회
*/
public static function me(array $params = [])
{
$tenantId = self::tenantId();
if (!$tenantId) {
return ApiResponse::error('활성 테넌트가 없습니다.', 400);
}
$userId = auth()->id();
if (!$userId) {
return ApiResponse::error('인증 정보가 없습니다.', 401);
}
$item = TenantUserProfile::where('tenant_id', $tenantId)
->where('user_id', $userId)
->with(['user:id,name,email'])
->first();
// 없으면 null 반환(스펙에 따라 404로 바꾸려면 위와 동일 처리)
return ApiResponse::response('result', $item ? $item->toArray() : null);
}
/**
* [PATCH] 내 프로필 수정
* - enabled 필드만 반영 (관리자 수정과 동일 로직)
*/
public static function updateMe(array $params = [])
{
$tenantId = self::tenantId();
if (!$tenantId) {
return ApiResponse::error('활성 테넌트가 없습니다.', 400);
}
$userId = auth()->id();
if (!$userId) {
return ApiResponse::error('인증 정보가 없습니다.', 401);
}
return self::update($userId, $params);
}
}

View File

@@ -0,0 +1,572 @@
<?php
namespace App\Swagger\v1;
use OpenApi\Annotations as OA;
/**
* 전역 태그 선언
*
* @OA\Tag(name="Tenant.Fields", description="테넌트별 필드 구동(효과값 조회/설정)")
* @OA\Tag(name="Tenant.Option Groups", description="테넌트 관리형 옵션 그룹")
* @OA\Tag(name="Tenant.Option Values", description="옵션 그룹의 항목(값) 관리")
* @OA\Tag(name="Tenant.Profiles", description="테넌트별 회원 프로필 조회/수정")
*/
class FieldProfileApi
{
/**
* Fields(효과값/설정) 스키마
*
* @OA\Schema(
* schema="FieldEffective",
* type="object",
* description="전역정의 + 테넌트설정이 merge된 필드 효과값",
* required={"field_key","label","data_type","input_type","enabled"},
* @OA\Property(property="field_key", type="string", example="position"),
* @OA\Property(property="label", type="string", example="직급"),
* @OA\Property(property="data_type", type="string", example="string"),
* @OA\Property(property="input_type", type="string", example="select"),
* @OA\Property(property="option_source", type="string", nullable=true, example="tenant_list"),
* @OA\Property(property="option_payload", type="object", nullable=true),
* @OA\Property(property="storage_area", type="string", example="tenant_profile"),
* @OA\Property(property="storage_key", type="string", example="position_key"),
* @OA\Property(property="is_core", type="boolean", example=false),
* @OA\Property(property="enabled", type="boolean", example=true),
* @OA\Property(property="required", type="boolean", example=false),
* @OA\Property(property="sort_order", type="integer", example=10),
* @OA\Property(property="option_group_id", type="integer", nullable=true, example=5),
* @OA\Property(property="code_group", type="string", nullable=true, example="employment_type")
* )
*
* @OA\Schema(
* schema="FieldSettingUpdateRequest",
* type="object",
* @OA\Property(property="enabled", type="boolean", example=true),
* @OA\Property(property="required", type="boolean", example=false),
* @OA\Property(property="sort_order", type="integer", example=20),
* @OA\Property(property="option_group_id", type="integer", nullable=true, example=5),
* @OA\Property(property="code_group", type="string", nullable=true, example="department")
* )
*
* @OA\Schema(
* schema="FieldSettingBulkItem",
* type="object",
* required={"field_key"},
* @OA\Property(property="field_key", type="string", example="position"),
* @OA\Property(property="enabled", type="boolean", example=true),
* @OA\Property(property="required", type="boolean", example=false),
* @OA\Property(property="sort_order", type="integer", example=10),
* @OA\Property(property="option_group_id", type="integer", nullable=true, example=5),
* @OA\Property(property="code_group", type="string", nullable=true, example="employment_type")
* )
* @OA\Schema(
* schema="FieldSettingBulkRequest",
* type="object",
* required={"items"},
* @OA\Property(
* property="items",
* type="array",
* @OA\Items(ref="#/components/schemas/FieldSettingBulkItem")
* )
* )
*/
public function _fieldSchemasNoop() {}
/**
* Option Group/Value 스키마
*
* @OA\Schema(
* schema="OptionGroup",
* type="object",
* required={"id","group_key","name"},
* @OA\Property(property="id", type="integer", example=3),
* @OA\Property(property="tenant_id", type="integer", example=1),
* @OA\Property(property="group_key", type="string", example="position"),
* @OA\Property(property="name", type="string", example="직급표(2025)"),
* @OA\Property(property="description", type="string", nullable=true, example="본사 기준")
* )
* @OA\Schema(
* schema="OptionGroupCreateRequest",
* type="object",
* required={"group_key","name"},
* @OA\Property(property="group_key", type="string", example="job_title"),
* @OA\Property(property="name", type="string", example="직책"),
* @OA\Property(property="description", type="string", nullable=true, example="영업조직용")
* )
* @OA\Schema(
* schema="OptionGroupUpdateRequest",
* type="object",
* @OA\Property(property="group_key", type="string", example="job_title"),
* @OA\Property(property="name", type="string", example="직책(개정)"),
* @OA\Property(property="description", type="string", nullable=true, example="영업/CS 통합")
* )
*
* @OA\Schema(
* schema="OptionValue",
* type="object",
* required={"id","group_id","value_key","value_label"},
* @OA\Property(property="id", type="integer", example=10),
* @OA\Property(property="group_id", type="integer", example=3),
* @OA\Property(property="value_key", type="string", example="manager"),
* @OA\Property(property="value_label", type="string", example="과장"),
* @OA\Property(property="sort_order", type="integer", example=30),
* @OA\Property(property="is_active", type="boolean", example=true)
* )
* @OA\Schema(
* schema="OptionValueCreateRequest",
* type="object",
* required={"value_key","value_label"},
* @OA\Property(property="value_key", type="string", example="director"),
* @OA\Property(property="value_label", type="string", example="이사"),
* @OA\Property(property="sort_order", type="integer", example=40),
* @OA\Property(property="is_active", type="boolean", example=true)
* )
* @OA\Schema(
* schema="OptionValueUpdateRequest",
* type="object",
* @OA\Property(property="value_key", type="string", example="director"),
* @OA\Property(property="value_label", type="string", example="이사"),
* @OA\Property(property="sort_order", type="integer", example=45),
* @OA\Property(property="is_active", type="boolean", example=false)
* )
* @OA\Schema(
* schema="OptionValueReorderRequest",
* type="object",
* required={"items"},
* @OA\Property(
* property="items",
* type="array",
* description="정렬 대상 목록",
* @OA\Items(
* type="object",
* required={"id","sort_order"},
* @OA\Property(property="id", type="integer", example=10),
* @OA\Property(property="sort_order", type="integer", example=100)
* )
* )
* )
*/
public function _optionSchemasNoop() {}
/**
* Profiles 스키마
*
* @OA\Schema(
* schema="Profile",
* type="object",
* description="테넌트별 사용자 프로필",
* required={"tenant_id","user_id"},
* @OA\Property(property="id", type="integer", example=101),
* @OA\Property(property="tenant_id", type="integer", example=1),
* @OA\Property(property="user_id", type="integer", example=55),
* @OA\Property(property="department_id", type="integer", nullable=true, example=12),
* @OA\Property(property="position_key", type="string", nullable=true, example="manager"),
* @OA\Property(property="job_title_key", type="string", nullable=true, example="lead_pm"),
* @OA\Property(property="work_location_key", type="string", nullable=true, example="seoul_hq"),
* @OA\Property(property="employment_type_key", type="string", nullable=true, example="regular"),
* @OA\Property(property="manager_user_id", type="integer", nullable=true, example=77),
* @OA\Property(property="display_name", type="string", nullable=true, example="김철수(영업1팀)"),
* @OA\Property(property="profile_photo_path", type="string", nullable=true, example="/uploads/tenant/1/avatar/55.png"),
* @OA\Property(property="json_extra", type="object", nullable=true,
* example={"employee_no":"A-001","entry_date":"2023-01-02","work_type":"hybrid"})
* )
* @OA\Schema(
* schema="ProfileUpdateRequest",
* type="object",
* description="프로필 수정 시, field_key: value 형태",
* example={
* "position":"manager",
* "employee_no":"A-001",
* "entry_date":"2023-01-02",
* "work_type":"hybrid"
* }
* )
* @OA\Schema(
* schema="ProfilePagination",
* type="object",
* description="LengthAwarePaginator 구조",
* @OA\Property(property="current_page", type="integer", example=1),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Profile")),
* @OA\Property(property="total", type="integer", example=3),
* @OA\Property(property="per_page", type="integer", example=20),
* @OA\Property(property="path", type="string", example="/api/v1/profiles")
* )
*/
public function _profileSchemasNoop() {}
/* =========================
====== Endpoints ========
========================= */
/**
* @OA\Get(
* path="/api/v1/fields",
* summary="효과값 필드 목록",
* description="전역 필드 정의와 테넌트 설정을 병합한, 실제 화면 노출용(Enabled) 필드 목록을 반환합니다.",
* tags={"Tenant.Fields"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Response(
* response=200,
* description="성공",
* @OA\JsonContent(allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", type="array",
* @OA\Items(ref="#/components/schemas/FieldEffective")
* ))
* })
* ),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function fieldsIndex() {}
/**
* @OA\Put(
* path="/api/v1/fields/bulk",
* summary="필드 설정 대량 저장",
* description="여러 필드에 대해 enabled/required/sort_order/option_group_id/code_group 설정을 한번에 저장합니다.",
* tags={"Tenant.Fields"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/FieldSettingBulkRequest")),
* @OA\Response(
* response=200,
* description="저장 성공",
* @OA\JsonContent(allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", type="object", example={"updated":true}))
* })
* ),
* @OA\Response(response=400, description="유효성 오류", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function fieldsBulk() {}
/**
* @OA\Patch(
* path="/api/v1/fields/{key}",
* summary="필드 설정 단건 수정",
* description="특정 field_key에 대한 테넌트 설정을 수정합니다.",
* tags={"Tenant.Fields"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(
* name="key", in="path", required=true,
* description="수정할 필드 키", @OA\Schema(type="string", example="position")
* ),
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/FieldSettingUpdateRequest")),
* @OA\Response(
* response=200,
* description="수정 성공",
* @OA\JsonContent(allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/FieldEffective"))
* })
* ),
* @OA\Response(response=400, description="유효성 오류", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="필드 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function fieldsUpdateOne() {}
/**
* @OA\Get(
* path="/api/v1/opt-groups",
* summary="옵션 그룹 목록",
* description="해당 테넌트의 옵션 그룹 목록을 페이징으로 반환합니다.",
* tags={"Tenant.Option Groups"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(ref="#/components/parameters/Page"),
* @OA\Parameter(ref="#/components/parameters/Size"),
* @OA\Parameter(name="q", in="query", required=false, description="검색어(그룹키/이름)", @OA\Schema(type="string", example="position")),
* @OA\Response(
* response=200,
* description="성공",
* @OA\JsonContent(allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", type="object",
* @OA\Property(property="current_page", type="integer", example=1),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/OptionGroup")),
* @OA\Property(property="total", type="integer", example=3)
* ))
* })
* ),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function optGroupsIndex() {}
/**
* @OA\Post(
* path="/api/v1/opt-groups",
* summary="옵션 그룹 생성",
* description="옵션 그룹을 생성합니다.",
* tags={"Tenant.Option Groups"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/OptionGroupCreateRequest")),
* @OA\Response(response=200, description="생성 성공",
* @OA\JsonContent(allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/OptionGroup"))
* })
* )
* )
*/
public function optGroupsStore() {}
/**
* @OA\Get(
* path="/api/v1/opt-groups/{id}",
* summary="옵션 그룹 단건 조회",
* tags={"Tenant.Option Groups"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=3)),
* @OA\Response(response=200, description="성공",
* @OA\JsonContent(allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/OptionGroup"))
* })
* )
* )
*/
public function optGroupsShow() {}
/**
* @OA\Patch(
* path="/api/v1/opt-groups/{id}",
* summary="옵션 그룹 수정",
* tags={"Tenant.Option Groups"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=3)),
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/OptionGroupUpdateRequest")),
* @OA\Response(response=200, description="수정 성공",
* @OA\JsonContent(allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/OptionGroup"))
* })
* )
* )
*/
public function optGroupsUpdate() {}
/**
* @OA\Delete(
* path="/api/v1/opt-groups/{id}",
* summary="옵션 그룹 삭제",
* tags={"Tenant.Option Groups"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=3)),
* @OA\Response(response=200, description="삭제 성공",
* @OA\JsonContent(allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", type="object", nullable=true, example=null))
* })
* )
* )
*/
public function optGroupsDestroy() {}
/**
* @OA\Get(
* path="/api/v1/opt-groups/{gid}/values",
* summary="옵션 값 목록",
* tags={"Tenant.Option Values"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="gid", in="path", required=true, @OA\Schema(type="integer", example=3)),
* @OA\Parameter(name="active_only", in="query", required=false, @OA\Schema(type="boolean", example=true)),
* @OA\Response(response=200, description="성공",
* @OA\JsonContent(allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/OptionValue")))
* })
* )
* )
*/
public function optionValuesIndex() {}
/**
* @OA\Post(
* path="/api/v1/opt-groups/{gid}/values",
* summary="옵션 값 생성",
* tags={"Tenant.Option Values"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="gid", in="path", required=true, @OA\Schema(type="integer", example=3)),
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/OptionValueCreateRequest")),
* @OA\Response(response=200, description="생성 성공",
* @OA\JsonContent(allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/OptionValue"))
* })
* )
* )
*/
public function optionValuesStore() {}
/**
* @OA\Get(
* path="/api/v1/opt-groups/{gid}/values/{id}",
* summary="옵션 값 단건 조회",
* tags={"Tenant.Option Values"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="gid", in="path", required=true, @OA\Schema(type="integer", example=3)),
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=10)),
* @OA\Response(response=200, description="성공",
* @OA\JsonContent(allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/OptionValue"))
* })
* )
* )
*/
public function optionValuesShow() {}
/**
* @OA\Patch(
* path="/api/v1/opt-groups/{gid}/values/{id}",
* summary="옵션 값 수정",
* tags={"Tenant.Option Values"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="gid", in="path", required=true, @OA\Schema(type="integer", example=3)),
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=10)),
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/OptionValueUpdateRequest")),
* @OA\Response(response=200, description="수정 성공",
* @OA\JsonContent(allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/OptionValue"))
* })
* )
* )
*/
public function optionValuesUpdate() {}
/**
* @OA\Delete(
* path="/api/v1/opt-groups/{gid}/values/{id}",
* summary="옵션 값 삭제",
* tags={"Tenant.Option Values"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="gid", in="path", required=true, @OA\Schema(type="integer", example=3)),
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=10)),
* @OA\Response(response=200, description="삭제 성공",
* @OA\JsonContent(allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", type="object", nullable=true, example=null))
* })
* )
* )
*/
public function optionValuesDestroy() {}
/**
* @OA\Patch(
* path="/api/v1/opt-groups/{gid}/values/reorder",
* summary="옵션 값 정렬 순서 일괄 변경",
* tags={"Tenant.Option Values"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="gid", in="path", required=true, @OA\Schema(type="integer", example=3)),
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/OptionValueReorderRequest")),
* @OA\Response(response=200, description="변경 성공",
* @OA\JsonContent(allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", type="object", example={"reordered":true}))
* })
* )
* )
*/
public function optionValuesReorder() {}
/**
* @OA\Get(
* path="/api/v1/profiles",
* summary="프로필 목록",
* description="테넌트 내 회원 프로필 목록을 페이징으로 반환합니다.",
* tags={"Tenant.Profiles"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(ref="#/components/parameters/Page"),
* @OA\Parameter(ref="#/components/parameters/Size"),
* @OA\Parameter(name="q", in="query", required=false, description="검색어(표기명/사번 등)", @OA\Schema(type="string", example="A-001")),
* @OA\Response(response=200, description="성공",
* @OA\JsonContent(allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/ProfilePagination"))
* })
* )
* )
*/
public function profilesIndex() {}
/**
* @OA\Get(
* path="/api/v1/profiles/{userId}",
* summary="프로필 단건 조회",
* tags={"Tenant.Profiles"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="userId", in="path", required=true, @OA\Schema(type="integer", example=55)),
* @OA\Response(response=200, description="성공",
* @OA\JsonContent(allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Profile"))
* })
* )
* )
*/
public function profilesShow() {}
/**
* @OA\Patch(
* path="/api/v1/profiles/{userId}",
* summary="프로필 수정(관리자)",
* description="관리자 권한으로 해당 사용자의 프로필을 수정합니다. 테넌트에서 enabled된 필드만 반영됩니다.",
* tags={"Tenant.Profiles"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="userId", in="path", required=true, @OA\Schema(type="integer", example=55)),
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/ProfileUpdateRequest")),
* @OA\Response(response=200, description="수정 성공",
* @OA\JsonContent(allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Profile"))
* })
* )
* )
*/
public function profilesUpdate() {}
/**
* @OA\Get(
* path="/api/v1/profiles/me",
* summary="내 프로필 조회",
* tags={"Tenant.Profiles"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Response(response=200, description="성공",
* @OA\JsonContent(allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Profile"))
* })
* )
* )
*/
public function profilesMe() {}
/**
* @OA\Patch(
* path="/api/v1/profiles/me",
* summary="내 프로필 수정",
* tags={"Tenant.Profiles"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/ProfileUpdateRequest")),
* @OA\Response(response=200, description="수정 성공",
* @OA\JsonContent(allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Profile"))
* })
* )
* )
*/
public function profilesUpdateMe() {}
}