feat: [org-chart] 조직도 관리 API 이관 (8개 엔드포인트)

- OrgChartController + OrgChartService 신규 생성
- FormRequest 5개 (Assign/Unassign/ReorderEmployees/ReorderDepartments/ToggleHide)
- Department 모델 options cast 추가
- Swagger 문서 (OrgChartApi.php) 생성
- hr.php 라우트 그룹 추가 (/v1/org-chart)
This commit is contained in:
김보곤
2026-03-22 17:22:12 +09:00
parent d502c9b85d
commit 902f681f6e
11 changed files with 1588 additions and 24 deletions

View File

@@ -0,0 +1,82 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\OrgChart\AssignRequest;
use App\Http\Requests\OrgChart\ReorderDepartmentsRequest;
use App\Http\Requests\OrgChart\ReorderEmployeesRequest;
use App\Http\Requests\OrgChart\ToggleHideRequest;
use App\Http\Requests\OrgChart\UnassignRequest;
use App\Services\OrgChartService;
use Illuminate\Http\Request;
class OrgChartController extends Controller
{
public function __construct(private OrgChartService $service) {}
// GET /v1/org-chart
public function index(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->getOrgChart($request->all());
}, __('message.fetched'));
}
// GET /v1/org-chart/stats
public function stats()
{
return ApiResponse::handle(function () {
return $this->service->getStats();
}, __('message.fetched'));
}
// GET /v1/org-chart/unassigned
public function unassigned(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->getUnassigned($request->all());
}, __('message.fetched'));
}
// POST /v1/org-chart/assign
public function assign(AssignRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->assign($request->validated());
}, __('message.updated'));
}
// POST /v1/org-chart/unassign
public function unassign(UnassignRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->unassign($request->validated());
}, __('message.updated'));
}
// PUT /v1/org-chart/reorder-employees
public function reorderEmployees(ReorderEmployeesRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->reorderEmployees($request->validated());
}, __('message.reordered'));
}
// PUT /v1/org-chart/reorder-departments
public function reorderDepartments(ReorderDepartmentsRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->reorderDepartments($request->validated());
}, __('message.reordered'));
}
// PATCH /v1/org-chart/departments/{id}/toggle-hide
public function toggleHide(int $id, ToggleHideRequest $request)
{
return ApiResponse::handle(function () use ($id, $request) {
return $this->service->toggleHide($id, $request->validated());
}, __('message.updated'));
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\OrgChart;
use Illuminate\Foundation\Http\FormRequest;
class AssignRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'employee_id' => 'required|integer',
'department_id' => 'required|integer',
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests\OrgChart;
use Illuminate\Foundation\Http\FormRequest;
class ReorderDepartmentsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'orders' => 'required|array',
'orders.*.id' => 'required|integer',
'orders.*.parent_id' => 'nullable|integer',
'orders.*.sort_order' => 'required|integer',
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\OrgChart;
use Illuminate\Foundation\Http\FormRequest;
class ReorderEmployeesRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'moves' => 'required|array',
'moves.*.employee_id' => 'required|integer',
'moves.*.department_id' => 'nullable|integer',
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\OrgChart;
use Illuminate\Foundation\Http\FormRequest;
class ToggleHideRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'hidden' => 'required|boolean',
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\OrgChart;
use Illuminate\Foundation\Http\FormRequest;
class UnassignRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'employee_id' => 'required|integer',
];
}
}

View File

