feat: 입찰(Bidding) 관리 기능 구현
- Bidding 모델, 서비스, 컨트롤러, FormRequest 추가 - 마이그레이션 및 시더 추가 - Swagger API 문서 추가 - 견적에서 입찰 전환 시 중복 체크 로직 추가 - per_page 파라미터 100 초과 시 자동 클램핑 처리 - error.bidding.already_registered 에러 메시지 추가
This commit is contained in:
81
app/Http/Controllers/Api/V1/BiddingController.php
Normal file
81
app/Http/Controllers/Api/V1/BiddingController.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\v1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Bidding\BiddingFilterRequest;
|
||||
use App\Http\Requests\Bidding\BiddingStatusRequest;
|
||||
use App\Http\Requests\Bidding\BiddingStoreRequest;
|
||||
use App\Http\Requests\Bidding\BiddingUpdateRequest;
|
||||
use App\Http\Requests\Bidding\BulkDeleteRequest;
|
||||
use App\Services\Bidding\BiddingService;
|
||||
|
||||
class BiddingController extends Controller
|
||||
{
|
||||
public function __construct(private BiddingService $service) {}
|
||||
|
||||
/**
|
||||
* 입찰 목록 조회
|
||||
*/
|
||||
public function index(BiddingFilterRequest $request)
|
||||
{
|
||||
return ApiResponse::handle(fn () => $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');
|
||||
}
|
||||
}
|
||||
36
app/Http/Requests/Bidding/BiddingFilterRequest.php
Normal file
36
app/Http/Requests/Bidding/BiddingFilterRequest.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Bidding;
|
||||
|
||||
use App\Models\Bidding\Bidding;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class BiddingFilterRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'status' => ['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')]),
|
||||
];
|
||||
}
|
||||
}
|
||||
30
app/Http/Requests/Bidding/BiddingStatusRequest.php
Normal file
30
app/Http/Requests/Bidding/BiddingStatusRequest.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Bidding;
|
||||
|
||||
use App\Models\Bidding\Bidding;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class BiddingStatusRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'status' => ['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')]),
|
||||
];
|
||||
}
|
||||
}
|
||||
56
app/Http/Requests/Bidding/BiddingStoreRequest.php
Normal file
56
app/Http/Requests/Bidding/BiddingStoreRequest.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Bidding;
|
||||
|
||||
use App\Models\Bidding\Bidding;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class BiddingStoreRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
// 견적 연결 (선택사항)
|
||||
'quote_id' => ['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' => '견적']),
|
||||
];
|
||||
}
|
||||
}
|
||||
46
app/Http/Requests/Bidding/BiddingUpdateRequest.php
Normal file
46
app/Http/Requests/Bidding/BiddingUpdateRequest.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Bidding;
|
||||
|
||||
use App\Models\Bidding\Bidding;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class BiddingUpdateRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
// 거래처/현장
|
||||
'client_id' => ['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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
30
app/Http/Requests/Bidding/BulkDeleteRequest.php
Normal file
30
app/Http/Requests/Bidding/BulkDeleteRequest.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Bidding;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class BulkDeleteRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'ids' => ['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]),
|
||||
];
|
||||
}
|
||||
}
|
||||
252
app/Models/Bidding/Bidding.php
Normal file
252
app/Models/Bidding/Bidding.php
Normal file
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Bidding;
|
||||
|
||||
use App\Models\Members\User;
|
||||
use App\Models\Orders\Client;
|
||||
use App\Models\Quote\Quote;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Bidding extends Model
|
||||
{
|
||||
use BelongsToTenant, HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'bidding_code',
|
||||
'quote_id',
|
||||
// 거래처/현장
|
||||
'client_id',
|
||||
'client_name',
|
||||
'project_name',
|
||||
// 입찰 정보
|
||||
'bidding_date',
|
||||
'bid_date',
|
||||
'submission_date',
|
||||
'confirm_date',
|
||||
'total_count',
|
||||
'bidding_amount',
|
||||
// 상태
|
||||
'status',
|
||||
// 입찰자
|
||||
'bidder_id',
|
||||
'bidder_name',
|
||||
// 공사기간
|
||||
'construction_start_date',
|
||||
'construction_end_date',
|
||||
'vat_type',
|
||||
// 비고
|
||||
'remarks',
|
||||
// 견적 데이터 스냅샷
|
||||
'expense_items',
|
||||
'estimate_detail_items',
|
||||
// 감사
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'bidding_date' => '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;
|
||||
}
|
||||
}
|
||||
261
app/Services/Bidding/BiddingService.php
Normal file
261
app/Services/Bidding/BiddingService.php
Normal file
@@ -0,0 +1,261 @@
|
||||
<?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']);
|
||||
}
|
||||
}
|
||||
360
app/Swagger/v1/BiddingApi.php
Normal file
360
app/Swagger/v1/BiddingApi.php
Normal file
@@ -0,0 +1,360 @@
|
||||
<?php
|
||||
|
||||
namespace App\Swagger\v1;
|
||||
|
||||
/**
|
||||
* @OA\Tag(
|
||||
* name="Bidding",
|
||||
* description="입찰관리 API"
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="Bidding",
|
||||
* type="object",
|
||||
*
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="tenant_id", type="integer", example=1),
|
||||
* @OA\Property(property="bidding_code", type="string", example="BID-20260119-0001"),
|
||||
* @OA\Property(property="quote_id", type="integer", nullable=true, example=1),
|
||||
* @OA\Property(property="client_id", type="integer", nullable=true, example=1),
|
||||
* @OA\Property(property="client_name", type="string", nullable=true, example="삼성물산"),
|
||||
* @OA\Property(property="project_name", type="string", nullable=true, example="강남역 오피스타워 신축공사"),
|
||||
* @OA\Property(property="bidding_date", type="string", format="date", nullable=true, example="2026-01-19"),
|
||||
* @OA\Property(property="bid_date", type="string", format="date", nullable=true, example="2026-02-01"),
|
||||
* @OA\Property(property="submission_date", type="string", format="date", nullable=true, example="2026-01-25"),
|
||||
* @OA\Property(property="confirm_date", type="string", format="date", nullable=true, example="2026-02-15"),
|
||||
* @OA\Property(property="total_count", type="integer", nullable=true, example=1),
|
||||
* @OA\Property(property="bidding_amount", type="number", format="float", nullable=true, example=150000000),
|
||||
* @OA\Property(property="status", type="string", enum={"waiting", "submitted", "failed", "invalid", "awarded", "hold"}, example="waiting"),
|
||||
* @OA\Property(property="bidder_id", type="integer", nullable=true, example=1),
|
||||
* @OA\Property(property="bidder_name", type="string", nullable=true, example="홍길동"),
|
||||
* @OA\Property(property="construction_start_date", type="string", format="date", nullable=true, example="2026-03-01"),
|
||||
* @OA\Property(property="construction_end_date", type="string", format="date", nullable=true, example="2026-12-31"),
|
||||
* @OA\Property(property="vat_type", type="string", enum={"included", "excluded"}, example="included"),
|
||||
* @OA\Property(property="remarks", type="string", nullable=true, example="특이사항 없음"),
|
||||
* @OA\Property(property="expense_items", type="array", nullable=true, @OA\Items(type="object")),
|
||||
* @OA\Property(property="estimate_detail_items", type="array", nullable=true, @OA\Items(type="object")),
|
||||
* @OA\Property(property="created_by", type="integer", nullable=true),
|
||||
* @OA\Property(property="updated_by", type="integer", nullable=true),
|
||||
* @OA\Property(property="created_at", type="string", format="date-time"),
|
||||
* @OA\Property(property="updated_at", type="string", format="date-time"),
|
||||
* @OA\Property(property="quote", type="object", nullable=true),
|
||||
* @OA\Property(property="client", type="object", nullable=true),
|
||||
* @OA\Property(property="bidder", type="object", nullable=true)
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="BiddingPagination",
|
||||
* type="object",
|
||||
*
|
||||
* @OA\Property(property="current_page", type="integer", example=1),
|
||||
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Bidding")),
|
||||
* @OA\Property(property="first_page_url", type="string"),
|
||||
* @OA\Property(property="from", type="integer"),
|
||||
* @OA\Property(property="last_page", type="integer"),
|
||||
* @OA\Property(property="last_page_url", type="string"),
|
||||
* @OA\Property(property="next_page_url", type="string", nullable=true),
|
||||
* @OA\Property(property="path", type="string"),
|
||||
* @OA\Property(property="per_page", type="integer"),
|
||||
* @OA\Property(property="prev_page_url", type="string", nullable=true),
|
||||
* @OA\Property(property="to", type="integer"),
|
||||
* @OA\Property(property="total", type="integer")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="BiddingStats",
|
||||
* type="object",
|
||||
*
|
||||
* @OA\Property(property="total", type="integer", example=100),
|
||||
* @OA\Property(property="waiting", type="integer", example=30),
|
||||
* @OA\Property(property="submitted", type="integer", example=20),
|
||||
* @OA\Property(property="failed", type="integer", example=10),
|
||||
* @OA\Property(property="invalid", type="integer", example=5),
|
||||
* @OA\Property(property="awarded", type="integer", example=30),
|
||||
* @OA\Property(property="hold", type="integer", example=5),
|
||||
* @OA\Property(property="total_amount", type="number", format="float", example=5000000000),
|
||||
* @OA\Property(property="awarded_amount", type="number", format="float", example=3000000000)
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="BiddingUpdateRequest",
|
||||
* type="object",
|
||||
*
|
||||
* @OA\Property(property="client_id", type="integer", nullable=true),
|
||||
* @OA\Property(property="client_name", type="string", nullable=true, maxLength=100),
|
||||
* @OA\Property(property="project_name", type="string", nullable=true, maxLength=200),
|
||||
* @OA\Property(property="bidding_date", type="string", format="date", nullable=true),
|
||||
* @OA\Property(property="bid_date", type="string", format="date", nullable=true),
|
||||
* @OA\Property(property="submission_date", type="string", format="date", nullable=true),
|
||||
* @OA\Property(property="confirm_date", type="string", format="date", nullable=true),
|
||||
* @OA\Property(property="total_count", type="integer", nullable=true, minimum=0),
|
||||
* @OA\Property(property="bidding_amount", type="number", format="float", nullable=true, minimum=0),
|
||||
* @OA\Property(property="status", type="string", enum={"waiting", "submitted", "failed", "invalid", "awarded", "hold"}, nullable=true),
|
||||
* @OA\Property(property="bidder_id", type="integer", nullable=true),
|
||||
* @OA\Property(property="bidder_name", type="string", nullable=true, maxLength=50),
|
||||
* @OA\Property(property="construction_start_date", type="string", format="date", nullable=true),
|
||||
* @OA\Property(property="construction_end_date", type="string", format="date", nullable=true),
|
||||
* @OA\Property(property="vat_type", type="string", enum={"included", "excluded"}, nullable=true),
|
||||
* @OA\Property(property="remarks", type="string", nullable=true),
|
||||
* @OA\Property(property="expense_items", type="array", nullable=true, @OA\Items(type="object")),
|
||||
* @OA\Property(property="estimate_detail_items", type="array", nullable=true, @OA\Items(type="object"))
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="BiddingStatusRequest",
|
||||
* type="object",
|
||||
* required={"status"},
|
||||
*
|
||||
* @OA\Property(property="status", type="string", enum={"waiting", "submitted", "failed", "invalid", "awarded", "hold"}, example="submitted")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="BiddingBulkDeleteRequest",
|
||||
* type="object",
|
||||
* required={"ids"},
|
||||
*
|
||||
* @OA\Property(property="ids", type="array", @OA\Items(type="integer"), example={1, 2, 3})
|
||||
* )
|
||||
*/
|
||||
class BiddingApi
|
||||
{
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/biddings",
|
||||
* tags={"Bidding"},
|
||||
* summary="입찰 목록 조회",
|
||||
* description="입찰 목록을 페이징하여 조회합니다.",
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(name="page", in="query", @OA\Schema(type="integer", default=1)),
|
||||
* @OA\Parameter(name="size", in="query", @OA\Schema(type="integer", default=20)),
|
||||
* @OA\Parameter(name="q", in="query", description="검색어 (입찰번호, 현장명, 거래처명)", @OA\Schema(type="string")),
|
||||
* @OA\Parameter(name="status", in="query", description="상태 필터", @OA\Schema(type="string", enum={"waiting", "submitted", "failed", "invalid", "awarded", "hold"})),
|
||||
* @OA\Parameter(name="client_id", in="query", description="거래처 ID", @OA\Schema(type="integer")),
|
||||
* @OA\Parameter(name="bidder_id", in="query", description="입찰담당자 ID", @OA\Schema(type="integer")),
|
||||
* @OA\Parameter(name="date_from", in="query", description="입찰일 시작", @OA\Schema(type="string", format="date")),
|
||||
* @OA\Parameter(name="date_to", in="query", description="입찰일 종료", @OA\Schema(type="string", format="date")),
|
||||
* @OA\Parameter(name="sort_by", in="query", description="정렬 기준", @OA\Schema(type="string", default="bidding_date")),
|
||||
* @OA\Parameter(name="sort_order", in="query", description="정렬 순서", @OA\Schema(type="string", enum={"asc", "desc"}, default="desc")),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/BiddingPagination")
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function index() {}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/biddings/stats",
|
||||
* tags={"Bidding"},
|
||||
* summary="입찰 통계 조회",
|
||||
* description="상태별 입찰 건수와 금액을 조회합니다.",
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/BiddingStats")
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function stats() {}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/biddings/{id}",
|
||||
* tags={"Bidding"},
|
||||
* summary="입찰 단건 조회",
|
||||
* description="입찰 상세 정보를 조회합니다.",
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Bidding")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=404, description="입찰을 찾을 수 없습니다")
|
||||
* )
|
||||
*/
|
||||
public function show() {}
|
||||
|
||||
/**
|
||||
* @OA\Put(
|
||||
* path="/api/v1/biddings/{id}",
|
||||
* tags={"Bidding"},
|
||||
* summary="입찰 수정",
|
||||
* description="입찰 정보를 수정합니다.",
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
*
|
||||
* @OA\JsonContent(ref="#/components/schemas/BiddingUpdateRequest")
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Bidding")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=404, description="입찰을 찾을 수 없습니다"),
|
||||
* @OA\Response(response=422, description="유효성 검증 실패")
|
||||
* )
|
||||
*/
|
||||
public function update() {}
|
||||
|
||||
/**
|
||||
* @OA\Delete(
|
||||
* path="/api/v1/biddings/{id}",
|
||||
* tags={"Bidding"},
|
||||
* summary="입찰 삭제",
|
||||
* description="입찰을 삭제합니다 (Soft Delete).",
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string"),
|
||||
* @OA\Property(property="data", type="null")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=404, description="입찰을 찾을 수 없습니다")
|
||||
* )
|
||||
*/
|
||||
public function destroy() {}
|
||||
|
||||
/**
|
||||
* @OA\Delete(
|
||||
* path="/api/v1/biddings/bulk",
|
||||
* tags={"Bidding"},
|
||||
* summary="입찰 일괄 삭제",
|
||||
* description="여러 입찰을 일괄 삭제합니다 (Soft Delete).",
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
*
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
*
|
||||
* @OA\JsonContent(ref="#/components/schemas/BiddingBulkDeleteRequest")
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string"),
|
||||
* @OA\Property(property="data", type="object",
|
||||
* @OA\Property(property="deleted_count", type="integer", example=3)
|
||||
* )
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=422, description="유효성 검증 실패")
|
||||
* )
|
||||
*/
|
||||
public function bulkDestroy() {}
|
||||
|
||||
/**
|
||||
* @OA\Patch(
|
||||
* path="/api/v1/biddings/{id}/status",
|
||||
* tags={"Bidding"},
|
||||
* summary="입찰 상태 변경",
|
||||
* description="입찰 상태를 변경합니다.",
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
*
|
||||
* @OA\JsonContent(ref="#/components/schemas/BiddingStatusRequest")
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Bidding")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=404, description="입찰을 찾을 수 없습니다"),
|
||||
* @OA\Response(response=422, description="유효성 검증 실패")
|
||||
* )
|
||||
*/
|
||||
public function updateStatus() {}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/quotes/{id}/convert-to-bidding",
|
||||
* tags={"Bidding"},
|
||||
* summary="견적을 입찰로 변환",
|
||||
* description="시공 견적을 입찰로 변환합니다. 견적 데이터 스냅샷이 저장됩니다.",
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="견적 ID", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Bidding")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=404, description="견적을 찾을 수 없습니다"),
|
||||
* @OA\Response(response=400, description="이미 입찰로 변환된 견적입니다")
|
||||
* )
|
||||
*/
|
||||
public function convertToBidding() {}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('biddings', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
198
database/seeders/BiddingSeeder.php
Normal file
198
database/seeders/BiddingSeeder.php
Normal file
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Bidding\Bidding;
|
||||
use App\Models\Tenants\Tenant;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class BiddingSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* 입찰 더미데이터 10건 Seed
|
||||
* React 목업 데이터 기준
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// 첫 번째 테넌트 ID 가져오기
|
||||
$tenant = Tenant::first();
|
||||
if (! $tenant) {
|
||||
$this->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)');
|
||||
}
|
||||
}
|
||||
@@ -390,4 +390,13 @@
|
||||
'cannot_delete_completed' => '완료된 검사는 삭제할 수 없습니다.',
|
||||
'already_completed' => '이미 완료된 검사입니다.',
|
||||
],
|
||||
|
||||
// 입찰 관련
|
||||
'bidding' => [
|
||||
'not_found' => '입찰을 찾을 수 없습니다.',
|
||||
'already_converted' => '이미 입찰로 변환된 견적입니다.',
|
||||
'already_registered' => '이미 입찰이 등록된 견적입니다. (입찰번호: :code)',
|
||||
'cannot_delete' => '해당 입찰은 삭제할 수 없습니다.',
|
||||
'invalid_status' => '유효하지 않은 입찰 상태입니다.',
|
||||
],
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user