diff --git a/app/Http/Controllers/Api/V1/Construction/ContractController.php b/app/Http/Controllers/Api/V1/Construction/ContractController.php new file mode 100644 index 0000000..eb0cf74 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Construction/ContractController.php @@ -0,0 +1,99 @@ +service->index($request->all()); + }, __('message.contract.fetched')); + } + + /** + * 계약 상세 조회 + */ + public function show(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->show($id); + }, __('message.contract.fetched')); + } + + /** + * 계약 등록 + */ + public function store(ContractStoreRequest $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->store($request->validated()); + }, __('message.contract.created')); + } + + /** + * 계약 수정 + */ + public function update(ContractUpdateRequest $request, int $id) + { + return ApiResponse::handle(function () use ($request, $id) { + return $this->service->update($id, $request->validated()); + }, __('message.contract.updated')); + } + + /** + * 계약 삭제 + */ + public function destroy(int $id) + { + return ApiResponse::handle(function () use ($id) { + $this->service->destroy($id); + + return 'success'; + }, __('message.contract.deleted')); + } + + /** + * 계약 일괄 삭제 + */ + public function bulkDestroy(Request $request) + { + return ApiResponse::handle(function () use ($request) { + $this->service->bulkDestroy($request->input('ids', [])); + + return 'success'; + }, __('message.contract.deleted')); + } + + /** + * 계약 통계 조회 + */ + public function stats(Request $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->stats($request->all()); + }, __('message.contract.fetched')); + } + + /** + * 계약 단계별 카운트 조회 + */ + public function stageCounts(Request $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->stageCounts($request->all()); + }, __('message.contract.fetched')); + } +} diff --git a/app/Http/Requests/Construction/ContractStoreRequest.php b/app/Http/Requests/Construction/ContractStoreRequest.php new file mode 100644 index 0000000..fa8fb1e --- /dev/null +++ b/app/Http/Requests/Construction/ContractStoreRequest.php @@ -0,0 +1,75 @@ + 'required|string|max:50', + 'project_name' => 'required|string|max:255', + + // 거래처 정보 + 'partner_id' => 'nullable|integer', + 'partner_name' => 'nullable|string|max:255', + + // 담당자 정보 + 'contract_manager_id' => 'nullable|integer', + 'contract_manager_name' => 'nullable|string|max:100', + 'construction_pm_id' => 'nullable|integer', + 'construction_pm_name' => 'nullable|string|max:100', + + // 계약 상세 + 'total_locations' => 'nullable|integer|min:0', + 'contract_amount' => 'nullable|numeric|min:0', + 'contract_start_date' => 'nullable|date', + 'contract_end_date' => 'nullable|date|after_or_equal:contract_start_date', + + // 상태 정보 + 'status' => [ + 'nullable', + Rule::in([Contract::STATUS_PENDING, Contract::STATUS_COMPLETED]), + ], + 'stage' => [ + 'nullable', + Rule::in([ + Contract::STAGE_ESTIMATE_SELECTED, + Contract::STAGE_ESTIMATE_PROGRESS, + Contract::STAGE_DELIVERY, + Contract::STAGE_INSTALLATION, + Contract::STAGE_INSPECTION, + Contract::STAGE_OTHER, + ]), + ], + + // 연결 정보 + 'bidding_id' => 'nullable|integer', + 'bidding_code' => 'nullable|string|max:50', + + // 기타 + 'remarks' => 'nullable|string', + 'is_active' => 'nullable|boolean', + ]; + } + + public function messages(): array + { + return [ + 'contract_code.required' => __('validation.required', ['attribute' => '계약번호']), + 'contract_code.max' => __('validation.max.string', ['attribute' => '계약번호', 'max' => 50]), + 'project_name.required' => __('validation.required', ['attribute' => '현장명']), + 'contract_end_date.after_or_equal' => __('validation.after_or_equal', ['attribute' => '계약종료일', 'date' => '계약시작일']), + ]; + } +} diff --git a/app/Http/Requests/Construction/ContractUpdateRequest.php b/app/Http/Requests/Construction/ContractUpdateRequest.php new file mode 100644 index 0000000..17ec951 --- /dev/null +++ b/app/Http/Requests/Construction/ContractUpdateRequest.php @@ -0,0 +1,73 @@ + 'sometimes|string|max:50', + 'project_name' => 'sometimes|string|max:255', + + // 거래처 정보 + 'partner_id' => 'nullable|integer', + 'partner_name' => 'nullable|string|max:255', + + // 담당자 정보 + 'contract_manager_id' => 'nullable|integer', + 'contract_manager_name' => 'nullable|string|max:100', + 'construction_pm_id' => 'nullable|integer', + 'construction_pm_name' => 'nullable|string|max:100', + + // 계약 상세 + 'total_locations' => 'nullable|integer|min:0', + 'contract_amount' => 'nullable|numeric|min:0', + 'contract_start_date' => 'nullable|date', + 'contract_end_date' => 'nullable|date|after_or_equal:contract_start_date', + + // 상태 정보 + 'status' => [ + 'nullable', + Rule::in([Contract::STATUS_PENDING, Contract::STATUS_COMPLETED]), + ], + 'stage' => [ + 'nullable', + Rule::in([ + Contract::STAGE_ESTIMATE_SELECTED, + Contract::STAGE_ESTIMATE_PROGRESS, + Contract::STAGE_DELIVERY, + Contract::STAGE_INSTALLATION, + Contract::STAGE_INSPECTION, + Contract::STAGE_OTHER, + ]), + ], + + // 연결 정보 + 'bidding_id' => 'nullable|integer', + 'bidding_code' => 'nullable|string|max:50', + + // 기타 + 'remarks' => 'nullable|string', + 'is_active' => 'nullable|boolean', + ]; + } + + public function messages(): array + { + return [ + 'contract_code.max' => __('validation.max.string', ['attribute' => '계약번호', 'max' => 50]), + 'contract_end_date.after_or_equal' => __('validation.after_or_equal', ['attribute' => '계약종료일', 'date' => '계약시작일']), + ]; + } +} diff --git a/app/Models/Construction/Contract.php b/app/Models/Construction/Contract.php new file mode 100644 index 0000000..2152675 --- /dev/null +++ b/app/Models/Construction/Contract.php @@ -0,0 +1,218 @@ + 'integer', + 'contract_amount' => 'decimal:2', + 'contract_start_date' => 'date:Y-m-d', + 'contract_end_date' => 'date:Y-m-d', + 'is_active' => 'boolean', + ]; + + protected $attributes = [ + 'is_active' => true, + 'status' => self::STATUS_PENDING, + 'stage' => self::STAGE_ESTIMATE_SELECTED, + 'total_locations' => 0, + 'contract_amount' => 0, + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + /** + * 계약담당자 + */ + public function contractManager(): BelongsTo + { + return $this->belongsTo(User::class, 'contract_manager_id'); + } + + /** + * 공사PM + */ + public function constructionPm(): BelongsTo + { + return $this->belongsTo(User::class, 'construction_pm_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 scopeStage($query, string $stage) + { + return $query->where('stage', $stage); + } + + /** + * 거래처별 필터 + */ + 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 getStageLabelAttribute(): string + { + return match ($this->stage) { + self::STAGE_ESTIMATE_SELECTED => '견적선정', + self::STAGE_ESTIMATE_PROGRESS => '견적진행', + self::STAGE_DELIVERY => '납품', + self::STAGE_INSTALLATION => '설치중', + self::STAGE_INSPECTION => '검수', + self::STAGE_OTHER => '기타', + default => $this->stage, + }; + } + + /** + * 진행중 여부 + */ + public function isPending(): bool + { + return $this->status === self::STATUS_PENDING; + } + + /** + * 완료 여부 + */ + public function isCompleted(): bool + { + return $this->status === self::STATUS_COMPLETED; + } +} diff --git a/app/Services/Construction/ContractService.php b/app/Services/Construction/ContractService.php new file mode 100644 index 0000000..0285201 --- /dev/null +++ b/app/Services/Construction/ContractService.php @@ -0,0 +1,283 @@ +tenantId(); + + $query = Contract::query() + ->where('tenant_id', $tenantId); + + // 검색 필터 + if (! empty($params['search'])) { + $search = $params['search']; + $query->where(function ($q) use ($search) { + $q->where('contract_code', 'like', "%{$search}%") + ->orWhere('project_name', 'like', "%{$search}%") + ->orWhere('partner_name', 'like', "%{$search}%"); + }); + } + + // 상태 필터 + if (! empty($params['status'])) { + $query->where('status', $params['status']); + } + + // 단계 필터 + if (! empty($params['stage'])) { + $query->where('stage', $params['stage']); + } + + // 거래처 필터 + if (! empty($params['partner_id'])) { + $query->where('partner_id', $params['partner_id']); + } + + // 계약담당자 필터 + if (! empty($params['contract_manager_id'])) { + $query->where('contract_manager_id', $params['contract_manager_id']); + } + + // 공사PM 필터 + if (! empty($params['construction_pm_id'])) { + $query->where('construction_pm_id', $params['construction_pm_id']); + } + + // 날짜 범위 필터 + if (! empty($params['start_date'])) { + $query->where('contract_start_date', '>=', $params['start_date']); + } + if (! empty($params['end_date'])) { + $query->where('contract_end_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): Contract + { + $tenantId = $this->tenantId(); + + return Contract::query() + ->where('tenant_id', $tenantId) + ->with(['contractManager', 'constructionPm', 'creator', 'updater']) + ->findOrFail($id); + } + + /** + * 계약 등록 + */ + public function store(array $data): Contract + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($data, $tenantId, $userId) { + $contract = Contract::create([ + 'tenant_id' => $tenantId, + 'contract_code' => $data['contract_code'], + 'project_name' => $data['project_name'], + 'partner_id' => $data['partner_id'] ?? null, + 'partner_name' => $data['partner_name'] ?? null, + 'contract_manager_id' => $data['contract_manager_id'] ?? null, + 'contract_manager_name' => $data['contract_manager_name'] ?? null, + 'construction_pm_id' => $data['construction_pm_id'] ?? null, + 'construction_pm_name' => $data['construction_pm_name'] ?? null, + 'total_locations' => $data['total_locations'] ?? 0, + 'contract_amount' => $data['contract_amount'] ?? 0, + 'contract_start_date' => $data['contract_start_date'] ?? null, + 'contract_end_date' => $data['contract_end_date'] ?? null, + 'status' => $data['status'] ?? Contract::STATUS_PENDING, + 'stage' => $data['stage'] ?? Contract::STAGE_ESTIMATE_SELECTED, + 'bidding_id' => $data['bidding_id'] ?? null, + 'bidding_code' => $data['bidding_code'] ?? null, + 'remarks' => $data['remarks'] ?? null, + 'is_active' => $data['is_active'] ?? true, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + + return $contract; + }); + } + + /** + * 계약 수정 + */ + public function update(int $id, array $data): Contract + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $data, $tenantId, $userId) { + $contract = Contract::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + $contract->fill([ + 'contract_code' => $data['contract_code'] ?? $contract->contract_code, + 'project_name' => $data['project_name'] ?? $contract->project_name, + 'partner_id' => $data['partner_id'] ?? $contract->partner_id, + 'partner_name' => $data['partner_name'] ?? $contract->partner_name, + 'contract_manager_id' => $data['contract_manager_id'] ?? $contract->contract_manager_id, + 'contract_manager_name' => $data['contract_manager_name'] ?? $contract->contract_manager_name, + 'construction_pm_id' => $data['construction_pm_id'] ?? $contract->construction_pm_id, + 'construction_pm_name' => $data['construction_pm_name'] ?? $contract->construction_pm_name, + 'total_locations' => $data['total_locations'] ?? $contract->total_locations, + 'contract_amount' => $data['contract_amount'] ?? $contract->contract_amount, + 'contract_start_date' => $data['contract_start_date'] ?? $contract->contract_start_date, + 'contract_end_date' => $data['contract_end_date'] ?? $contract->contract_end_date, + 'status' => $data['status'] ?? $contract->status, + 'stage' => $data['stage'] ?? $contract->stage, + 'bidding_id' => $data['bidding_id'] ?? $contract->bidding_id, + 'bidding_code' => $data['bidding_code'] ?? $contract->bidding_code, + 'remarks' => $data['remarks'] ?? $contract->remarks, + 'is_active' => $data['is_active'] ?? $contract->is_active, + 'updated_by' => $userId, + ]); + + $contract->save(); + + return $contract->fresh(); + }); + } + + /** + * 계약 삭제 + */ + public function destroy(int $id): bool + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $tenantId, $userId) { + $contract = Contract::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + $contract->deleted_by = $userId; + $contract->save(); + $contract->delete(); + + return true; + }); + } + + /** + * 계약 일괄 삭제 + */ + public function bulkDestroy(array $ids): bool + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($ids, $tenantId, $userId) { + $contracts = Contract::query() + ->where('tenant_id', $tenantId) + ->whereIn('id', $ids) + ->get(); + + foreach ($contracts as $contract) { + $contract->deleted_by = $userId; + $contract->save(); + $contract->delete(); + } + + return true; + }); + } + + /** + * 계약 통계 조회 + */ + public function stats(array $params): array + { + $tenantId = $this->tenantId(); + + $query = Contract::query() + ->where('tenant_id', $tenantId); + + // 날짜 범위 필터 + if (! empty($params['start_date'])) { + $query->where('contract_start_date', '>=', $params['start_date']); + } + if (! empty($params['end_date'])) { + $query->where('contract_end_date', '<=', $params['end_date']); + } + + $totalCount = (clone $query)->count(); + $pendingCount = (clone $query)->where('status', Contract::STATUS_PENDING)->count(); + $completedCount = (clone $query)->where('status', Contract::STATUS_COMPLETED)->count(); + $totalAmount = (clone $query)->sum('contract_amount'); + $totalLocations = (clone $query)->sum('total_locations'); + + return [ + 'total_count' => $totalCount, + 'pending_count' => $pendingCount, + 'completed_count' => $completedCount, + 'total_amount' => (float) $totalAmount, + 'total_locations' => (int) $totalLocations, + ]; + } + + /** + * 계약 단계별 카운트 조회 + */ + public function stageCounts(array $params): array + { + $tenantId = $this->tenantId(); + + $query = Contract::query() + ->where('tenant_id', $tenantId); + + // 날짜 범위 필터 + if (! empty($params['start_date'])) { + $query->where('contract_start_date', '>=', $params['start_date']); + } + if (! empty($params['end_date'])) { + $query->where('contract_end_date', '<=', $params['end_date']); + } + + $stageCounts = (clone $query) + ->select('stage', DB::raw('COUNT(*) as count')) + ->groupBy('stage') + ->pluck('count', 'stage') + ->toArray(); + + return [ + 'estimate_selected' => $stageCounts[Contract::STAGE_ESTIMATE_SELECTED] ?? 0, + 'estimate_progress' => $stageCounts[Contract::STAGE_ESTIMATE_PROGRESS] ?? 0, + 'delivery' => $stageCounts[Contract::STAGE_DELIVERY] ?? 0, + 'installation' => $stageCounts[Contract::STAGE_INSTALLATION] ?? 0, + 'inspection' => $stageCounts[Contract::STAGE_INSPECTION] ?? 0, + 'other' => $stageCounts[Contract::STAGE_OTHER] ?? 0, + ]; + } +} diff --git a/app/Swagger/v1/ContractApi.php b/app/Swagger/v1/ContractApi.php new file mode 100644 index 0000000..1d2f8e4 --- /dev/null +++ b/app/Swagger/v1/ContractApi.php @@ -0,0 +1,339 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + + // 계약 기본 정보 + $table->string('contract_code', 50)->comment('계약번호'); + $table->string('project_name')->comment('현장명'); + + // 거래처 정보 + $table->unsignedBigInteger('partner_id')->nullable()->comment('거래처 ID'); + $table->string('partner_name')->nullable()->comment('거래처명'); + + // 담당자 정보 + $table->unsignedBigInteger('contract_manager_id')->nullable()->comment('계약담당자 ID'); + $table->string('contract_manager_name')->nullable()->comment('계약담당자명'); + $table->unsignedBigInteger('construction_pm_id')->nullable()->comment('공사PM ID'); + $table->string('construction_pm_name')->nullable()->comment('공사PM명'); + + // 계약 상세 + $table->integer('total_locations')->default(0)->comment('총 개소'); + $table->decimal('contract_amount', 15, 2)->default(0)->comment('계약금액'); + $table->date('contract_start_date')->nullable()->comment('계약시작일'); + $table->date('contract_end_date')->nullable()->comment('계약종료일'); + + // 상태 정보 + $table->string('status', 20)->default('pending')->comment('계약상태: pending, completed'); + $table->string('stage', 30)->default('estimate_selected')->comment('계약단계: estimate_selected, estimate_progress, delivery, installation, inspection, other'); + + // 연결 정보 + $table->unsignedBigInteger('bidding_id')->nullable()->comment('입찰 ID'); + $table->string('bidding_code', 50)->nullable()->comment('입찰번호'); + + // 비고 + $table->text('remarks')->nullable()->comment('비고'); + + // 활성화 상태 + $table->boolean('is_active')->default(true)->comment('활성화 여부'); + + // 감사 컬럼 + $table->unsignedBigInteger('created_by')->nullable()->comment('생성자 ID'); + $table->unsignedBigInteger('updated_by')->nullable()->comment('수정자 ID'); + $table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자 ID'); + + // 타임스탬프 + $table->timestamps(); + $table->softDeletes(); + + // 인덱스 + $table->index('tenant_id'); + $table->index('partner_id'); + $table->index('status'); + $table->index('stage'); + $table->unique(['tenant_id', 'contract_code']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('contracts'); + } +}; \ No newline at end of file diff --git a/lang/ko/message.php b/lang/ko/message.php index 99a4fd7..b59a41a 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -274,6 +274,14 @@ 'active_fetched' => '활성화된 현장 목록을 조회했습니다.', ], + // 계약 관리 (시공관리) + 'contract' => [ + 'fetched' => '계약을 조회했습니다.', + 'created' => '계약이 등록되었습니다.', + 'updated' => '계약이 수정되었습니다.', + 'deleted' => '계약이 삭제되었습니다.', + ], + // 보고서 관리 'report' => [ 'fetched' => '보고서를 조회했습니다.', diff --git a/routes/api.php b/routes/api.php index 1e96abb..66df4e2 100644 --- a/routes/api.php +++ b/routes/api.php @@ -25,6 +25,7 @@ use App\Http\Controllers\Api\V1\ClassificationController; use App\Http\Controllers\Api\V1\ClientController; use App\Http\Controllers\Api\V1\ClientGroupController; +use App\Http\Controllers\Api\V1\Construction\ContractController; use App\Http\Controllers\Api\V1\CommonController; use App\Http\Controllers\Api\V1\CompanyController; use App\Http\Controllers\Api\V1\DashboardController; @@ -419,6 +420,21 @@ Route::delete('/{id}', [SiteController::class, 'destroy'])->whereNumber('id')->name('v1.sites.destroy'); }); + // Construction API (시공관리) + Route::prefix('construction')->group(function () { + // Contract API (계약관리) + Route::prefix('contracts')->group(function () { + Route::get('', [ContractController::class, 'index'])->name('v1.construction.contracts.index'); + Route::post('', [ContractController::class, 'store'])->name('v1.construction.contracts.store'); + Route::get('/stats', [ContractController::class, 'stats'])->name('v1.construction.contracts.stats'); + Route::get('/stage-counts', [ContractController::class, 'stageCounts'])->name('v1.construction.contracts.stage-counts'); + Route::delete('/bulk', [ContractController::class, 'bulkDestroy'])->name('v1.construction.contracts.bulk-destroy'); + Route::get('/{id}', [ContractController::class, 'show'])->whereNumber('id')->name('v1.construction.contracts.show'); + Route::put('/{id}', [ContractController::class, 'update'])->whereNumber('id')->name('v1.construction.contracts.update'); + Route::delete('/{id}', [ContractController::class, 'destroy'])->whereNumber('id')->name('v1.construction.contracts.destroy'); + }); + }); + // Card API (카드 관리) Route::prefix('cards')->group(function () { Route::get('', [CardController::class, 'index'])->name('v1.cards.index');