@@ -28,6 +28,7 @@ class Department extends Model
'parent_id' => 'int',
'is_active' => 'bool',
'sort_order' => 'int',
'options' => 'array',
];
protected $hidden = [

View File

@@ -0,0 +1,308 @@
<?php
namespace App\Services;
use App\Models\Tenants\Department;
use App\Models\Tenants\TenantUserProfile;
use Illuminate\Support\Facades\DB;
class OrgChartService extends Service
{
/**
* 조직도 전체 조회 (부서 트리 + 직원 + 통계)
*/
public function getOrgChart(array $params): array
{
$tenantId = $this->tenantId();
$includeHidden = filter_var($params['include_hidden'] ?? true, FILTER_VALIDATE_BOOLEAN);
// 부서 전체 조회 (트리 구성을 위해 flat으로 가져옴)
$deptQuery = Department::where('tenant_id', $tenantId)
->where('is_active', true);
if (! $includeHidden) {
$deptQuery->where(function ($q) {
$q->whereNull('options->orgchart_hidden')
->orWhere('options->orgchart_hidden', false);
});
}
$departments = $deptQuery
->orderBy('sort_order')
->orderBy('name')
->get();
// 전체 활성 직원
$employees = TenantUserProfile::where('tenant_id', $tenantId)
->active()
->with(['user:id,name,email'])
->orderBy('display_name')
->get()
->map(fn ($e) => [
'id' => $e->id,
'user_id' => $e->user_id,
'department_id' => $e->department_id,
'display_name' => $e->display_name ?? $e->user?->name ?? '(이름없음)',
'position_label' => $e->position_label,
])
->values();
// 회사 정보
$tenant = \App\Models\Tenants\Tenant::find($tenantId);
// 통계
$total = $employees->count();
$assigned = $employees->whereNotNull('department_id')->count();
// 부서 트리 구성
$deptTree = $this->buildTree($departments, $employees);
// 숨겨진 부서 목록 (include_hidden=true일 때만 의미)
$hiddenDepts = $departments->filter(fn ($d) => ($d->options['orgchart_hidden'] ?? false) === true)
->map(fn ($d) => ['id' => $d->id, 'name' => $d->name, 'code' => $d->code])
->values();
return [
'company' => [
'name' => $tenant->company_name ?? 'SAM',
'ceo_name' => $tenant->ceo_name ?? '',
],
'departments' => $deptTree,
'hidden_departments' => $hiddenDepts,
'unassigned' => $employees->whereNull('department_id')->values(),
'stats' => [
'total' => $total,
'assigned' => $assigned,
'unassigned' => $total - $assigned,
],
];
}
/**
* 조직도 통계
*/
public function getStats(): array
{
$tenantId = $this->tenantId();
$total = TenantUserProfile::where('tenant_id', $tenantId)->active()->count();
$assigned = TenantUserProfile::where('tenant_id', $tenantId)->active()->whereNotNull('department_id')->count();
return [
'total' => $total,
'assigned' => $assigned,
'unassigned' => $total - $assigned,
];
}
/**
* 미배치 직원 목록
*/
public function getUnassigned(array $params): array
{
$tenantId = $this->tenantId();
$query = TenantUserProfile::where('tenant_id', $tenantId)
->active()
->whereNull('department_id')
->with(['user:id,name,email'])
->orderBy('display_name');
if (! empty($params['q'])) {
$q = $params['q'];
$query->where(function ($w) use ($q) {
$w->where('display_name', 'like', "%{$q}%")
->orWhereHas('user', function ($u) use ($q) {
$u->where('name', 'like', "%{$q}%");
});
});
}
return $query->get()
->map(fn ($e) => [
'id' => $e->id,
'user_id' => $e->user_id,
'display_name' => $e->display_name ?? $e->user?->name ?? '(이름없음)',
'position_label' => $e->position_label,
])
->values()
->toArray();
}
/**
* 직원 부서 배치
*/
public function assign(array $params): array
{
$tenantId = $this->tenantId();
$employee = TenantUserProfile::where('tenant_id', $tenantId)
->where('id', $params['employee_id'])
->first();
if (! $employee) {
return ['error' => __('error.not_found'), 'code' => 404];
}
$dept = Department::where('tenant_id', $tenantId)
->where('id', $params['department_id'])
->first();
if (! $dept) {
return ['error' => __('error.not_found'), 'code' => 404];
}
$employee->department_id = $params['department_id'];
$employee->save();
return [
'employee_id' => $employee->id,
'department_id' => $params['department_id'],
];
}
/**
* 직원 미배치 처리
*/
public function unassign(array $params): array
{
$tenantId = $this->tenantId();
$employee = TenantUserProfile::where('tenant_id', $tenantId)
->where('id', $params['employee_id'])
->first();
if (! $employee) {
return ['error' => __('error.not_found'), 'code' => 404];
}
$employee->department_id = null;
$employee->save();
return ['employee_id' => $employee->id, 'department_id' => null];
}
/**
* 직원 일괄 배치/이동
*/
public function reorderEmployees(array $params): array
{
$tenantId = $this->tenantId();
DB::transaction(function () use ($params, $tenantId) {
foreach ($params['moves'] as $move) {
TenantUserProfile::where('tenant_id', $tenantId)
->where('id', $move['employee_id'])
->update(['department_id' => $move['department_id']]);
}
});
return ['processed' => count($params['moves'])];
}
/**
* 부서 순서/계층 일괄 변경
*/
public function reorderDepartments(array $params): array
{
$tenantId = $this->tenantId();
// 순환 참조 검증
$orders = collect($params['orders']);
foreach ($orders as $order) {
if ($order['parent_id'] !== null && $order['parent_id'] === $order['id']) {
return ['error' => '자기 자신을 상위 부서로 설정할 수 없습니다.', 'code' => 422];
}
}
// 순환 참조 심층 검증 (A→B→C→A 같은 케이스)
$parentMap = $orders->pluck('parent_id', 'id')->toArray();
foreach ($parentMap as $id => $parentId) {
if ($parentId === null) {
continue;
}
$visited = [$id];
$current = $parentId;
while ($current !== null && isset($parentMap[$current])) {
if (in_array($current, $visited)) {
return ['error' => '순환 참조가 감지되었습니다.', 'code' => 422];
}
$visited[] = $current;
$current = $parentMap[$current];
}
}
DB::transaction(function () use ($params, $tenantId) {
foreach ($params['orders'] as $order) {
Department::where('tenant_id', $tenantId)
->where('id', $order['id'])
->update([
'parent_id' => $order['parent_id'],
'sort_order' => $order['sort_order'],
]);
}
});
return ['processed' => count($params['orders'])];
}
/**
* 부서 숨기기/표시 토글
*/
public function toggleHide(int $departmentId, array $params): array
{
$tenantId = $this->tenantId();
$dept = Department::where('tenant_id', $tenantId)
->where('id', $departmentId)
->first();
if (! $dept) {
return ['error' => __('error.not_found'), 'code' => 404];
}
$options = $dept->options ?? [];
$options['orgchart_hidden'] = $params['hidden'];
$dept->options = $options;
$dept->save();
return [
'id' => $dept->id,
'orgchart_hidden' => $params['hidden'],
];
}
/**
* flat 부서 목록 → 트리 구조 변환
*/
private function buildTree($departments, $employees): array
{
$deptMap = [];
foreach ($departments as $dept) {
$deptMap[$dept->id] = [
'id' => $dept->id,
'name' => $dept->name,
'code' => $dept->code,
'parent_id' => $dept->parent_id,
'sort_order' => $dept->sort_order,
'is_active' => $dept->is_active,
'orgchart_hidden' => $dept->options['orgchart_hidden'] ?? false,
'children' => [],
'employees' => $employees->where('department_id', $dept->id)->values()->toArray(),
];
}
$tree = [];
foreach ($deptMap as $id => &$node) {
if ($node['parent_id'] === null || ! isset($deptMap[$node['parent_id']])) {
$tree[] = &$node;
} else {
$deptMap[$node['parent_id']]['children'][] = &$node;
}
}
unset($node);
return $tree;
}
}

View File

@@ -0,0 +1,359 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(
* name="OrgChart",
* description="조직도 관리"
* )
*
* @OA\Schema(
* schema="OrgChartEmployee",
* type="object",
*
* @OA\Property(property="id", type="integer", example=5, description="프로필 ID"),
* @OA\Property(property="user_id", type="integer", example=3),
* @OA\Property(property="department_id", type="integer", nullable=true, example=1),
* @OA\Property(property="display_name", type="string", example="홍길동"),
* @OA\Property(property="position_label", type="string", nullable=true, example="과장")
* )
*
* @OA\Schema(
* schema="OrgChartDepartmentNode",
* type="object",
*
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="name", type="string", example="경영지원팀"),
* @OA\Property(property="code", type="string", nullable=true, example="MGT"),
* @OA\Property(property="parent_id", type="integer", nullable=true),
* @OA\Property(property="sort_order", type="integer", example=1),
* @OA\Property(property="is_active", type="boolean", example=true),
* @OA\Property(property="orgchart_hidden", type="boolean", example=false),
* @OA\Property(property="children", type="array", @OA\Items(ref="#/components/schemas/OrgChartDepartmentNode")),
* @OA\Property(property="employees", type="array", @OA\Items(ref="#/components/schemas/OrgChartEmployee"))
* )
*
* @OA\Schema(
* schema="OrgChartStats",
* type="object",
*
* @OA\Property(property="total", type="integer", example=50),
* @OA\Property(property="assigned", type="integer", example=42),
* @OA\Property(property="unassigned", type="integer", example=8)
* )
*
* @OA\Schema(
* schema="OrgChartAssignRequest",
* type="object",
* required={"employee_id", "department_id"},
*
* @OA\Property(property="employee_id", type="integer", example=5, description="직원 프로필 ID"),
* @OA\Property(property="department_id", type="integer", example=3, description="배치할 부서 ID")
* )
*
* @OA\Schema(
* schema="OrgChartUnassignRequest",
* type="object",
* required={"employee_id"},
*
* @OA\Property(property="employee_id", type="integer", example=5, description="직원 프로필 ID")
* )
*
* @OA\Schema(
* schema="OrgChartReorderEmployeesRequest",
* type="object",
* required={"moves"},
*
* @OA\Property(
* property="moves",
* type="array",
*
* @OA\Items(
* type="object",
* required={"employee_id"},
*
* @OA\Property(property="employee_id", type="integer", example=5),
* @OA\Property(property="department_id", type="integer", nullable=true, example=3)
* )
* )
* )
*
* @OA\Schema(
* schema="OrgChartReorderDepartmentsRequest",
* type="object",
* required={"orders"},
*
* @OA\Property(
* property="orders",
* type="array",
*
* @OA\Items(
* type="object",
* required={"id", "parent_id", "sort_order"},
*
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="parent_id", type="integer", nullable=true, example=null),
* @OA\Property(property="sort_order", type="integer", example=0)
* )
* )
* )
*
* @OA\Schema(
* schema="OrgChartToggleHideRequest",
* type="object",
* required={"hidden"},
*
* @OA\Property(property="hidden", type="boolean", example=true, description="숨기기 여부")
* )
*/
class OrgChartApi
{
/**
* @OA\Get(
* path="/api/v1/org-chart",
* tags={"OrgChart"},
* summary="조직도 전체 조회 (부서 트리 + 직원 + 통계)",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Parameter(name="include_hidden", in="query", description="숨겨진 부서 포함 여부 (기본: true)", @OA\Schema(type="boolean")),
*
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(
* property="data",
* type="object",
* @OA\Property(property="company", type="object",
* @OA\Property(property="name", type="string", example="주일산업"),
* @OA\Property(property="ceo_name", type="string", example="홍길동")
* ),
* @OA\Property(property="departments", type="array", @OA\Items(ref="#/components/schemas/OrgChartDepartmentNode")),
* @OA\Property(property="hidden_departments", type="array", @OA\Items(type="object",
* @OA\Property(property="id", type="integer"),
* @OA\Property(property="name", type="string"),
* @OA\Property(property="code", type="string")
* )),
* @OA\Property(property="unassigned", type="array", @OA\Items(ref="#/components/schemas/OrgChartEmployee")),
* @OA\Property(property="stats", ref="#/components/schemas/OrgChartStats")
* )
* )
* )
* )
*/
public function index() {}
/**
* @OA\Get(
* path="/api/v1/org-chart/stats",
* tags={"OrgChart"},
* summary="조직도 통계 (전체/배치/미배치 인원)",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/OrgChartStats")
* )
* )
* )
*/
public function stats() {}
/**
* @OA\Get(
* path="/api/v1/org-chart/unassigned",
* tags={"OrgChart"},
* summary="미배치 직원 목록",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Parameter(name="q", in="query", description="이름 검색", @OA\Schema(type="string")),
*
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/OrgChartEmployee"))
* )
* )
* )
*/
public function unassigned() {}
/**
* @OA\Post(
* path="/api/v1/org-chart/assign",
* tags={"OrgChart"},
* summary="직원 부서 배치",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/OrgChartAssignRequest")
* ),
*
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", type="object",
* @OA\Property(property="employee_id", type="integer"),
* @OA\Property(property="department_id", type="integer")
* )
* )
* )
* )
*/
public function assign() {}
/**
* @OA\Post(
* path="/api/v1/org-chart/unassign",
* tags={"OrgChart"},
* summary="직원 미배치 처리",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/OrgChartUnassignRequest")
* ),
*
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", type="object",
* @OA\Property(property="employee_id", type="integer"),
* @OA\Property(property="department_id", type="integer", nullable=true)
* )
* )
* )
* )
*/
public function unassign() {}
/**
* @OA\Put(
* path="/api/v1/org-chart/reorder-employees",
* tags={"OrgChart"},
* summary="직원 일괄 배치/이동",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/OrgChartReorderEmployeesRequest")
* ),
*
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", type="object",
* @OA\Property(property="processed", type="integer", example=3)
* )
* )
* )
* )
*/
public function reorderEmployees() {}
/**
* @OA\Put(
* path="/api/v1/org-chart/reorder-departments",
* tags={"OrgChart"},
* summary="부서 순서/계층 일괄 변경",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/OrgChartReorderDepartmentsRequest")
* ),
*
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", type="object",
* @OA\Property(property="processed", type="integer", example=5)
* )
* )
* )
* )
*/
public function reorderDepartments() {}
/**
* @OA\Patch(
* path="/api/v1/org-chart/departments/{id}/toggle-hide",
* tags={"OrgChart"},
* summary="부서 숨기기/표시 토글",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, description="부서 ID", @OA\Schema(type="integer")),
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/OrgChartToggleHideRequest")
* ),
*
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", type="object",
* @OA\Property(property="id", type="integer"),
* @OA\Property(property="orgchart_hidden", type="boolean")
* )
* )
* )
* )
*/
public function toggleHide() {}
}

