From 7dd683ace8282f1d8eb893fe99db05ff662c1ede Mon Sep 17 00:00:00 2001 From: hskwon Date: Mon, 19 Jan 2026 20:23:30 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9E=85=EC=B0=B0(Bidding)=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bidding 모델, 서비스, 컨트롤러, FormRequest 추가 - 마이그레이션 및 시더 추가 - Swagger API 문서 추가 - 견적에서 입찰 전환 시 중복 체크 로직 추가 - per_page 파라미터 100 초과 시 자동 클램핑 처리 - error.bidding.already_registered 에러 메시지 추가 --- .../Controllers/Api/V1/BiddingController.php | 81 ++++ .../Requests/Bidding/BiddingFilterRequest.php | 36 ++ .../Requests/Bidding/BiddingStatusRequest.php | 30 ++ .../Requests/Bidding/BiddingStoreRequest.php | 56 +++ .../Requests/Bidding/BiddingUpdateRequest.php | 46 +++ .../Requests/Bidding/BulkDeleteRequest.php | 30 ++ app/Models/Bidding/Bidding.php | 252 ++++++++++++ app/Services/Bidding/BiddingService.php | 261 +++++++++++++ app/Swagger/v1/BiddingApi.php | 360 ++++++++++++++++++ ...026_01_19_100000_create_biddings_table.php | 77 ++++ database/seeders/BiddingSeeder.php | 198 ++++++++++ lang/ko/error.php | 9 + 12 files changed, 1436 insertions(+) create mode 100644 app/Http/Controllers/Api/V1/BiddingController.php create mode 100644 app/Http/Requests/Bidding/BiddingFilterRequest.php create mode 100644 app/Http/Requests/Bidding/BiddingStatusRequest.php create mode 100644 app/Http/Requests/Bidding/BiddingStoreRequest.php create mode 100644 app/Http/Requests/Bidding/BiddingUpdateRequest.php create mode 100644 app/Http/Requests/Bidding/BulkDeleteRequest.php create mode 100644 app/Models/Bidding/Bidding.php create mode 100644 app/Services/Bidding/BiddingService.php create mode 100644 app/Swagger/v1/BiddingApi.php create mode 100644 database/migrations/2026_01_19_100000_create_biddings_table.php create mode 100644 database/seeders/BiddingSeeder.php diff --git a/app/Http/Controllers/Api/V1/BiddingController.php b/app/Http/Controllers/Api/V1/BiddingController.php new file mode 100644 index 0000000..01a0051 --- /dev/null +++ b/app/Http/Controllers/Api/V1/BiddingController.php @@ -0,0 +1,81 @@ + $this->service->index($request->validated())); + } + + /** + * 입찰 통계 조회 + */ + public function stats() + { + return ApiResponse::handle(fn () => $this->service->stats()); + } + + /** + * 입찰 생성 (견적에서 전환 포함) + */ + public function store(BiddingStoreRequest $request) + { + return ApiResponse::handle(fn () => $this->service->store($request->validated()), 'message.created'); + } + + /** + * 입찰 단건 조회 + */ + public function show(int $id) + { + return ApiResponse::handle(fn () => $this->service->show($id)); + } + + /** + * 입찰 수정 + */ + public function update(BiddingUpdateRequest $request, int $id) + { + return ApiResponse::handle(fn () => $this->service->update($id, $request->validated()), 'message.updated'); + } + + /** + * 입찰 삭제 + */ + public function destroy(int $id) + { + return ApiResponse::handle(fn () => $this->service->destroy($id), 'message.deleted'); + } + + /** + * 입찰 일괄 삭제 + */ + public function bulkDestroy(BulkDeleteRequest $request) + { + return ApiResponse::handle(fn () => $this->service->bulkDestroy($request->validated()['ids']), 'message.deleted'); + } + + /** + * 입찰 상태 변경 + */ + public function updateStatus(BiddingStatusRequest $request, int $id) + { + return ApiResponse::handle(fn () => $this->service->updateStatus($id, $request->validated()['status']), 'message.updated'); + } +} diff --git a/app/Http/Requests/Bidding/BiddingFilterRequest.php b/app/Http/Requests/Bidding/BiddingFilterRequest.php new file mode 100644 index 0000000..81da902 --- /dev/null +++ b/app/Http/Requests/Bidding/BiddingFilterRequest.php @@ -0,0 +1,36 @@ + ['nullable', 'string', Rule::in(Bidding::STATUSES)], + 'from_date' => ['nullable', 'date'], + 'to_date' => ['nullable', 'date', 'after_or_equal:from_date'], + 'search' => ['nullable', 'string', 'max:100'], + 'sort_by' => ['nullable', 'string', Rule::in(['bidding_date', 'bidding_code', 'client_name', 'project_name', 'bidding_amount', 'status', 'created_at'])], + 'sort_dir' => ['nullable', 'string', Rule::in(['asc', 'desc'])], + 'per_page' => ['nullable', 'integer', 'min:1'], + ]; + } + + public function messages(): array + { + return [ + 'status.in' => __('validation.in', ['attribute' => __('validation.attributes.status')]), + 'to_date.after_or_equal' => __('validation.after_or_equal', ['attribute' => __('validation.attributes.to_date'), 'date' => __('validation.attributes.from_date')]), + ]; + } +} diff --git a/app/Http/Requests/Bidding/BiddingStatusRequest.php b/app/Http/Requests/Bidding/BiddingStatusRequest.php new file mode 100644 index 0000000..50d8f70 --- /dev/null +++ b/app/Http/Requests/Bidding/BiddingStatusRequest.php @@ -0,0 +1,30 @@ + ['required', 'string', Rule::in(Bidding::STATUSES)], + ]; + } + + public function messages(): array + { + return [ + 'status.required' => __('validation.required', ['attribute' => __('validation.attributes.status')]), + 'status.in' => __('validation.in', ['attribute' => __('validation.attributes.status')]), + ]; + } +} diff --git a/app/Http/Requests/Bidding/BiddingStoreRequest.php b/app/Http/Requests/Bidding/BiddingStoreRequest.php new file mode 100644 index 0000000..4de7f62 --- /dev/null +++ b/app/Http/Requests/Bidding/BiddingStoreRequest.php @@ -0,0 +1,56 @@ + ['nullable', 'integer', 'exists:quotes,id'], + // 거래처/현장 + 'client_id' => ['nullable', 'integer'], + 'client_name' => ['nullable', 'string', 'max:100'], + 'project_name' => ['required', 'string', 'max:200'], + // 입찰 정보 + 'bidding_date' => ['nullable', 'date'], + 'bid_date' => ['nullable', 'date'], + 'submission_date' => ['nullable', 'date'], + 'confirm_date' => ['nullable', 'date'], + 'total_count' => ['nullable', 'integer', 'min:0'], + 'bidding_amount' => ['nullable', 'numeric', 'min:0'], + // 상태 (기본값: waiting) + 'status' => ['nullable', 'string', Rule::in(Bidding::STATUSES)], + // 입찰자 + 'bidder_id' => ['nullable', 'integer'], + 'bidder_name' => ['nullable', 'string', 'max:50'], + // 공사기간 + 'construction_start_date' => ['nullable', 'date'], + 'construction_end_date' => ['nullable', 'date', 'after_or_equal:construction_start_date'], + 'vat_type' => ['nullable', 'string', Rule::in(Bidding::VAT_TYPES)], + // 비고 + 'remarks' => ['nullable', 'string'], + // 견적 데이터 스냅샷 (견적에서 전환 시) + 'expense_items' => ['nullable', 'array'], + 'estimate_detail_items' => ['nullable', 'array'], + ]; + } + + public function messages(): array + { + return [ + 'project_name.required' => __('validation.required', ['attribute' => '현장명']), + 'quote_id.exists' => __('validation.exists', ['attribute' => '견적']), + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Bidding/BiddingUpdateRequest.php b/app/Http/Requests/Bidding/BiddingUpdateRequest.php new file mode 100644 index 0000000..7e40035 --- /dev/null +++ b/app/Http/Requests/Bidding/BiddingUpdateRequest.php @@ -0,0 +1,46 @@ + ['nullable', 'integer'], + 'client_name' => ['nullable', 'string', 'max:100'], + 'project_name' => ['nullable', 'string', 'max:200'], + // 입찰 정보 + 'bidding_date' => ['nullable', 'date'], + 'bid_date' => ['nullable', 'date'], + 'submission_date' => ['nullable', 'date'], + 'confirm_date' => ['nullable', 'date'], + 'total_count' => ['nullable', 'integer', 'min:0'], + 'bidding_amount' => ['nullable', 'numeric', 'min:0'], + // 상태 + 'status' => ['nullable', 'string', Rule::in(Bidding::STATUSES)], + // 입찰자 + 'bidder_id' => ['nullable', 'integer'], + 'bidder_name' => ['nullable', 'string', 'max:50'], + // 공사기간 + 'construction_start_date' => ['nullable', 'date'], + 'construction_end_date' => ['nullable', 'date', 'after_or_equal:construction_start_date'], + 'vat_type' => ['nullable', 'string', Rule::in(Bidding::VAT_TYPES)], + // 비고 + 'remarks' => ['nullable', 'string'], + // 견적 데이터 스냅샷 + 'expense_items' => ['nullable', 'array'], + 'estimate_detail_items' => ['nullable', 'array'], + ]; + } +} diff --git a/app/Http/Requests/Bidding/BulkDeleteRequest.php b/app/Http/Requests/Bidding/BulkDeleteRequest.php new file mode 100644 index 0000000..f378c0e --- /dev/null +++ b/app/Http/Requests/Bidding/BulkDeleteRequest.php @@ -0,0 +1,30 @@ + ['required', 'array', 'min:1'], + 'ids.*' => ['required', 'integer'], + ]; + } + + public function messages(): array + { + return [ + 'ids.required' => __('validation.required', ['attribute' => 'ids']), + 'ids.array' => __('validation.array', ['attribute' => 'ids']), + 'ids.min' => __('validation.min.array', ['attribute' => 'ids', 'min' => 1]), + ]; + } +} diff --git a/app/Models/Bidding/Bidding.php b/app/Models/Bidding/Bidding.php new file mode 100644 index 0000000..c8d68f7 --- /dev/null +++ b/app/Models/Bidding/Bidding.php @@ -0,0 +1,252 @@ + 'date', + 'bid_date' => 'date', + 'submission_date' => 'date', + 'confirm_date' => 'date', + 'construction_start_date' => 'date', + 'construction_end_date' => 'date', + 'bidding_amount' => 'decimal:2', + 'expense_items' => 'array', + 'estimate_detail_items' => 'array', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'deleted_at' => 'datetime', + ]; + + /** + * 상태 상수 + */ + public const STATUS_WAITING = 'waiting'; // 입찰대기 + + public const STATUS_SUBMITTED = 'submitted'; // 투찰 + + public const STATUS_FAILED = 'failed'; // 탈락 + + public const STATUS_INVALID = 'invalid'; // 유찰 + + public const STATUS_AWARDED = 'awarded'; // 낙찰 + + public const STATUS_HOLD = 'hold'; // 보류 + + public const STATUSES = [ + self::STATUS_WAITING, + self::STATUS_SUBMITTED, + self::STATUS_FAILED, + self::STATUS_INVALID, + self::STATUS_AWARDED, + self::STATUS_HOLD, + ]; + + /** + * 부가세 유형 상수 + */ + public const VAT_INCLUDED = 'included'; + + public const VAT_EXCLUDED = 'excluded'; + + public const VAT_TYPES = [ + self::VAT_INCLUDED, + self::VAT_EXCLUDED, + ]; + + /** + * 연결된 견적 + */ + public function quote(): BelongsTo + { + return $this->belongsTo(Quote::class); + } + + /** + * 거래처 + */ + public function client(): BelongsTo + { + return $this->belongsTo(Client::class); + } + + /** + * 입찰자 + */ + public function bidder(): BelongsTo + { + return $this->belongsTo(User::class, 'bidder_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 scopeWaiting($query) + { + return $query->where('status', self::STATUS_WAITING); + } + + public function scopeSubmitted($query) + { + return $query->where('status', self::STATUS_SUBMITTED); + } + + public function scopeFailed($query) + { + return $query->where('status', self::STATUS_FAILED); + } + + public function scopeInvalid($query) + { + return $query->where('status', self::STATUS_INVALID); + } + + public function scopeAwarded($query) + { + return $query->where('status', self::STATUS_AWARDED); + } + + public function scopeHold($query) + { + return $query->where('status', self::STATUS_HOLD); + } + + /** + * 상태 필터 스코프 + */ + public function scopeOfStatus($query, ?string $status) + { + if ($status) { + return $query->where('status', $status); + } + + return $query; + } + + /** + * 날짜 범위 스코프 + */ + public function scopeDateRange($query, ?string $from, ?string $to) + { + if ($from) { + $query->where('bidding_date', '>=', $from); + } + if ($to) { + $query->where('bidding_date', '<=', $to); + } + + return $query; + } + + /** + * 검색 스코프 + */ + public function scopeSearch($query, ?string $keyword) + { + if (! $keyword) { + return $query; + } + + return $query->where(function ($q) use ($keyword) { + $q->where('bidding_code', 'like', "%{$keyword}%") + ->orWhere('client_name', 'like', "%{$keyword}%") + ->orWhere('project_name', 'like', "%{$keyword}%") + ->orWhere('bidder_name', 'like', "%{$keyword}%"); + }); + } + + /** + * 수정 가능 여부 확인 + */ + public function isEditable(): bool + { + return ! in_array($this->status, [self::STATUS_AWARDED, self::STATUS_FAILED, self::STATUS_INVALID]); + } + + /** + * 삭제 가능 여부 확인 + */ + public function isDeletable(): bool + { + return ! in_array($this->status, [self::STATUS_AWARDED]); + } + + /** + * 낙찰 가능 여부 확인 + */ + public function isAwardable(): bool + { + return in_array($this->status, [self::STATUS_WAITING, self::STATUS_SUBMITTED, self::STATUS_HOLD]); + } + + /** + * 계약 전환 가능 여부 확인 + */ + public function isConvertibleToContract(): bool + { + return $this->status === self::STATUS_AWARDED; + } +} diff --git a/app/Services/Bidding/BiddingService.php b/app/Services/Bidding/BiddingService.php new file mode 100644 index 0000000..dc40000 --- /dev/null +++ b/app/Services/Bidding/BiddingService.php @@ -0,0 +1,261 @@ +tenantId(); + + $query = Bidding::where('tenant_id', $tenantId) + ->with(['quote:id,quote_number']); + + // 상태 필터 + if (! empty($params['status'])) { + $query->where('status', $params['status']); + } + + // 날짜 범위 필터 + if (! empty($params['from_date'])) { + $query->where('bidding_date', '>=', $params['from_date']); + } + if (! empty($params['to_date'])) { + $query->where('bidding_date', '<=', $params['to_date']); + } + + // 검색 (입찰번호, 거래처명, 현장명, 입찰자명) + if (! empty($params['search'])) { + $keyword = $params['search']; + $query->where(function ($q) use ($keyword) { + $q->where('bidding_code', 'like', "%{$keyword}%") + ->orWhere('client_name', 'like', "%{$keyword}%") + ->orWhere('project_name', 'like', "%{$keyword}%") + ->orWhere('bidder_name', 'like', "%{$keyword}%"); + }); + } + + // 정렬 + $sortBy = $params['sort_by'] ?? 'bidding_date'; + $sortDir = $params['sort_dir'] ?? 'desc'; + $query->orderBy($sortBy, $sortDir); + + // 페이지네이션 (최대 100개로 제한) + $perPage = min($params['per_page'] ?? 20, 100); + + return $query->paginate($perPage); + } + + /** + * 입찰 생성 (견적에서 전환 포함) + */ + public function store(array $data): Bidding + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + // 견적에서 전환하는 경우, 이미 입찰이 등록되었는지 체크 + if (! empty($data['quote_id'])) { + $existingBidding = Bidding::where('tenant_id', $tenantId) + ->where('quote_id', $data['quote_id']) + ->first(); + + if ($existingBidding) { + throw new \Symfony\Component\HttpKernel\Exception\ConflictHttpException( + __('error.bidding.already_registered', ['code' => $existingBidding->bidding_code]) + ); + } + } + + // 입찰번호 자동 생성 + $biddingCode = $this->generateBiddingCode($tenantId); + + $bidding = Bidding::create([ + 'tenant_id' => $tenantId, + 'bidding_code' => $biddingCode, + 'quote_id' => $data['quote_id'] ?? null, + // 거래처/현장 + 'client_id' => $data['client_id'] ?? null, + 'client_name' => $data['client_name'] ?? null, + 'project_name' => $data['project_name'], + // 입찰 정보 + 'bidding_date' => $data['bidding_date'] ?? now()->toDateString(), + 'bid_date' => $data['bid_date'] ?? null, + 'submission_date' => $data['submission_date'] ?? null, + 'confirm_date' => $data['confirm_date'] ?? null, + 'total_count' => $data['total_count'] ?? 0, + 'bidding_amount' => $data['bidding_amount'] ?? 0, + // 상태 (기본값: waiting) + 'status' => $data['status'] ?? Bidding::STATUS_WAITING, + // 입찰자 + 'bidder_id' => $data['bidder_id'] ?? null, + 'bidder_name' => $data['bidder_name'] ?? null, + // 공사기간 + 'construction_start_date' => $data['construction_start_date'] ?? null, + 'construction_end_date' => $data['construction_end_date'] ?? null, + 'vat_type' => $data['vat_type'] ?? Bidding::VAT_EXCLUDED, + // 비고 + 'remarks' => $data['remarks'] ?? null, + // 견적 데이터 스냅샷 + 'expense_items' => $data['expense_items'] ?? null, + 'estimate_detail_items' => $data['estimate_detail_items'] ?? null, + // 감사 + 'created_by' => $userId, + ]); + + return $bidding->load(['quote:id,quote_number']); + } + + /** + * 입찰번호 자동 생성 (BID-YYYY-NNN) + */ + private function generateBiddingCode(int $tenantId): string + { + $year = now()->year; + $prefix = "BID-{$year}-"; + + // 올해 생성된 마지막 입찰번호 조회 + $lastBidding = Bidding::where('tenant_id', $tenantId) + ->where('bidding_code', 'like', "{$prefix}%") + ->orderBy('bidding_code', 'desc') + ->first(); + + if ($lastBidding) { + // 마지막 번호에서 숫자 추출 후 +1 + $lastNumber = (int) substr($lastBidding->bidding_code, -3); + $nextNumber = $lastNumber + 1; + } else { + $nextNumber = 1; + } + + return $prefix.str_pad($nextNumber, 3, '0', STR_PAD_LEFT); + } + + /** + * 입찰 통계 조회 + */ + public function stats(): array + { + $tenantId = $this->tenantId(); + + return [ + 'total' => Bidding::where('tenant_id', $tenantId)->count(), + 'waiting' => Bidding::where('tenant_id', $tenantId)->where('status', Bidding::STATUS_WAITING)->count(), + 'submitted' => Bidding::where('tenant_id', $tenantId)->where('status', Bidding::STATUS_SUBMITTED)->count(), + 'awarded' => Bidding::where('tenant_id', $tenantId)->where('status', Bidding::STATUS_AWARDED)->count(), + 'failed' => Bidding::where('tenant_id', $tenantId)->where('status', Bidding::STATUS_FAILED)->count(), + 'invalid' => Bidding::where('tenant_id', $tenantId)->where('status', Bidding::STATUS_INVALID)->count(), + 'hold' => Bidding::where('tenant_id', $tenantId)->where('status', Bidding::STATUS_HOLD)->count(), + ]; + } + + /** + * 입찰 단건 조회 + */ + public function show(int $id): Bidding + { + $tenantId = $this->tenantId(); + + $bidding = Bidding::where('tenant_id', $tenantId) + ->with(['quote:id,quote_number,site_name,total_amount,status']) + ->find($id); + + if (! $bidding) { + throw new NotFoundHttpException(__('error.not_found')); + } + + return $bidding; + } + + /** + * 입찰 수정 + */ + public function update(int $id, array $data): Bidding + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $bidding = Bidding::where('tenant_id', $tenantId)->find($id); + + if (! $bidding) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $data['updated_by'] = $userId; + $bidding->update($data); + + return $bidding->fresh(['quote:id,quote_number']); + } + + /** + * 입찰 삭제 (소프트 삭제) + */ + public function destroy(int $id): bool + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $bidding = Bidding::where('tenant_id', $tenantId)->find($id); + + if (! $bidding) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $bidding->update(['deleted_by' => $userId]); + $bidding->delete(); + + return true; + } + + /** + * 입찰 일괄 삭제 + */ + public function bulkDestroy(array $ids): int + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $biddings = Bidding::where('tenant_id', $tenantId) + ->whereIn('id', $ids) + ->get(); + + $deletedCount = 0; + foreach ($biddings as $bidding) { + $bidding->update(['deleted_by' => $userId]); + $bidding->delete(); + $deletedCount++; + } + + return $deletedCount; + } + + /** + * 입찰 상태 변경 + */ + public function updateStatus(int $id, string $status): Bidding + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $bidding = Bidding::where('tenant_id', $tenantId)->find($id); + + if (! $bidding) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $bidding->update([ + 'status' => $status, + 'updated_by' => $userId, + ]); + + return $bidding->fresh(['quote:id,quote_number']); + } +} diff --git a/app/Swagger/v1/BiddingApi.php b/app/Swagger/v1/BiddingApi.php new file mode 100644 index 0000000..3ff921d --- /dev/null +++ b/app/Swagger/v1/BiddingApi.php @@ -0,0 +1,360 @@ +id(); + $table->foreignId('tenant_id')->constrained()->comment('테넌트 ID'); + + // 기본 정보 + $table->string('bidding_code', 50)->comment('입찰번호 (예: BID-2025-001)'); + $table->foreignId('quote_id')->nullable()->comment('연결된 견적 ID (quotes.id)'); + + // 거래처/현장 + $table->unsignedBigInteger('client_id')->nullable()->comment('거래처 ID'); + $table->string('client_name', 100)->nullable()->comment('거래처명 (스냅샷)'); + $table->string('project_name', 200)->nullable()->comment('현장명'); + + // 입찰 정보 + $table->date('bidding_date')->nullable()->comment('입찰일'); + $table->date('bid_date')->nullable()->comment('입찰일 (레거시 호환)'); + $table->date('submission_date')->nullable()->comment('투찰일'); + $table->date('confirm_date')->nullable()->comment('확정일'); + $table->unsignedInteger('total_count')->default(0)->comment('총 개소'); + $table->decimal('bidding_amount', 15, 2)->default(0)->comment('입찰금액'); + + // 상태 (waiting/submitted/failed/invalid/awarded/hold) + $table->string('status', 20)->default('waiting')->comment('상태'); + + // 입찰자 + $table->unsignedBigInteger('bidder_id')->nullable()->comment('입찰자 ID'); + $table->string('bidder_name', 50)->nullable()->comment('입찰자명 (스냅샷)'); + + // 공사기간 + $table->date('construction_start_date')->nullable()->comment('공사 시작일'); + $table->date('construction_end_date')->nullable()->comment('공사 종료일'); + $table->string('vat_type', 20)->default('excluded')->comment('부가세 (included/excluded)'); + + // 비고 + $table->text('remarks')->nullable()->comment('비고'); + + // 견적 데이터 스냅샷 (JSON) + $table->json('expense_items')->nullable()->comment('공과 항목 스냅샷'); + $table->json('estimate_detail_items')->nullable()->comment('견적 상세 항목 스냅샷'); + + // 감사 + $table->foreignId('created_by')->nullable()->comment('생성자'); + $table->foreignId('updated_by')->nullable()->comment('수정자'); + $table->foreignId('deleted_by')->nullable()->comment('삭제자'); + $table->timestamps(); + $table->softDeletes(); + + // 인덱스 + $table->index('tenant_id', 'idx_biddings_tenant_id'); + $table->index('status', 'idx_biddings_status'); + $table->index('bidding_date', 'idx_biddings_bidding_date'); + $table->index('quote_id', 'idx_biddings_quote_id'); + $table->unique(['tenant_id', 'bidding_code', 'deleted_at'], 'uq_tenant_bidding_code'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('biddings'); + } +}; diff --git a/database/seeders/BiddingSeeder.php b/database/seeders/BiddingSeeder.php new file mode 100644 index 0000000..46b3d02 --- /dev/null +++ b/database/seeders/BiddingSeeder.php @@ -0,0 +1,198 @@ +command->warn('테넌트가 없습니다. TenantSeeder를 먼저 실행하세요.'); + + return; + } + + $tenantId = $tenant->id; + + $biddings = [ + [ + 'bidding_code' => 'BID-2025-001', + 'client_name' => '이사대표', + 'project_name' => '광장 아파트', + 'bidding_date' => '2025-01-25', + 'total_count' => 15, + 'bidding_amount' => 71000000, + 'bid_date' => '2025-01-20', + 'submission_date' => '2025-01-22', + 'confirm_date' => '2025-01-25', + 'status' => 'awarded', + 'bidder_name' => '홍길동', + 'remarks' => '', + ], + [ + 'bidding_code' => 'BID-2025-002', + 'client_name' => '야사건설', + 'project_name' => '대림아파트', + 'bidding_date' => '2025-01-20', + 'total_count' => 22, + 'bidding_amount' => 100000000, + 'bid_date' => '2025-01-18', + 'submission_date' => null, + 'confirm_date' => null, + 'status' => 'waiting', + 'bidder_name' => '김철수', + 'remarks' => '', + ], + [ + 'bidding_code' => 'BID-2025-003', + 'client_name' => '여의건설', + 'project_name' => '현장아파트', + 'bidding_date' => '2025-01-18', + 'total_count' => 18, + 'bidding_amount' => 85000000, + 'bid_date' => '2025-01-15', + 'submission_date' => '2025-01-16', + 'confirm_date' => '2025-01-18', + 'status' => 'awarded', + 'bidder_name' => '홍길동', + 'remarks' => '', + ], + [ + 'bidding_code' => 'BID-2025-004', + 'client_name' => '이사대표', + 'project_name' => '송파타워', + 'bidding_date' => '2025-01-15', + 'total_count' => 30, + 'bidding_amount' => 120000000, + 'bid_date' => '2025-01-12', + 'submission_date' => '2025-01-13', + 'confirm_date' => '2025-01-15', + 'status' => 'failed', + 'bidder_name' => '이영희', + 'remarks' => '가격 경쟁력 부족', + ], + [ + 'bidding_code' => 'BID-2025-005', + 'client_name' => '야사건설', + 'project_name' => '강남센터', + 'bidding_date' => '2025-01-12', + 'total_count' => 25, + 'bidding_amount' => 95000000, + 'bid_date' => '2025-01-10', + 'submission_date' => '2025-01-11', + 'confirm_date' => null, + 'status' => 'submitted', + 'bidder_name' => '홍길동', + 'remarks' => '', + ], + [ + 'bidding_code' => 'BID-2025-006', + 'client_name' => '여의건설', + 'project_name' => '목동센터', + 'bidding_date' => '2025-01-10', + 'total_count' => 12, + 'bidding_amount' => 78000000, + 'bid_date' => '2025-01-08', + 'submission_date' => '2025-01-09', + 'confirm_date' => '2025-01-10', + 'status' => 'invalid', + 'bidder_name' => '김철수', + 'remarks' => '입찰 조건 미충족', + ], + [ + 'bidding_code' => 'BID-2025-007', + 'client_name' => '이사대표', + 'project_name' => '서초타워', + 'bidding_date' => '2025-01-08', + 'total_count' => 35, + 'bidding_amount' => 150000000, + 'bid_date' => '2025-01-05', + 'submission_date' => null, + 'confirm_date' => null, + 'status' => 'waiting', + 'bidder_name' => '이영희', + 'remarks' => '', + ], + [ + 'bidding_code' => 'BID-2025-008', + 'client_name' => '야사건설', + 'project_name' => '청담프로젝트', + 'bidding_date' => '2025-01-05', + 'total_count' => 40, + 'bidding_amount' => 200000000, + 'bid_date' => '2025-01-03', + 'submission_date' => '2025-01-04', + 'confirm_date' => '2025-01-05', + 'status' => 'awarded', + 'bidder_name' => '홍길동', + 'remarks' => '', + ], + [ + 'bidding_code' => 'BID-2025-009', + 'client_name' => '여의건설', + 'project_name' => '잠실센터', + 'bidding_date' => '2025-01-03', + 'total_count' => 20, + 'bidding_amount' => 88000000, + 'bid_date' => '2025-01-01', + 'submission_date' => null, + 'confirm_date' => null, + 'status' => 'hold', + 'bidder_name' => '김철수', + 'remarks' => '검토 대기 중', + ], + [ + 'bidding_code' => 'BID-2025-010', + 'client_name' => '이사대표', + 'project_name' => '역삼빌딩', + 'bidding_date' => '2025-01-01', + 'total_count' => 10, + 'bidding_amount' => 65000000, + 'bid_date' => '2024-12-28', + 'submission_date' => null, + 'confirm_date' => null, + 'status' => 'waiting', + 'bidder_name' => '이영희', + 'remarks' => '', + ], + ]; + + foreach ($biddings as $data) { + Bidding::create([ + 'tenant_id' => $tenantId, + 'bidding_code' => $data['bidding_code'], + 'client_name' => $data['client_name'], + 'project_name' => $data['project_name'], + 'bidding_date' => $data['bidding_date'], + 'total_count' => $data['total_count'], + 'bidding_amount' => $data['bidding_amount'], + 'bid_date' => $data['bid_date'], + 'submission_date' => $data['submission_date'], + 'confirm_date' => $data['confirm_date'], + 'status' => $data['status'], + 'bidder_name' => $data['bidder_name'], + 'remarks' => $data['remarks'], + 'vat_type' => 'excluded', + 'created_by' => 1, + ]); + } + + $this->command->info('입찰 더미데이터 10건이 생성되었습니다.'); + $this->command->info('- waiting: 3건 (BID-002, 007, 010)'); + $this->command->info('- awarded: 3건 (BID-001, 003, 008)'); + $this->command->info('- submitted: 1건 (BID-005)'); + $this->command->info('- failed: 1건 (BID-004)'); + $this->command->info('- invalid: 1건 (BID-006)'); + $this->command->info('- hold: 1건 (BID-009)'); + } +} diff --git a/lang/ko/error.php b/lang/ko/error.php index fa8fe5e..02dac5d 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -390,4 +390,13 @@ 'cannot_delete_completed' => '완료된 검사는 삭제할 수 없습니다.', 'already_completed' => '이미 완료된 검사입니다.', ], + + // 입찰 관련 + 'bidding' => [ + 'not_found' => '입찰을 찾을 수 없습니다.', + 'already_converted' => '이미 입찰로 변환된 견적입니다.', + 'already_registered' => '이미 입찰이 등록된 견적입니다. (입찰번호: :code)', + 'cannot_delete' => '해당 입찰은 삭제할 수 없습니다.', + 'invalid_status' => '유효하지 않은 입찰 상태입니다.', + ], ];