tenantId(); $page = (int) ($params['page'] ?? 1); $size = (int) ($params['size'] ?? 20); $q = trim((string) ($params['q'] ?? '')); $status = $params['status'] ?? null; $productCategory = $params['product_category'] ?? null; $clientId = $params['client_id'] ?? null; $dateFrom = $params['date_from'] ?? null; $dateTo = $params['date_to'] ?? null; $sortBy = $params['sort_by'] ?? 'registration_date'; $sortOrder = $params['sort_order'] ?? 'desc'; $query = Quote::query()->where('tenant_id', $tenantId); // 검색어 if ($q !== '') { $query->search($q); } // 상태 필터 if ($status) { $query->where('status', $status); } // 제품 카테고리 필터 if ($productCategory) { $query->where('product_category', $productCategory); } // 발주처 필터 if ($clientId) { $query->where('client_id', $clientId); } // 날짜 범위 $query->dateRange($dateFrom, $dateTo); // 정렬 $allowedSortColumns = ['registration_date', 'quote_number', 'client_name', 'total_amount', 'status', 'created_at']; if (in_array($sortBy, $allowedSortColumns)) { $query->orderBy($sortBy, $sortOrder === 'asc' ? 'asc' : 'desc'); } else { $query->orderBy('registration_date', 'desc'); } $query->orderBy('id', 'desc'); return $query->paginate($size, ['*'], 'page', $page); } /** * 견적 단건 조회 */ public function show(int $id): Quote { $tenantId = $this->tenantId(); $quote = Quote::with(['items', 'revisions', 'client', 'creator', 'updater', 'finalizer']) ->where('tenant_id', $tenantId) ->find($id); if (! $quote) { throw new NotFoundHttpException(__('error.quote_not_found')); } return $quote; } /** * 견적 생성 */ public function store(array $data): Quote { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); return DB::transaction(function () use ($data, $tenantId, $userId) { // 견적번호 생성 $quoteNumber = $data['quote_number'] ?? $this->numberService->generate($data['product_category'] ?? 'SCREEN'); // 금액 계산 $materialCost = (float) ($data['material_cost'] ?? 0); $laborCost = (float) ($data['labor_cost'] ?? 0); $installCost = (float) ($data['install_cost'] ?? 0); $subtotal = $materialCost + $laborCost + $installCost; $discountRate = (float) ($data['discount_rate'] ?? 0); $discountAmount = $subtotal * ($discountRate / 100); $totalAmount = $subtotal - $discountAmount; // 견적 생성 $quote = Quote::create([ 'tenant_id' => $tenantId, 'quote_number' => $quoteNumber, 'registration_date' => $data['registration_date'] ?? now()->toDateString(), 'receipt_date' => $data['receipt_date'] ?? null, 'author' => $data['author'] ?? null, // 발주처 정보 'client_id' => $data['client_id'] ?? null, 'client_name' => $data['client_name'] ?? null, 'manager' => $data['manager'] ?? null, 'contact' => $data['contact'] ?? null, // 현장 정보 'site_id' => $data['site_id'] ?? null, 'site_name' => $data['site_name'] ?? null, 'site_code' => $data['site_code'] ?? null, // 제품 정보 'product_category' => $data['product_category'] ?? Quote::CATEGORY_SCREEN, 'product_id' => $data['product_id'] ?? null, 'product_code' => $data['product_code'] ?? null, 'product_name' => $data['product_name'] ?? null, // 규격 정보 'open_size_width' => $data['open_size_width'] ?? null, 'open_size_height' => $data['open_size_height'] ?? null, 'quantity' => $data['quantity'] ?? 1, 'unit_symbol' => $data['unit_symbol'] ?? null, 'floors' => $data['floors'] ?? null, // 금액 정보 'material_cost' => $materialCost, 'labor_cost' => $laborCost, 'install_cost' => $installCost, 'subtotal' => $subtotal, 'discount_rate' => $discountRate, 'discount_amount' => $discountAmount, 'total_amount' => $data['total_amount'] ?? $totalAmount, // 상태 관리 'status' => Quote::STATUS_DRAFT, 'current_revision' => 0, 'is_final' => false, // 기타 정보 'completion_date' => $data['completion_date'] ?? null, 'remarks' => $data['remarks'] ?? null, 'memo' => $data['memo'] ?? null, 'notes' => $data['notes'] ?? null, // 자동산출 입력값 'calculation_inputs' => $data['calculation_inputs'] ?? null, // 감사 'created_by' => $userId, ]); // 견적 품목 생성 if (! empty($data['items']) && is_array($data['items'])) { $this->createItems($quote, $data['items'], $tenantId); } return $quote->load(['items', 'client']); }); } /** * 견적 수정 */ public function update(int $id, array $data): Quote { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $quote = Quote::where('tenant_id', $tenantId)->find($id); if (! $quote) { throw new NotFoundHttpException(__('error.quote_not_found')); } if (! $quote->isEditable()) { throw new BadRequestHttpException(__('error.quote_not_editable')); } return DB::transaction(function () use ($quote, $data, $tenantId, $userId) { // 수정 이력 생성 $this->createRevision($quote, $userId); // 금액 재계산 $materialCost = (float) ($data['material_cost'] ?? $quote->material_cost); $laborCost = (float) ($data['labor_cost'] ?? $quote->labor_cost); $installCost = (float) ($data['install_cost'] ?? $quote->install_cost); $subtotal = $materialCost + $laborCost + $installCost; $discountRate = (float) ($data['discount_rate'] ?? $quote->discount_rate); $discountAmount = $subtotal * ($discountRate / 100); $totalAmount = $subtotal - $discountAmount; // 업데이트 $quote->update([ 'receipt_date' => $data['receipt_date'] ?? $quote->receipt_date, 'author' => $data['author'] ?? $quote->author, // 발주처 정보 'client_id' => $data['client_id'] ?? $quote->client_id, 'client_name' => $data['client_name'] ?? $quote->client_name, 'manager' => $data['manager'] ?? $quote->manager, 'contact' => $data['contact'] ?? $quote->contact, // 현장 정보 'site_id' => $data['site_id'] ?? $quote->site_id, 'site_name' => $data['site_name'] ?? $quote->site_name, 'site_code' => $data['site_code'] ?? $quote->site_code, // 제품 정보 'product_category' => $data['product_category'] ?? $quote->product_category, 'product_id' => $data['product_id'] ?? $quote->product_id, 'product_code' => $data['product_code'] ?? $quote->product_code, 'product_name' => $data['product_name'] ?? $quote->product_name, // 규격 정보 'open_size_width' => $data['open_size_width'] ?? $quote->open_size_width, 'open_size_height' => $data['open_size_height'] ?? $quote->open_size_height, 'quantity' => $data['quantity'] ?? $quote->quantity, 'unit_symbol' => $data['unit_symbol'] ?? $quote->unit_symbol, 'floors' => $data['floors'] ?? $quote->floors, // 금액 정보 'material_cost' => $materialCost, 'labor_cost' => $laborCost, 'install_cost' => $installCost, 'subtotal' => $subtotal, 'discount_rate' => $discountRate, 'discount_amount' => $discountAmount, 'total_amount' => $data['total_amount'] ?? $totalAmount, // 기타 정보 'completion_date' => $data['completion_date'] ?? $quote->completion_date, 'remarks' => $data['remarks'] ?? $quote->remarks, 'memo' => $data['memo'] ?? $quote->memo, 'notes' => $data['notes'] ?? $quote->notes, // 자동산출 입력값 'calculation_inputs' => $data['calculation_inputs'] ?? $quote->calculation_inputs, // 감사 'updated_by' => $userId, 'current_revision' => $quote->current_revision + 1, ]); // 품목 업데이트 (전체 교체) if (isset($data['items']) && is_array($data['items'])) { $quote->items()->delete(); $this->createItems($quote, $data['items'], $tenantId); } return $quote->refresh()->load(['items', 'revisions', 'client']); }); } /** * 견적 삭제 (Soft Delete) */ public function destroy(int $id): bool { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $quote = Quote::where('tenant_id', $tenantId)->find($id); if (! $quote) { throw new NotFoundHttpException(__('error.quote_not_found')); } if (! $quote->isDeletable()) { throw new BadRequestHttpException(__('error.quote_not_deletable')); } $quote->deleted_by = $userId; $quote->save(); $quote->delete(); return true; } /** * 견적 일괄 삭제 */ public function bulkDestroy(array $ids): int { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $deletedCount = 0; foreach ($ids as $id) { $quote = Quote::where('tenant_id', $tenantId)->find($id); if ($quote && $quote->isDeletable()) { $quote->deleted_by = $userId; $quote->save(); $quote->delete(); $deletedCount++; } } return $deletedCount; } /** * 최종 확정 */ public function finalize(int $id): Quote { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $quote = Quote::where('tenant_id', $tenantId)->find($id); if (! $quote) { throw new NotFoundHttpException(__('error.quote_not_found')); } if (! $quote->isFinalizable()) { throw new BadRequestHttpException(__('error.quote_not_finalizable')); } $quote->update([ 'status' => Quote::STATUS_FINALIZED, 'is_final' => true, 'finalized_at' => now(), 'finalized_by' => $userId, 'updated_by' => $userId, ]); return $quote->refresh()->load(['items', 'client', 'finalizer']); } /** * 확정 취소 */ public function cancelFinalize(int $id): Quote { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $quote = Quote::where('tenant_id', $tenantId)->find($id); if (! $quote) { throw new NotFoundHttpException(__('error.quote_not_found')); } if ($quote->status !== Quote::STATUS_FINALIZED) { throw new BadRequestHttpException(__('error.quote_not_finalized')); } if ($quote->status === Quote::STATUS_CONVERTED) { throw new BadRequestHttpException(__('error.quote_already_converted')); } $quote->update([ 'status' => Quote::STATUS_DRAFT, 'is_final' => false, 'finalized_at' => null, 'finalized_by' => null, 'updated_by' => $userId, ]); return $quote->refresh()->load(['items', 'client']); } /** * 수주 전환 */ public function convertToOrder(int $id): Quote { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $quote = Quote::where('tenant_id', $tenantId)->find($id); if (! $quote) { throw new NotFoundHttpException(__('error.quote_not_found')); } if (! $quote->isConvertible()) { throw new BadRequestHttpException(__('error.quote_not_convertible')); } return DB::transaction(function () use ($quote, $userId) { // TODO: 수주(Order) 생성 로직 구현 // $order = $this->orderService->createFromQuote($quote); $quote->update([ 'status' => Quote::STATUS_CONVERTED, 'updated_by' => $userId, ]); return $quote->refresh()->load(['items', 'client']); }); } /** * 견적 품목 생성 */ private function createItems(Quote $quote, array $items, int $tenantId): void { foreach ($items as $index => $item) { $quantity = (float) ($item['calculated_quantity'] ?? $item['base_quantity'] ?? 1); $unitPrice = (float) ($item['unit_price'] ?? 0); $totalPrice = $quantity * $unitPrice; QuoteItem::create([ 'quote_id' => $quote->id, 'tenant_id' => $tenantId, 'item_id' => $item['item_id'] ?? null, 'item_code' => $item['item_code'] ?? '', 'item_name' => $item['item_name'] ?? '', 'specification' => $item['specification'] ?? null, 'unit' => $item['unit'] ?? 'EA', 'base_quantity' => $item['base_quantity'] ?? 1, 'calculated_quantity' => $quantity, 'unit_price' => $unitPrice, 'total_price' => $item['total_price'] ?? $totalPrice, 'formula' => $item['formula'] ?? null, 'formula_result' => $item['formula_result'] ?? null, 'formula_source' => $item['formula_source'] ?? null, 'formula_category' => $item['formula_category'] ?? null, 'data_source' => $item['data_source'] ?? null, 'delivery_date' => $item['delivery_date'] ?? null, 'note' => $item['note'] ?? null, 'sort_order' => $item['sort_order'] ?? $index, ]); } } /** * 수정 이력 생성 */ private function createRevision(Quote $quote, int $userId): QuoteRevision { // 현재 견적 데이터 스냅샷 $previousData = $quote->toArray(); $previousData['items'] = $quote->items->toArray(); return QuoteRevision::create([ 'quote_id' => $quote->id, 'tenant_id' => $quote->tenant_id, 'revision_number' => $quote->current_revision + 1, 'revision_date' => now()->toDateString(), 'revision_by' => $userId, 'revision_by_name' => auth()->user()?->name ?? 'Unknown', 'revision_reason' => null, // 별도 입력 받지 않음 'previous_data' => $previousData, ]); } }