View File

@@ -22,6 +22,7 @@
use App\Http\Controllers\Api\V1\Construction\StructureReviewController;
use App\Http\Controllers\Api\V1\DepartmentController;
use App\Http\Controllers\Api\V1\EmployeeController;
use App\Http\Controllers\Api\V1\OrgChartController;
use App\Http\Controllers\Api\V1\LeaveController;
use App\Http\Controllers\Api\V1\LeavePolicyController;
use App\Http\Controllers\Api\V1\PositionController;
@@ -50,6 +51,18 @@
Route::delete('/{id}/permissions/{permission}', [DepartmentController::class, 'revokePermissions'])->name('v1.departments.permissions.revoke'); // 권한 제거(해당 메뉴 범위까지)
});
// OrgChart API (조직도 관리)
Route::prefix('org-chart')->group(function () {
Route::get('', [OrgChartController::class, 'index'])->name('v1.org-chart.index');
Route::get('/stats', [OrgChartController::class, 'stats'])->name('v1.org-chart.stats');
Route::get('/unassigned', [OrgChartController::class, 'unassigned'])->name('v1.org-chart.unassigned');
Route::post('/assign', [OrgChartController::class, 'assign'])->name('v1.org-chart.assign');
Route::post('/unassign', [OrgChartController::class, 'unassign'])->name('v1.org-chart.unassign');
Route::put('/reorder-employees', [OrgChartController::class, 'reorderEmployees'])->name('v1.org-chart.reorder-employees');
Route::put('/reorder-departments', [OrgChartController::class, 'reorderDepartments'])->name('v1.org-chart.reorder-departments');
Route::patch('/departments/{id}/toggle-hide', [OrgChartController::class, 'toggleHide'])->whereNumber('id')->name('v1.org-chart.toggle-hide');
});
// Position API (직급/직책 통합 관리)
Route::prefix('positions')->group(function () {
Route::get('', [PositionController::class, 'index'])->name('v1.positions.index');

View File

@@ -11,7 +11,7 @@
"servers": [
{
"url": "https://api.sam.kr/",
"description": "SAM API 서버"
"description": "SAM관리시스템 API 서버"
}
],
"paths": {
@@ -9577,9 +9577,15 @@
],
"properties": {
"code": {
"description": "LOT 코드",
"description": "코드 체계 (prod+spec)",
"type": "string",
"example": "RM260319"
"example": "RM"
},
"lot_no": {
"description": "LOT 번호",
"type": "string",
"example": "RM260319",
"nullable": true
},
"item_name": {
"type": "string",
@@ -36518,6 +36524,502 @@
]
}
},
"/api/v1/org-chart": {
"get": {
"tags": [
"OrgChart"
],
"summary": "조직도 전체 조회 (부서 트리 + 직원 + 통계)",
"operationId": "d92f1fc2da2664ef3543e4e1328d25e9",
"parameters": [
{
"name": "include_hidden",
"in": "query",
"description": "숨겨진 부서 포함 여부 (기본: true)",
"schema": {
"type": "boolean"
}
}
],
"responses": {
"200": {
"description": "성공",
"content": {
"application/json": {
"schema": {
"properties": {
"success": {
"type": "boolean",
"example": true
},
"message": {
"type": "string"
},
"data": {
"properties": {
"company": {
"properties": {
"name": {
"type": "string",
"example": "주일산업"
},
"ceo_name": {
"type": "string",
"example": "홍길동"
}
},
"type": "object"
},
"departments": {
"type": "array",
"items": {
"$ref": "#/components/schemas/OrgChartDepartmentNode"
}
},
"hidden_departments": {
"type": "array",
"items": {
"properties": {
"id": {
"type": "integer"
},
"name": {
"type": "string"
},
"code": {
"type": "string"
}
},
"type": "object"
}
},
"unassigned": {
"type": "array",
"items": {
"$ref": "#/components/schemas/OrgChartEmployee"
}
},
"stats": {
"$ref": "#/components/schemas/OrgChartStats"
}
},
"type": "object"
}
},
"type": "object"
}
}
}
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"BearerAuth": []
}
]
}
},
"/api/v1/org-chart/stats": {
"get": {
"tags": [
"OrgChart"
],
"summary": "조직도 통계 (전체/배치/미배치 인원)",
"operationId": "c00470fa81707bdf862c9eec4139d0ba",
"responses": {
"200": {
"description": "성공",
"content": {
"application/json": {
"schema": {
"properties": {
"success": {
"type": "boolean",
"example": true
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/OrgChartStats"
}
},
"type": "object"
}
}
}
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"BearerAuth": []
}
]
}
},
"/api/v1/org-chart/unassigned": {
"get": {
"tags": [
"OrgChart"
],
"summary": "미배치 직원 목록",
"operationId": "13121a2e9ccefcfbf7cc60c727522bc4",
"parameters": [
{
"name": "q",
"in": "query",
"description": "이름 검색",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "성공",
"content": {
"application/json": {
"schema": {
"properties": {
"success": {
"type": "boolean",
"example": true
},
"message": {
"type": "string"
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/OrgChartEmployee"
}
}
},
"type": "object"
}
}
}
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"BearerAuth": []
}
]
}
},
"/api/v1/org-chart/assign": {
"post": {
"tags": [
"OrgChart"
],
"summary": "직원 부서 배치",
"operationId": "6a7281807e859f10409469dda17fdd89",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OrgChartAssignRequest"
}
}
}
},
"responses": {
"200": {
"description": "성공",
"content": {
"application/json": {
"schema": {
"properties": {
"success": {
"type": "boolean",
"example": true
},
"message": {
"type": "string"
},
"data": {
"properties": {
"employee_id": {
"type": "integer"
},
"department_id": {
"type": "integer"
}
},
"type": "object"
}
},
"type": "object"
}
}
}
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"BearerAuth": []
}
]
}
},
"/api/v1/org-chart/unassign": {
"post": {
"tags": [
"OrgChart"
],
"summary": "직원 미배치 처리",
"operationId": "387e1c87037f9bf708b7255c35f5e86c",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OrgChartUnassignRequest"
}
}
}
},
"responses": {
"200": {
"description": "성공",
"content": {
"application/json": {
"schema": {
"properties": {
"success": {
"type": "boolean",
"example": true
},
"message": {
"type": "string"
},
"data": {
"properties": {
"employee_id": {
"type": "integer"
},
"department_id": {
"type": "integer",
"nullable": true
}
},
"type": "object"
}
},
"type": "object"
}
}
}
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"BearerAuth": []
}
]
}
},
"/api/v1/org-chart/reorder-employees": {
"put": {
"tags": [
"OrgChart"
],
"summary": "직원 일괄 배치/이동",
"operationId": "7af302b074864287d8f4b09af4e27c9c",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OrgChartReorderEmployeesRequest"
}
}
}
},
"responses": {
"200": {
"description": "성공",
"content": {
"application/json": {
"schema": {
"properties": {
"success": {
"type": "boolean",
"example": true
},
"message": {
"type": "string"
},
"data": {
"properties": {
"processed": {
"type": "integer",
"example": 3
}
},
"type": "object"
}
},
"type": "object"
}
}
}
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"BearerAuth": []
}
]
}
},
"/api/v1/org-chart/reorder-departments": {
"put": {
"tags": [
"OrgChart"
],
"summary": "부서 순서/계층 일괄 변경",
"operationId": "da0aa294c8ebf3ab4f7550f56ca901f3",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OrgChartReorderDepartmentsRequest"
}
}
}
},
"responses": {
"200": {
"description": "성공",
"content": {
"application/json": {
"schema": {
"properties": {
"success": {
"type": "boolean",
"example": true
},
"message": {
"type": "string"
},
"data": {
"properties": {
"processed": {
"type": "integer",
"example": 5
}
},
"type": "object"
}
},
"type": "object"
}
}
}
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"BearerAuth": []
}
]
}
},
"/api/v1/org-chart/departments/{id}/toggle-hide": {
"patch": {
"tags": [
"OrgChart"
],
"summary": "부서 숨기기/표시 토글",
"operationId": "b3913a80123a5bc5b8f7e4d07beed9c8",
"parameters": [
{
"name": "id",
"in": "path",
"description": "부서 ID",
"required": true,
"schema": {
"type": "integer"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OrgChartToggleHideRequest"
}
}
}
},
"responses": {
"200": {
"description": "성공",
"content": {
"application/json": {
"schema": {
"properties": {
"success": {
"type": "boolean",
"example": true
},
"message": {
"type": "string"
},
"data": {
"properties": {
"id": {
"type": "integer"
},
"orgchart_hidden": {
"type": "boolean"
}
},
"type": "object"
}
},
"type": "object"
}
}
}
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"BearerAuth": []
}
]
}
},
"/api/v1/payments": {
"get": {
"tags": [
@@ -49474,14 +49976,9 @@
{
"name": "delivery_method",
"in": "query",
"description": "배송방식",
"description": "배송방식 (common_codes delivery_method 참조)",
"schema": {
"type": "string",
"enum": [
"pickup",
"direct",
"logistics"
]
"type": "string"
}
},
{
@@ -64904,9 +65401,15 @@
"example": 431
},
"code": {
"description": "LOT 코드: {제품Code}{종류Code}{YYMMDD}",
"description": "코드 체계 (제품Code+종류Code)",
"type": "string",
"example": "RS260319"
"example": "RS"
},
"lot_no": {
"description": "LOT 번호 (code+날짜+일련번호)",
"type": "string",
"example": "RS260319",
"nullable": true
},
"legacy_code": {
"description": "이전 코드 (items 기반)",
@@ -80193,6 +80696,204 @@
},
"type": "object"
},
"OrgChartEmployee": {
"properties": {
"id": {
"description": "프로필 ID",
"type": "integer",
"example": 5
},
"user_id": {
"type": "integer",
"example": 3
},
"department_id": {
"type": "integer",
"example": 1,
"nullable": true
},
"display_name": {
"type": "string",
"example": "홍길동"
},
"position_label": {
"type": "string",
"example": "과장",
"nullable": true
}
},
"type": "object"
},
"OrgChartDepartmentNode": {
"properties": {
"id": {
"type": "integer",
"example": 1
},
"name": {
"type": "string",
"example": "경영지원팀"
},
"code": {
"type": "string",
"example": "MGT",
"nullable": true
},
"parent_id": {
"type": "integer",
"nullable": true
},
"sort_order": {
"type": "integer",
"example": 1
},
"is_active": {
"type": "boolean",
"example": true
},
"orgchart_hidden": {
"type": "boolean",
"example": false
},
"children": {
"type": "array",
"items": {
"$ref": "#/components/schemas/OrgChartDepartmentNode"
}
},
"employees": {
"type": "array",
"items": {
"$ref": "#/components/schemas/OrgChartEmployee"
}
}
},
"type": "object"
},
"OrgChartStats": {
"properties": {
"total": {
"type": "integer",
"example": 50
},
"assigned": {
"type": "integer",
"example": 42
},
"unassigned": {
"type": "integer",
"example": 8
}
},
"type": "object"
},
"OrgChartAssignRequest": {
"required": [
"employee_id",
"department_id"
],
"properties": {
"employee_id": {
"description": "직원 프로필 ID",
"type": "integer",
"example": 5
},
"department_id": {
"description": "배치할 부서 ID",
"type": "integer",
"example": 3
}
},
"type": "object"
},
"OrgChartUnassignRequest": {
"required": [
"employee_id"
],
"properties": {
"employee_id": {
"description": "직원 프로필 ID",
"type": "integer",
"example": 5
}
},
"type": "object"
},
"OrgChartReorderEmployeesRequest": {
"required": [
"moves"
],
"properties": {
"moves": {
"type": "array",
"items": {
"required": [
"employee_id"
],
"properties": {
"employee_id": {
"type": "integer",
"example": 5
},
"department_id": {
"type": "integer",
"example": 3,
"nullable": true
}
},
"type": "object"
}
}
},
"type": "object"
},
"OrgChartReorderDepartmentsRequest": {
"required": [
"orders"
],
"properties": {
"orders": {
"type": "array",
"items": {
"required": [
"id",
"parent_id",
"sort_order"
],
"properties": {
"id": {
"type": "integer",
"example": 1
},
"parent_id": {
"type": "integer",
"example": null,
"nullable": true
},
"sort_order": {
"type": "integer",
"example": 0
}
},
"type": "object"
}
}
},
"type": "object"
},
"OrgChartToggleHideRequest": {
"required": [
"hidden"
],
"properties": {
"hidden": {
"description": "숨기기 여부",
"type": "boolean",
"example": true
}
},
"type": "object"
},
"Payment": {
"description": "결제 정보",
"properties": {
@@ -87955,13 +88656,8 @@
"example": "보통"
},
"delivery_method": {
"description": "배송방식",
"description": "배송방식 (common_codes delivery_method 참조)",
"type": "string",
"enum": [
"pickup",
"direct",
"logistics"
],
"example": "pickup"
},
"delivery_method_label": {
@@ -88371,13 +89067,8 @@
"example": "normal"
},
"delivery_method": {
"description": "배송방식",
"description": "배송방식 (common_codes delivery_method 참조)",
"type": "string",
"enum": [
"pickup",
"direct",
"logistics"
],
"example": "pickup"
},
"client_id": {
@@ -95014,6 +95705,10 @@
"name": "Order",
"description": "수주관리 API"
},
{
"name": "OrgChart",
"description": "조직도 관리"
},
{
"name": "Payments",
"description": "결제 관리"