From fb06975d972f4493061d0f7a5df1d1d9b317cc83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sat, 31 Jan 2026 09:39:55 +0900 Subject: [PATCH] =?UTF-8?q?feat:=EB=AC=B8=EC=84=9C=EA=B4=80=EB=A6=AC=20Pha?= =?UTF-8?q?se=204.1=20-=20DocumentTemplate=20API=20+=20=EA=B2=B0=EC=9E=AC?= =?UTF-8?q?=20=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20=ED=99=9C?= =?UTF-8?q?=EC=84=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DocumentTemplate 모델 6개 생성 (Template, ApprovalLine, BasicField, Section, SectionItem, Column) - DocumentTemplateService (list/show) + DocumentTemplateController (index/show) - GET /v1/document-templates, GET /v1/document-templates/{id} 라우트 - DocumentTemplateApi.php Swagger (7개 스키마, 2개 엔드포인트) - Document 결재 워크플로우 4개 엔드포인트 활성화 (submit/approve/reject/cancel) - ApproveRequest, RejectRequest FormRequest 생성 - DocumentApi.php Swagger에 결재 엔드포인트 4개 추가 - Document.template() 참조 경로 수정 (DocumentTemplate → Documents 네임스페이스) Co-Authored-By: Claude Opus 4.5 --- .../Api/V1/Documents/DocumentController.php | 52 ++++- .../Documents/DocumentTemplateController.php | 36 ++++ app/Http/Requests/Document/ApproveRequest.php | 20 ++ app/Http/Requests/Document/RejectRequest.php | 20 ++ .../DocumentTemplate/IndexRequest.php | 26 +++ app/Models/Documents/Document.php | 2 +- app/Models/Documents/DocumentTemplate.php | 113 ++++++++++ .../DocumentTemplateApprovalLine.php | 38 ++++ .../Documents/DocumentTemplateBasicField.php | 38 ++++ .../Documents/DocumentTemplateColumn.php | 43 ++++ .../Documents/DocumentTemplateSection.php | 43 ++++ .../Documents/DocumentTemplateSectionItem.php | 44 ++++ app/Services/DocumentTemplateService.php | 67 ++++++ app/Swagger/v1/DocumentApi.php | 147 +++++++++++++ app/Swagger/v1/DocumentTemplateApi.php | 194 ++++++++++++++++++ routes/api/v1/documents.php | 23 ++- 16 files changed, 893 insertions(+), 13 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/Documents/DocumentTemplateController.php create mode 100644 app/Http/Requests/Document/ApproveRequest.php create mode 100644 app/Http/Requests/Document/RejectRequest.php create mode 100644 app/Http/Requests/DocumentTemplate/IndexRequest.php create mode 100644 app/Models/Documents/DocumentTemplate.php create mode 100644 app/Models/Documents/DocumentTemplateApprovalLine.php create mode 100644 app/Models/Documents/DocumentTemplateBasicField.php create mode 100644 app/Models/Documents/DocumentTemplateColumn.php create mode 100644 app/Models/Documents/DocumentTemplateSection.php create mode 100644 app/Models/Documents/DocumentTemplateSectionItem.php create mode 100644 app/Services/DocumentTemplateService.php create mode 100644 app/Swagger/v1/DocumentTemplateApi.php diff --git a/app/Http/Controllers/Api/V1/Documents/DocumentController.php b/app/Http/Controllers/Api/V1/Documents/DocumentController.php index 639031f..9cbc832 100644 --- a/app/Http/Controllers/Api/V1/Documents/DocumentController.php +++ b/app/Http/Controllers/Api/V1/Documents/DocumentController.php @@ -4,7 +4,9 @@ use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; +use App\Http\Requests\Document\ApproveRequest; use App\Http\Requests\Document\IndexRequest; +use App\Http\Requests\Document\RejectRequest; use App\Http\Requests\Document\StoreRequest; use App\Http\Requests\Document\UpdateRequest; use App\Services\DocumentService; @@ -70,10 +72,50 @@ public function destroy(int $id): JsonResponse } // ========================================================================= - // 결재 관련 메서드 (보류 - 기존 시스템 연동 필요) + // 결재 워크플로우 // ========================================================================= - // public function submit(int $id): JsonResponse - // public function approve(int $id, ApproveRequest $request): JsonResponse - // public function reject(int $id, RejectRequest $request): JsonResponse - // public function cancel(int $id): JsonResponse + + /** + * 결재 제출 (DRAFT → PENDING) + * POST /v1/documents/{id}/submit + */ + public function submit(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->submit($id); + }, __('message.updated')); + } + + /** + * 결재 승인 + * POST /v1/documents/{id}/approve + */ + public function approve(int $id, ApproveRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($id, $request) { + return $this->service->approve($id, $request->validated()['comment'] ?? null); + }, __('message.updated')); + } + + /** + * 결재 반려 + * POST /v1/documents/{id}/reject + */ + public function reject(int $id, RejectRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($id, $request) { + return $this->service->reject($id, $request->validated()['comment']); + }, __('message.updated')); + } + + /** + * 결재 취소/회수 + * POST /v1/documents/{id}/cancel + */ + public function cancel(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->cancel($id); + }, __('message.updated')); + } } diff --git a/app/Http/Controllers/Api/V1/Documents/DocumentTemplateController.php b/app/Http/Controllers/Api/V1/Documents/DocumentTemplateController.php new file mode 100644 index 0000000..3173881 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Documents/DocumentTemplateController.php @@ -0,0 +1,36 @@ +service->list($request->validated()); + }, __('message.fetched')); + } + + /** + * 양식 상세 조회 + * GET /v1/document-templates/{id} + */ + public function show(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->show($id); + }, __('message.fetched')); + } +} diff --git a/app/Http/Requests/Document/ApproveRequest.php b/app/Http/Requests/Document/ApproveRequest.php new file mode 100644 index 0000000..83da347 --- /dev/null +++ b/app/Http/Requests/Document/ApproveRequest.php @@ -0,0 +1,20 @@ + 'nullable|string|max:500', + ]; + } +} diff --git a/app/Http/Requests/Document/RejectRequest.php b/app/Http/Requests/Document/RejectRequest.php new file mode 100644 index 0000000..e2946ff --- /dev/null +++ b/app/Http/Requests/Document/RejectRequest.php @@ -0,0 +1,20 @@ + 'required|string|max:500', + ]; + } +} diff --git a/app/Http/Requests/DocumentTemplate/IndexRequest.php b/app/Http/Requests/DocumentTemplate/IndexRequest.php new file mode 100644 index 0000000..260c6cd --- /dev/null +++ b/app/Http/Requests/DocumentTemplate/IndexRequest.php @@ -0,0 +1,26 @@ + 'nullable|boolean', + 'category' => 'nullable|string|max:50', + 'search' => 'nullable|string|max:100', + 'sort_by' => 'nullable|string|in:created_at,name,category', + 'sort_dir' => 'nullable|string|in:asc,desc', + 'per_page' => 'nullable|integer|min:1|max:100', + 'page' => 'nullable|integer|min:1', + ]; + } +} diff --git a/app/Models/Documents/Document.php b/app/Models/Documents/Document.php index 6f3b2c2..1f5b4ed 100644 --- a/app/Models/Documents/Document.php +++ b/app/Models/Documents/Document.php @@ -96,7 +96,7 @@ class Document extends Model */ public function template(): BelongsTo { - return $this->belongsTo(\App\Models\DocumentTemplate::class, 'template_id'); + return $this->belongsTo(DocumentTemplate::class, 'template_id'); } /** diff --git a/app/Models/Documents/DocumentTemplate.php b/app/Models/Documents/DocumentTemplate.php new file mode 100644 index 0000000..a1dd054 --- /dev/null +++ b/app/Models/Documents/DocumentTemplate.php @@ -0,0 +1,113 @@ + 'array', + 'is_active' => 'boolean', + ]; + + // ========================================================================= + // Relationships + // ========================================================================= + + /** + * 결재라인 + */ + public function approvalLines(): HasMany + { + return $this->hasMany(DocumentTemplateApprovalLine::class, 'template_id') + ->orderBy('sort_order'); + } + + /** + * 기본 필드 + */ + public function basicFields(): HasMany + { + return $this->hasMany(DocumentTemplateBasicField::class, 'template_id') + ->orderBy('sort_order'); + } + + /** + * 검사 기준서 섹션 + */ + public function sections(): HasMany + { + return $this->hasMany(DocumentTemplateSection::class, 'template_id') + ->orderBy('sort_order'); + } + + /** + * 테이블 컬럼 + */ + public function columns(): HasMany + { + return $this->hasMany(DocumentTemplateColumn::class, 'template_id') + ->orderBy('sort_order'); + } + + // ========================================================================= + // Scopes + // ========================================================================= + + /** + * 활성 양식만 + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * 카테고리 필터 + */ + public function scopeCategory($query, string $category) + { + return $query->where('category', $category); + } +} diff --git a/app/Models/Documents/DocumentTemplateApprovalLine.php b/app/Models/Documents/DocumentTemplateApprovalLine.php new file mode 100644 index 0000000..b6b274b --- /dev/null +++ b/app/Models/Documents/DocumentTemplateApprovalLine.php @@ -0,0 +1,38 @@ + 'integer', + ]; + + public function template(): BelongsTo + { + return $this->belongsTo(DocumentTemplate::class, 'template_id'); + } +} diff --git a/app/Models/Documents/DocumentTemplateBasicField.php b/app/Models/Documents/DocumentTemplateBasicField.php new file mode 100644 index 0000000..c2e22c6 --- /dev/null +++ b/app/Models/Documents/DocumentTemplateBasicField.php @@ -0,0 +1,38 @@ + 'integer', + ]; + + public function template(): BelongsTo + { + return $this->belongsTo(DocumentTemplate::class, 'template_id'); + } +} diff --git a/app/Models/Documents/DocumentTemplateColumn.php b/app/Models/Documents/DocumentTemplateColumn.php new file mode 100644 index 0000000..af39ef2 --- /dev/null +++ b/app/Models/Documents/DocumentTemplateColumn.php @@ -0,0 +1,43 @@ + 'array', + 'sort_order' => 'integer', + ]; + + public function template(): BelongsTo + { + return $this->belongsTo(DocumentTemplate::class, 'template_id'); + } +} diff --git a/app/Models/Documents/DocumentTemplateSection.php b/app/Models/Documents/DocumentTemplateSection.php new file mode 100644 index 0000000..1346f35 --- /dev/null +++ b/app/Models/Documents/DocumentTemplateSection.php @@ -0,0 +1,43 @@ + 'integer', + ]; + + public function template(): BelongsTo + { + return $this->belongsTo(DocumentTemplate::class, 'template_id'); + } + + public function items(): HasMany + { + return $this->hasMany(DocumentTemplateSectionItem::class, 'section_id') + ->orderBy('sort_order'); + } +} diff --git a/app/Models/Documents/DocumentTemplateSectionItem.php b/app/Models/Documents/DocumentTemplateSectionItem.php new file mode 100644 index 0000000..b61fd5e --- /dev/null +++ b/app/Models/Documents/DocumentTemplateSectionItem.php @@ -0,0 +1,44 @@ + 'integer', + ]; + + public function section(): BelongsTo + { + return $this->belongsTo(DocumentTemplateSection::class, 'section_id'); + } +} diff --git a/app/Services/DocumentTemplateService.php b/app/Services/DocumentTemplateService.php new file mode 100644 index 0000000..bbce2c5 --- /dev/null +++ b/app/Services/DocumentTemplateService.php @@ -0,0 +1,67 @@ +tenantId(); + + $query = DocumentTemplate::query() + ->where('tenant_id', $tenantId) + ->with(['approvalLines', 'basicFields']); + + // 활성 상태 필터 + if (isset($params['is_active'])) { + $query->where('is_active', filter_var($params['is_active'], FILTER_VALIDATE_BOOLEAN)); + } + + // 카테고리 필터 + if (! empty($params['category'])) { + $query->where('category', $params['category']); + } + + // 검색 (양식명) + if (! empty($params['search'])) { + $search = $params['search']; + $query->where(function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('title', 'like', "%{$search}%"); + }); + } + + // 정렬 + $sortBy = $params['sort_by'] ?? 'created_at'; + $sortDir = $params['sort_dir'] ?? 'desc'; + $query->orderBy($sortBy, $sortDir); + + $perPage = $params['per_page'] ?? 20; + + return $query->paginate($perPage); + } + + /** + * 양식 상세 조회 (전체 관계 포함) + */ + public function show(int $id): DocumentTemplate + { + $tenantId = $this->tenantId(); + + return DocumentTemplate::query() + ->where('tenant_id', $tenantId) + ->with([ + 'approvalLines', + 'basicFields', + 'sections.items', + 'columns', + ]) + ->findOrFail($id); + } +} diff --git a/app/Swagger/v1/DocumentApi.php b/app/Swagger/v1/DocumentApi.php index fa6383c..5c47d30 100644 --- a/app/Swagger/v1/DocumentApi.php +++ b/app/Swagger/v1/DocumentApi.php @@ -334,4 +334,151 @@ public function update() {} * ) */ public function destroy() {} + + // ========================================================================= + // 결재 워크플로우 + // ========================================================================= + + /** + * @OA\Post( + * path="/api/v1/documents/{id}/submit", + * tags={"Documents"}, + * summary="결재 제출", + * description="DRAFT 또는 REJECTED 상태의 문서를 결재 요청합니다 (PENDING 상태로 변경).", + * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, + * + * @OA\Parameter(name="id", in="path", required=true, description="문서 ID", @OA\Schema(type="integer")), + * + * @OA\Response( + * response=200, + * description="결재 제출 성공", + * + * @OA\JsonContent( + * allOf={ + * + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Document")) + * } + * ) + * ), + * + * @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=404, description="문서를 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function submit() {} + + /** + * @OA\Post( + * path="/api/v1/documents/{id}/approve", + * tags={"Documents"}, + * summary="결재 승인", + * description="현재 사용자의 결재 단계를 승인합니다. 모든 단계 완료 시 문서가 APPROVED 상태로 변경됩니다.", + * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, + * + * @OA\Parameter(name="id", in="path", required=true, description="문서 ID", @OA\Schema(type="integer")), + * + * @OA\RequestBody( + * required=false, + * + * @OA\JsonContent( + * + * @OA\Property(property="comment", type="string", example="승인합니다.", nullable=true, description="결재 의견") + * ) + * ), + * + * @OA\Response( + * response=200, + * description="승인 성공", + * + * @OA\JsonContent( + * allOf={ + * + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Document")) + * } + * ) + * ), + * + * @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=404, description="문서를 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function approve() {} + + /** + * @OA\Post( + * path="/api/v1/documents/{id}/reject", + * tags={"Documents"}, + * summary="결재 반려", + * description="현재 사용자의 결재 단계를 반려합니다. 문서가 REJECTED 상태로 변경됩니다.", + * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, + * + * @OA\Parameter(name="id", in="path", required=true, description="문서 ID", @OA\Schema(type="integer")), + * + * @OA\RequestBody( + * required=true, + * + * @OA\JsonContent( + * required={"comment"}, + * + * @OA\Property(property="comment", type="string", example="검사 기준 미달로 반려합니다.", description="반려 사유 (필수)") + * ) + * ), + * + * @OA\Response( + * response=200, + * description="반려 성공", + * + * @OA\JsonContent( + * allOf={ + * + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Document")) + * } + * ) + * ), + * + * @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=404, description="문서를 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function reject() {} + + /** + * @OA\Post( + * path="/api/v1/documents/{id}/cancel", + * tags={"Documents"}, + * summary="결재 취소/회수", + * description="작성자만 DRAFT 또는 PENDING 상태의 문서를 취소할 수 있습니다. CANCELLED 상태로 변경됩니다.", + * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, + * + * @OA\Parameter(name="id", in="path", required=true, description="문서 ID", @OA\Schema(type="integer")), + * + * @OA\Response( + * response=200, + * description="취소 성공", + * + * @OA\JsonContent( + * allOf={ + * + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Document")) + * } + * ) + * ), + * + * @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=404, description="문서를 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function cancel() {} } diff --git a/app/Swagger/v1/DocumentTemplateApi.php b/app/Swagger/v1/DocumentTemplateApi.php new file mode 100644 index 0000000..2471ab0 --- /dev/null +++ b/app/Swagger/v1/DocumentTemplateApi.php @@ -0,0 +1,194 @@ +group(function () { + Route::get('/', [DocumentTemplateController::class, 'index'])->name('v1.document-templates.index'); + Route::get('/{id}', [DocumentTemplateController::class, 'show'])->whereNumber('id')->name('v1.document-templates.show'); +}); + +// 문서 CRUD + 결재 Route::prefix('documents')->group(function () { // 문서 CRUD Route::get('/', [DocumentController::class, 'index'])->name('v1.documents.index'); @@ -18,9 +27,9 @@ Route::patch('/{id}', [DocumentController::class, 'update'])->whereNumber('id')->name('v1.documents.update'); Route::delete('/{id}', [DocumentController::class, 'destroy'])->whereNumber('id')->name('v1.documents.destroy'); - // 결재 워크플로우 (보류 - 기존 시스템 연동 필요) - // Route::post('/{id}/submit', [DocumentController::class, 'submit'])->name('v1.documents.submit'); - // Route::post('/{id}/approve', [DocumentController::class, 'approve'])->name('v1.documents.approve'); - // Route::post('/{id}/reject', [DocumentController::class, 'reject'])->name('v1.documents.reject'); - // Route::post('/{id}/cancel', [DocumentController::class, 'cancel'])->name('v1.documents.cancel'); -}); + // 결재 워크플로우 + Route::post('/{id}/submit', [DocumentController::class, 'submit'])->whereNumber('id')->name('v1.documents.submit'); + Route::post('/{id}/approve', [DocumentController::class, 'approve'])->whereNumber('id')->name('v1.documents.approve'); + Route::post('/{id}/reject', [DocumentController::class, 'reject'])->whereNumber('id')->name('v1.documents.reject'); + Route::post('/{id}/cancel', [DocumentController::class, 'cancel'])->whereNumber('id')->name('v1.documents.cancel'); +}); \ No newline at end of file