Files
sam-api/app/Services/Bidding/BiddingService.php
hskwon 7dd683ace8 feat: 입찰(Bidding) 관리 기능 구현
- Bidding 모델, 서비스, 컨트롤러, FormRequest 추가
- 마이그레이션 및 시더 추가
- Swagger API 문서 추가
- 견적에서 입찰 전환 시 중복 체크 로직 추가
- per_page 파라미터 100 초과 시 자동 클램핑 처리
- error.bidding.already_registered 에러 메시지 추가
2026-01-19 20:23:30 +09:00

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']);
}
}