- Bidding 모델, 서비스, 컨트롤러, FormRequest 추가 - 마이그레이션 및 시더 추가 - Swagger API 문서 추가 - 견적에서 입찰 전환 시 중복 체크 로직 추가 - per_page 파라미터 100 초과 시 자동 클램핑 처리 - error.bidding.already_registered 에러 메시지 추가
262 lines
8.3 KiB
PHP
262 lines
8.3 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Bidding;
|
|
|
|
use App\Models\Bidding\Bidding;
|
|
use App\Services\Service;
|
|
use Illuminate\Pagination\LengthAwarePaginator;
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
|
|
class BiddingService extends Service
|
|
{
|
|
/**
|
|
* 입찰 목록 조회 (페이지네이션, 필터링)
|
|
*/
|
|
public function index(array $params): LengthAwarePaginator
|
|
{
|
|
$tenantId = $this->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']);
|
|
}
|
|
}
|