diff --git a/app/Http/Controllers/Api/V1/TenantStatFieldController.php b/app/Http/Controllers/Api/V1/TenantStatFieldController.php new file mode 100644 index 0000000..c49b28b --- /dev/null +++ b/app/Http/Controllers/Api/V1/TenantStatFieldController.php @@ -0,0 +1,75 @@ +service->index($request->all()); + }, __('message.tenant_stat_field.fetched')); + } + + // POST /tenant-stat-fields + public function store(TenantStatFieldStoreRequest $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->store($request->validated()); + }, __('message.tenant_stat_field.created')); + } + + // GET /tenant-stat-fields/{id} + public function show(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->show($id); + }, __('message.tenant_stat_field.fetched')); + } + + // PATCH /tenant-stat-fields/{id} + public function update(int $id, TenantStatFieldUpdateRequest $request) + { + return ApiResponse::handle(function () use ($id, $request) { + return $this->service->update($id, $request->validated()); + }, __('message.tenant_stat_field.updated')); + } + + // DELETE /tenant-stat-fields/{id} + public function destroy(int $id) + { + return ApiResponse::handle(function () use ($id) { + $this->service->destroy($id); + + return 'success'; + }, __('message.tenant_stat_field.deleted')); + } + + // POST /tenant-stat-fields/reorder + public function reorder(Request $request) + { + return ApiResponse::handle(function () use ($request) { + $this->service->reorder($request->input()); + + return 'success'; + }, __('message.tenant_stat_field.reordered')); + } + + // PUT /tenant-stat-fields/bulk-upsert + public function bulkUpsert(Request $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->bulkUpsert($request->input('items', [])); + }, __('message.tenant_stat_field.bulk_upsert')); + } +} diff --git a/app/Http/Requests/TenantStatField/TenantStatFieldStoreRequest.php b/app/Http/Requests/TenantStatField/TenantStatFieldStoreRequest.php new file mode 100644 index 0000000..1996a48 --- /dev/null +++ b/app/Http/Requests/TenantStatField/TenantStatFieldStoreRequest.php @@ -0,0 +1,28 @@ + 'required|string|max:50', + 'field_key' => 'required|string|max:100', + 'field_name' => 'required|string|max:100', + 'field_type' => 'required|string|max:20', + 'aggregation_types' => 'nullable|array', + 'aggregation_types.*' => 'string|in:avg,sum,min,max,count', + 'is_critical' => 'nullable|boolean', + 'display_order' => 'nullable|integer|min:0', + 'description' => 'nullable|string', + ]; + } +} diff --git a/app/Http/Requests/TenantStatField/TenantStatFieldUpdateRequest.php b/app/Http/Requests/TenantStatField/TenantStatFieldUpdateRequest.php new file mode 100644 index 0000000..580a902 --- /dev/null +++ b/app/Http/Requests/TenantStatField/TenantStatFieldUpdateRequest.php @@ -0,0 +1,28 @@ + 'sometimes|string|max:50', + 'field_key' => 'sometimes|string|max:100', + 'field_name' => 'sometimes|string|max:100', + 'field_type' => 'sometimes|string|max:20', + 'aggregation_types' => 'nullable|array', + 'aggregation_types.*' => 'string|in:avg,sum,min,max,count', + 'is_critical' => 'sometimes|boolean', + 'display_order' => 'sometimes|integer|min:0', + 'description' => 'nullable|string', + ]; + } +} diff --git a/app/Services/TenantStatFieldService.php b/app/Services/TenantStatFieldService.php new file mode 100644 index 0000000..ebe922d --- /dev/null +++ b/app/Services/TenantStatFieldService.php @@ -0,0 +1,237 @@ +tenantId(); + + $size = (int) ($params['size'] ?? 20); + $targetTable = $params['target_table'] ?? null; + $isCritical = $params['is_critical'] ?? null; + $aggregationType = $params['aggregation_type'] ?? null; + + $query = TenantStatField::query() + ->where('tenant_id', $tenantId); + + // 필터: 대상 테이블 + if ($targetTable) { + $query->forTable($targetTable); + } + + // 필터: 중요 필드만 + if ($isCritical !== null && $isCritical !== '') { + $query->critical(); + } + + // 필터: 특정 집계 함수 포함 + if ($aggregationType) { + $query->withAggregation($aggregationType); + } + + // 정렬: display_order, field_name + $query->ordered(); + + return $query->paginate($size); + } + + public function store(array $data) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + // FormRequest에서 이미 검증됨 + $payload = $data; + + // tenant_id + target_table + field_key 유니크 검증 + $exists = TenantStatField::query() + ->where('tenant_id', $tenantId) + ->where('target_table', $payload['target_table']) + ->where('field_key', $payload['field_key']) + ->exists(); + if ($exists) { + throw new BadRequestHttpException(__('error.duplicate_key')); + } + + $payload['tenant_id'] = $tenantId; + $payload['is_critical'] = $payload['is_critical'] ?? false; + $payload['display_order'] = $payload['display_order'] ?? 0; + $payload['created_by'] = $userId; + + return TenantStatField::create($payload); + } + + public function show(int $id) + { + $tenantId = $this->tenantId(); + + $field = TenantStatField::query() + ->where('tenant_id', $tenantId) + ->find($id); + + if (! $field) { + throw new BadRequestHttpException(__('error.not_found')); + } + + return $field; + } + + public function update(int $id, array $data) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $field = TenantStatField::query() + ->where('tenant_id', $tenantId) + ->find($id); + + if (! $field) { + throw new BadRequestHttpException(__('error.not_found')); + } + + // FormRequest에서 이미 검증됨 + $payload = $data; + + // target_table 또는 field_key 변경 시 유니크 검증 + $targetTableChanged = isset($payload['target_table']) && $payload['target_table'] !== $field->target_table; + $fieldKeyChanged = isset($payload['field_key']) && $payload['field_key'] !== $field->field_key; + + if ($targetTableChanged || $fieldKeyChanged) { + $newTargetTable = $payload['target_table'] ?? $field->target_table; + $newFieldKey = $payload['field_key'] ?? $field->field_key; + + $dup = TenantStatField::query() + ->where('tenant_id', $tenantId) + ->where('target_table', $newTargetTable) + ->where('field_key', $newFieldKey) + ->where('id', '!=', $id) + ->exists(); + if ($dup) { + throw new BadRequestHttpException(__('error.duplicate_key')); + } + } + + $payload['updated_by'] = $userId; + $field->update($payload); + + return $field->refresh(); + } + + public function destroy(int $id): void + { + $tenantId = $this->tenantId(); + + $field = TenantStatField::query() + ->where('tenant_id', $tenantId) + ->find($id); + + if (! $field) { + throw new BadRequestHttpException(__('error.not_found')); + } + + $field->delete(); + } + + public function reorder(array $items): void + { + $tenantId = $this->tenantId(); + + $rows = $items['items'] ?? $items; + if (! is_array($rows)) { + throw new BadRequestHttpException(__('error.invalid_payload')); + } + + DB::transaction(function () use ($tenantId, $rows) { + foreach ($rows as $row) { + if (! isset($row['id'], $row['display_order'])) { + continue; + } + + TenantStatField::query() + ->where('tenant_id', $tenantId) + ->where('id', $row['id']) + ->update(['display_order' => (int) $row['display_order']]); + } + }); + } + + public function bulkUpsert(array $items): array + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + if (! is_array($items) || empty($items)) { + throw new BadRequestHttpException(__('error.empty_items')); + } + + $result = ['created' => 0, 'updated' => 0]; + + DB::transaction(function () use ($tenantId, $userId, $items, &$result) { + foreach ($items as $payload) { + // 기본 검증 + if (! isset($payload['target_table'], $payload['field_key'], $payload['field_name'], $payload['field_type'])) { + throw new BadRequestHttpException(__('error.invalid_payload')); + } + + if (! empty($payload['id'])) { + // 수정 + $model = TenantStatField::query() + ->where('tenant_id', $tenantId) + ->find($payload['id']); + if (! $model) { + throw new BadRequestHttpException(__('error.not_found')); + } + + // target_table 또는 field_key 변경 시 유니크 검증 + $targetTableChanged = isset($payload['target_table']) && $payload['target_table'] !== $model->target_table; + $fieldKeyChanged = isset($payload['field_key']) && $payload['field_key'] !== $model->field_key; + + if ($targetTableChanged || $fieldKeyChanged) { + $newTargetTable = $payload['target_table'] ?? $model->target_table; + $newFieldKey = $payload['field_key'] ?? $model->field_key; + + $dup = TenantStatField::query() + ->where('tenant_id', $tenantId) + ->where('target_table', $newTargetTable) + ->where('field_key', $newFieldKey) + ->where('id', '!=', $payload['id']) + ->exists(); + if ($dup) { + throw new BadRequestHttpException(__('error.duplicate_key')); + } + } + + $payload['updated_by'] = $userId; + $model->update($payload); + $result['updated']++; + } else { + // 신규 생성 + $dup = TenantStatField::query() + ->where('tenant_id', $tenantId) + ->where('target_table', $payload['target_table']) + ->where('field_key', $payload['field_key']) + ->exists(); + if ($dup) { + throw new BadRequestHttpException(__('error.duplicate_key')); + } + + $payload['tenant_id'] = $tenantId; + $payload['is_critical'] = $payload['is_critical'] ?? false; + $payload['display_order'] = $payload['display_order'] ?? 0; + $payload['created_by'] = $userId; + + TenantStatField::create($payload); + $result['created']++; + } + } + }); + + return $result; + } +} diff --git a/app/Swagger/v1/TenantStatFieldApi.php b/app/Swagger/v1/TenantStatFieldApi.php new file mode 100644 index 0000000..ff72bc4 --- /dev/null +++ b/app/Swagger/v1/TenantStatFieldApi.php @@ -0,0 +1,368 @@ + '테넌트가 복구되었습니다.', ], + 'tenant_stat_field' => [ + 'fetched' => '통계 필드를 조회했습니다.', + 'created' => '통계 필드가 생성되었습니다.', + 'updated' => '통계 필드가 수정되었습니다.', + 'deleted' => '통계 필드가 삭제되었습니다.', + 'reordered' => '통계 필드 정렬이 변경되었습니다.', + 'bulk_upsert' => '통계 필드가 일괄 저장되었습니다.', + ], + // 파일 관리 'file' => [ 'uploaded' => '파일이 업로드되었습니다.', diff --git a/routes/api.php b/routes/api.php index 76c9adc..07f0ee9 100644 --- a/routes/api.php +++ b/routes/api.php @@ -105,6 +105,17 @@ Route::put('/restore/{tenant_id}', [TenantController::class, 'restore'])->name('v1.tenant.restore'); // 테넌트 복구 }); + // Tenant Statistics Field API + Route::prefix('tenant-stat-fields')->group(function () { + Route::get('/', [TenantStatFieldController::class, 'index'])->name('v1.tenant-stat-fields.index'); // 목록 조회 + Route::post('/', [TenantStatFieldController::class, 'store'])->name('v1.tenant-stat-fields.store'); // 생성 + Route::get('/{id}', [TenantStatFieldController::class, 'show'])->name('v1.tenant-stat-fields.show'); // 단건 조회 + Route::patch('/{id}', [TenantStatFieldController::class, 'update'])->name('v1.tenant-stat-fields.update'); // 수정 + Route::delete('/{id}', [TenantStatFieldController::class, 'destroy'])->name('v1.tenant-stat-fields.destroy'); // 삭제 + Route::post('/reorder', [TenantStatFieldController::class, 'reorder'])->name('v1.tenant-stat-fields.reorder'); // 정렬 변경 + Route::put('/bulk-upsert', [TenantStatFieldController::class, 'bulkUpsert'])->name('v1.tenant-stat-fields.bulk-upsert'); // 일괄 저장 + }); + // Menu API Route::middleware(['perm.map', 'permission'])->prefix('menus')->group(function () { Route::get('/', [MenuController::class, 'index'])->name('v1.menus.index');