diff --git a/app/Http/Controllers/Api/V1/Construction/StructureReviewController.php b/app/Http/Controllers/Api/V1/Construction/StructureReviewController.php new file mode 100644 index 0000000..470291c --- /dev/null +++ b/app/Http/Controllers/Api/V1/Construction/StructureReviewController.php @@ -0,0 +1,89 @@ +service->index($request->all()); + }, __('message.structure_review.fetched')); + } + + /** + * 구조검토 상세 조회 + */ + public function show(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->show($id); + }, __('message.structure_review.fetched')); + } + + /** + * 구조검토 등록 + */ + public function store(StructureReviewStoreRequest $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->store($request->validated()); + }, __('message.structure_review.created')); + } + + /** + * 구조검토 수정 + */ + public function update(StructureReviewUpdateRequest $request, int $id) + { + return ApiResponse::handle(function () use ($request, $id) { + return $this->service->update($id, $request->validated()); + }, __('message.structure_review.updated')); + } + + /** + * 구조검토 삭제 + */ + public function destroy(int $id) + { + return ApiResponse::handle(function () use ($id) { + $this->service->destroy($id); + + return 'success'; + }, __('message.structure_review.deleted')); + } + + /** + * 구조검토 일괄 삭제 + */ + public function bulkDestroy(Request $request) + { + return ApiResponse::handle(function () use ($request) { + $this->service->bulkDestroy($request->input('ids', [])); + + return 'success'; + }, __('message.structure_review.deleted')); + } + + /** + * 구조검토 통계 조회 + */ + public function stats(Request $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->stats($request->all()); + }, __('message.structure_review.fetched')); + } +} \ No newline at end of file diff --git a/app/Http/Requests/Construction/StructureReviewStoreRequest.php b/app/Http/Requests/Construction/StructureReviewStoreRequest.php new file mode 100644 index 0000000..f161f74 --- /dev/null +++ b/app/Http/Requests/Construction/StructureReviewStoreRequest.php @@ -0,0 +1,59 @@ + 'nullable|string|max:50', + + // 거래처 정보 + 'partner_id' => 'nullable|integer', + 'partner_name' => 'nullable|string|max:100', + + // 현장 정보 + 'site_id' => 'nullable|integer', + 'site_name' => 'nullable|string|max:200', + + // 검토 일정 + 'request_date' => 'nullable|date', + 'review_date' => 'nullable|date', + 'completion_date' => 'nullable|date|after_or_equal:request_date', + + // 검토자 정보 + 'review_company' => 'nullable|string|max:100', + 'reviewer_name' => 'nullable|string|max:50', + + // 상태 정보 + 'status' => [ + 'nullable', + Rule::in([StructureReview::STATUS_PENDING, StructureReview::STATUS_COMPLETED]), + ], + + // 기타 + 'file_url' => 'nullable|string|max:500', + 'remarks' => 'nullable|string', + 'is_active' => 'nullable|boolean', + ]; + } + + public function messages(): array + { + return [ + 'review_number.max' => __('validation.max.string', ['attribute' => '검토번호', 'max' => 50]), + 'completion_date.after_or_equal' => __('validation.after_or_equal', ['attribute' => '완료일', 'date' => '의뢰일']), + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Construction/StructureReviewUpdateRequest.php b/app/Http/Requests/Construction/StructureReviewUpdateRequest.php new file mode 100644 index 0000000..d857531 --- /dev/null +++ b/app/Http/Requests/Construction/StructureReviewUpdateRequest.php @@ -0,0 +1,59 @@ + 'sometimes|string|max:50', + + // 거래처 정보 + 'partner_id' => 'nullable|integer', + 'partner_name' => 'nullable|string|max:100', + + // 현장 정보 + 'site_id' => 'nullable|integer', + 'site_name' => 'nullable|string|max:200', + + // 검토 일정 + 'request_date' => 'nullable|date', + 'review_date' => 'nullable|date', + 'completion_date' => 'nullable|date|after_or_equal:request_date', + + // 검토자 정보 + 'review_company' => 'nullable|string|max:100', + 'reviewer_name' => 'nullable|string|max:50', + + // 상태 정보 + 'status' => [ + 'nullable', + Rule::in([StructureReview::STATUS_PENDING, StructureReview::STATUS_COMPLETED]), + ], + + // 기타 + 'file_url' => 'nullable|string|max:500', + 'remarks' => 'nullable|string', + 'is_active' => 'nullable|boolean', + ]; + } + + public function messages(): array + { + return [ + 'review_number.max' => __('validation.max.string', ['attribute' => '검토번호', 'max' => 50]), + 'completion_date.after_or_equal' => __('validation.after_or_equal', ['attribute' => '완료일', 'date' => '의뢰일']), + ]; + } +} \ No newline at end of file diff --git a/app/Models/Construction/StructureReview.php b/app/Models/Construction/StructureReview.php new file mode 100644 index 0000000..7987e4b --- /dev/null +++ b/app/Models/Construction/StructureReview.php @@ -0,0 +1,170 @@ + 'date:Y-m-d', + 'review_date' => 'date:Y-m-d', + 'completion_date' => 'date:Y-m-d', + 'is_active' => 'boolean', + ]; + + protected $attributes = [ + 'is_active' => true, + 'status' => self::STATUS_PENDING, + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + /** + * 현장 + */ + public function site(): BelongsTo + { + return $this->belongsTo(Site::class, 'site_id'); + } + + /** + * 생성자 + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * 수정자 + */ + public function updater(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + /** + * 상태별 필터 + */ + public function scopeStatus($query, string $status) + { + return $query->where('status', $status); + } + + /** + * 현장별 필터 + */ + public function scopeSite($query, int $siteId) + { + return $query->where('site_id', $siteId); + } + + /** + * 거래처별 필터 + */ + public function scopePartner($query, int $partnerId) + { + return $query->where('partner_id', $partnerId); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + /** + * 상태 라벨 반환 + */ + public function getStatusLabelAttribute(): string + { + return match ($this->status) { + self::STATUS_PENDING => '검토대기', + self::STATUS_COMPLETED => '검토완료', + default => $this->status, + }; + } + + /** + * 검토대기 여부 + */ + public function isPending(): bool + { + return $this->status === self::STATUS_PENDING; + } + + /** + * 검토완료 여부 + */ + public function isCompleted(): bool + { + return $this->status === self::STATUS_COMPLETED; + } +} \ No newline at end of file diff --git a/app/Services/Construction/StructureReviewService.php b/app/Services/Construction/StructureReviewService.php new file mode 100644 index 0000000..d763364 --- /dev/null +++ b/app/Services/Construction/StructureReviewService.php @@ -0,0 +1,229 @@ +tenantId(); + + $query = StructureReview::query() + ->where('tenant_id', $tenantId); + + // 검색 필터 + if (! empty($params['search'])) { + $search = $params['search']; + $query->where(function ($q) use ($search) { + $q->where('review_number', 'like', "%{$search}%") + ->orWhere('partner_name', 'like', "%{$search}%") + ->orWhere('site_name', 'like', "%{$search}%") + ->orWhere('review_company', 'like', "%{$search}%") + ->orWhere('reviewer_name', 'like', "%{$search}%"); + }); + } + + // 상태 필터 + if (! empty($params['status'])) { + $query->where('status', $params['status']); + } + + // 거래처 필터 + if (! empty($params['partner_id'])) { + $query->where('partner_id', $params['partner_id']); + } + + // 현장 필터 + if (! empty($params['site_id'])) { + $query->where('site_id', $params['site_id']); + } + + // 날짜 범위 필터 (의뢰일 기준) + if (! empty($params['start_date'])) { + $query->where('request_date', '>=', $params['start_date']); + } + if (! empty($params['end_date'])) { + $query->where('request_date', '<=', $params['end_date']); + } + + // 활성화 상태 필터 + if (isset($params['is_active'])) { + $query->where('is_active', $params['is_active']); + } + + // 정렬 + $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): StructureReview + { + $tenantId = $this->tenantId(); + + return StructureReview::query() + ->where('tenant_id', $tenantId) + ->with(['site', 'creator', 'updater']) + ->findOrFail($id); + } + + /** + * 구조검토 등록 + */ + public function store(array $data): StructureReview + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($data, $tenantId, $userId) { + $review = StructureReview::create([ + 'tenant_id' => $tenantId, + 'review_number' => $data['review_number'] ?? null, + 'partner_id' => $data['partner_id'] ?? null, + 'partner_name' => $data['partner_name'] ?? null, + 'site_id' => $data['site_id'] ?? null, + 'site_name' => $data['site_name'] ?? null, + 'request_date' => $data['request_date'] ?? null, + 'review_company' => $data['review_company'] ?? null, + 'reviewer_name' => $data['reviewer_name'] ?? null, + 'review_date' => $data['review_date'] ?? null, + 'completion_date' => $data['completion_date'] ?? null, + 'status' => $data['status'] ?? StructureReview::STATUS_PENDING, + 'file_url' => $data['file_url'] ?? null, + 'remarks' => $data['remarks'] ?? null, + 'is_active' => $data['is_active'] ?? true, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + + return $review; + }); + } + + /** + * 구조검토 수정 + */ + public function update(int $id, array $data): StructureReview + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $data, $tenantId, $userId) { + $review = StructureReview::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + $review->fill([ + 'review_number' => $data['review_number'] ?? $review->review_number, + 'partner_id' => $data['partner_id'] ?? $review->partner_id, + 'partner_name' => $data['partner_name'] ?? $review->partner_name, + 'site_id' => $data['site_id'] ?? $review->site_id, + 'site_name' => $data['site_name'] ?? $review->site_name, + 'request_date' => $data['request_date'] ?? $review->request_date, + 'review_company' => $data['review_company'] ?? $review->review_company, + 'reviewer_name' => $data['reviewer_name'] ?? $review->reviewer_name, + 'review_date' => $data['review_date'] ?? $review->review_date, + 'completion_date' => $data['completion_date'] ?? $review->completion_date, + 'status' => $data['status'] ?? $review->status, + 'file_url' => $data['file_url'] ?? $review->file_url, + 'remarks' => $data['remarks'] ?? $review->remarks, + 'is_active' => $data['is_active'] ?? $review->is_active, + 'updated_by' => $userId, + ]); + + $review->save(); + + return $review->fresh(); + }); + } + + /** + * 구조검토 삭제 + */ + public function destroy(int $id): bool + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $tenantId, $userId) { + $review = StructureReview::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + $review->deleted_by = $userId; + $review->save(); + $review->delete(); + + return true; + }); + } + + /** + * 구조검토 일괄 삭제 + */ + public function bulkDestroy(array $ids): bool + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($ids, $tenantId, $userId) { + $reviews = StructureReview::query() + ->where('tenant_id', $tenantId) + ->whereIn('id', $ids) + ->get(); + + foreach ($reviews as $review) { + $review->deleted_by = $userId; + $review->save(); + $review->delete(); + } + + return true; + }); + } + + /** + * 구조검토 통계 조회 + */ + public function stats(array $params): array + { + $tenantId = $this->tenantId(); + + $query = StructureReview::query() + ->where('tenant_id', $tenantId); + + // 날짜 범위 필터 + if (! empty($params['start_date'])) { + $query->where('request_date', '>=', $params['start_date']); + } + if (! empty($params['end_date'])) { + $query->where('request_date', '<=', $params['end_date']); + } + + $totalCount = (clone $query)->count(); + $pendingCount = (clone $query)->where('status', StructureReview::STATUS_PENDING)->count(); + $completedCount = (clone $query)->where('status', StructureReview::STATUS_COMPLETED)->count(); + + return [ + 'total' => $totalCount, + 'pending' => $pendingCount, + 'completed' => $completedCount, + ]; + } +} \ No newline at end of file diff --git a/database/migrations/2026_01_09_100000_create_structure_reviews_table.php b/database/migrations/2026_01_09_100000_create_structure_reviews_table.php new file mode 100644 index 0000000..2596c2b --- /dev/null +++ b/database/migrations/2026_01_09_100000_create_structure_reviews_table.php @@ -0,0 +1,54 @@ +id(); + $table->foreignId('tenant_id')->comment('테넌트 ID'); + $table->string('review_number', 50)->nullable()->comment('검토번호'); + $table->foreignId('partner_id')->nullable()->comment('거래처 ID'); + $table->string('partner_name', 100)->nullable()->comment('거래처명'); + $table->foreignId('site_id')->nullable()->comment('현장 ID'); + $table->string('site_name', 200)->nullable()->comment('현장명'); + $table->date('request_date')->nullable()->comment('구조검토 의뢰일'); + $table->string('review_company', 100)->nullable()->comment('구조검토 회사'); + $table->string('reviewer_name', 50)->nullable()->comment('구조검토자'); + $table->date('review_date')->nullable()->comment('구조검토일'); + $table->date('completion_date')->nullable()->comment('구조검토 완료일'); + $table->string('status', 20)->default('pending')->comment('상태: pending, completed'); + $table->string('file_url', 500)->nullable()->comment('구조검토 파일 URL'); + $table->text('remarks')->nullable()->comment('비고'); + $table->boolean('is_active')->default(true)->comment('활성화 여부'); + $table->foreignId('created_by')->nullable()->comment('생성자 ID'); + $table->foreignId('updated_by')->nullable()->comment('수정자 ID'); + $table->foreignId('deleted_by')->nullable()->comment('삭제자 ID'); + $table->timestamps(); + $table->softDeletes(); + + // 인덱스 + $table->index('tenant_id'); + $table->index('partner_id'); + $table->index('site_id'); + $table->index('status'); + $table->index('request_date'); + $table->index('created_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('structure_reviews'); + } +}; \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index c4cb51b..8c2ca21 100644 --- a/routes/api.php +++ b/routes/api.php @@ -29,6 +29,7 @@ use App\Http\Controllers\Api\V1\ComprehensiveAnalysisController; use App\Http\Controllers\Api\V1\Construction\ContractController; use App\Http\Controllers\Api\V1\Construction\HandoverReportController; +use App\Http\Controllers\Api\V1\Construction\StructureReviewController; use App\Http\Controllers\Api\V1\DailyReportController; use App\Http\Controllers\Api\V1\DashboardController; use App\Http\Controllers\Api\V1\DepartmentController; @@ -447,6 +448,17 @@ Route::put('/{id}', [HandoverReportController::class, 'update'])->whereNumber('id')->name('v1.construction.handover-reports.update'); Route::delete('/{id}', [HandoverReportController::class, 'destroy'])->whereNumber('id')->name('v1.construction.handover-reports.destroy'); }); + + // StructureReview API (구조검토관리) + Route::prefix('structure-reviews')->group(function () { + Route::get('', [StructureReviewController::class, 'index'])->name('v1.construction.structure-reviews.index'); + Route::post('', [StructureReviewController::class, 'store'])->name('v1.construction.structure-reviews.store'); + Route::get('/stats', [StructureReviewController::class, 'stats'])->name('v1.construction.structure-reviews.stats'); + Route::delete('/bulk', [StructureReviewController::class, 'bulkDestroy'])->name('v1.construction.structure-reviews.bulk-destroy'); + Route::get('/{id}', [StructureReviewController::class, 'show'])->whereNumber('id')->name('v1.construction.structure-reviews.show'); + Route::put('/{id}', [StructureReviewController::class, 'update'])->whereNumber('id')->name('v1.construction.structure-reviews.update'); + Route::delete('/{id}', [StructureReviewController::class, 'destroy'])->whereNumber('id')->name('v1.construction.structure-reviews.destroy'); + }); }); // Card API (카드 관리)