feat: 입찰(Bidding) 관리 기능 구현

- Bidding 모델, 서비스, 컨트롤러, FormRequest 추가
- 마이그레이션 및 시더 추가
- Swagger API 문서 추가
- 견적에서 입찰 전환 시 중복 체크 로직 추가
- per_page 파라미터 100 초과 시 자동 클램핑 처리
- error.bidding.already_registered 에러 메시지 추가
This commit is contained in:
2026-01-19 20:23:30 +09:00
parent 7282c1ee07
commit 7dd683ace8
12 changed files with 1436 additions and 0 deletions

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

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

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

View 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' => '견적']),
];
}
}

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

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

View 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;
}
}

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

View 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() {}
}

View File

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

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

View File

@@ -390,4 +390,13 @@
'cannot_delete_completed' => '완료된 검사는 삭제할 수 없습니다.',
'already_completed' => '이미 완료된 검사입니다.',
],
// 입찰 관련
'bidding' => [
'not_found' => '입찰을 찾을 수 없습니다.',
'already_converted' => '이미 입찰로 변환된 견적입니다.',
'already_registered' => '이미 입찰이 등록된 견적입니다. (입찰번호: :code)',
'cannot_delete' => '해당 입찰은 삭제할 수 없습니다.',
'invalid_status' => '유효하지 않은 입찰 상태입니다.',
],
];