Files
sam-api/app/Services/OrgChartService.php
김보곤 902f681f6e 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)
2026-03-22 17:22:12 +09:00

309 lines
9.4 KiB
PHP

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