member = $member; $this->is_admin = $is_admin; $this->status_manager = new StatusManager(); } // ============================================================================== // 핵심 견적 관리 메서드들 (Requirements 8.1, 8.2, 9.2, 10.3) // StatusManager와 연동하여 체계적인 상태 관리 제공 // ============================================================================== /** * 견적 생성 (StatusManager 연동) * * @param array $data 견적 데이터 * @param string $user_role 사용자 역할 (customer, agent, admin) * @return array ['success' => bool, 'data' => array, 'message' => string] */ public function createEstimateWithStatus($data, $user_role = 'customer') { try { sql_query("START TRANSACTION"); // 1. 기본 견적 생성 $estimate_id = $this->createEstimate($data); if (!$estimate_id) { throw new Exception('견적 생성에 실패했습니다.'); } // 2. StatusManager를 통한 초기 상태 설정 $user_id = $this->member['mb_id'] ?? 'system'; $status_result = $this->status_manager->changeStatus( $data['wr_id'], '견적신청중', $user_id, $user_role, '견적 생성' ); if (!$status_result['success']) { throw new Exception('상태 설정 실패: ' . $status_result['message']); } sql_query("COMMIT"); return [ 'success' => true, 'data' => [ 'estimate_id' => $estimate_id, 'wr_id' => $data['wr_id'], 'status' => '견적신청중' ], 'message' => '견적이 성공적으로 생성되었습니다.' ]; } catch (Exception $e) { sql_query("ROLLBACK"); error_log("EstimateManager::createEstimateWithStatus Error: " . $e->getMessage()); return [ 'success' => false, 'data' => null, 'message' => $e->getMessage() ]; } } /** * 견적 완료 처리 (StatusManager 연동) * * @param int $wr_id 게시물 ID * @param string $user_role 사용자 역할 * @return array ['success' => bool, 'data' => array, 'message' => string] */ public function completeEstimate($wr_id, $user_role = 'customer') { try { // 1. 견적 데이터 검증 $estimate = sql_fetch("SELECT * FROM {$this->estimate_table} WHERE wr_id = '{$wr_id}' AND is_deleted = 0"); if (!$estimate) { throw new Exception('견적을 찾을 수 없습니다.'); } // 2. 견적 항목 존재 확인 $item_count = sql_fetch("SELECT COUNT(*) as cnt FROM {$this->item_table} WHERE estimate_id = '{$estimate['id']}' AND is_deleted = 0"); if (!$item_count || $item_count['cnt'] == 0) { throw new Exception('견적 항목이 없습니다. 최소 1개 이상의 항목을 추가해주세요.'); } // 3. StatusManager를 통한 상태 변경 $user_id = $this->member['mb_id'] ?? 'system'; $status_result = $this->status_manager->changeStatus( $wr_id, '작성완료', $user_id, $user_role, '고객 견적 작성 완료' ); if (!$status_result['success']) { throw new Exception('상태 변경 실패: ' . $status_result['message']); } // ❗ [핵심 수정] '작성완료' 상태 변경 후 알림을 발송합니다. if (function_exists('notify_by_status')) { notify_by_status($wr_id, '작성완료'); } // 4. 24시간 입찰 마감시간 설정 $deadline = date('Y-m-d H:i:s', strtotime('+24 hours')); $update_result = $this->update($this->estimate_table, $estimate['id'], [ 'bidding_deadline' => $deadline, 'updated_at' => date('Y-m-d H:i:s') ]); if (!$update_result) { throw new Exception('입찰 마감시간 설정에 실패했습니다.'); } return [ 'success' => true, 'data' => [ 'estimate_id' => $estimate['id'], 'wr_id' => $wr_id, 'status' => '작성완료', 'bidding_deadline' => $deadline ], 'message' => '견적 작성이 완료되었습니다. 24시간 후에 견적을 확인해주세요.' ]; } catch (Exception $e) { error_log("EstimateManager::completeEstimate Error: " . $e->getMessage()); return [ 'success' => false, 'data' => null, 'message' => $e->getMessage() ]; } } /** * 새 견적을 생성합니다 (Requirement 8.1, 8.2) * @param array $data 견적 데이터 * @return int|bool estimate_id 또는 실패 시 false */ public function createEstimate($data) { sql_query("START TRANSACTION"); try { $customer_id = $this->member['mb_id'] ?? ''; $estimate_data = [ 'wr_id' => $data['wr_id'], 'status' => '수정중', 'zip_code' => trim($data['zip_code'] ?? ''), 'address1' => trim($data['address1'] ?? ''), 'address2' => trim($data['address2'] ?? ''), 'address3' => trim($data['address3'] ?? ''), 'house_type' => trim($data['house_type'] ?? ''), 'house_size' => trim($data['house_size'] ?? ''), 'material' => trim($data['material'] ?? ''), 'color' => trim($data['color'] ?? ''), 'glass_thickness' => trim($data['glass_thickness'] ?? ''), 'install' => trim($data['install'] ?? ''), 'created_by' => $customer_id, 'updated_by' => $customer_id, 'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'), ]; $estimate_id = $this->create($this->estimate_table, $estimate_data); if (!$estimate_id) { throw new Exception('견적서 생성 실패'); } // 견적 항목들 저장 if (isset($data['items']) && is_array($data['items'])) { foreach ($data['items'] as $index => $item) { $item['estimate_id'] = $estimate_id; $item['no'] = $index + 1; $this->createEstimateItem($estimate_id, $item); } } $this->logHistory($estimate_id, null, 'estimate_created', $estimate_data); sql_query("COMMIT"); return $estimate_id; } catch (Exception $e) { sql_query("ROLLBACK"); error_log("createEstimate 오류: " . $e->getMessage()); return false; } } /** * 견적 선택 (StatusManager 연동 개선) * * @param int $estimate_id 견적서 ID * @param int $bid_id 선택할 입찰 ID * @param string $user_role 사용자 역할 * @return array ['success' => bool, 'data' => array, 'message' => string] */ public function selectBidWithStatus($estimate_id, $bid_id, $user_role = 'customer') { try { sql_query("START TRANSACTION"); // 1. 견적서와 입찰 정보 확인 $estimate = sql_fetch("SELECT * FROM {$this->estimate_table} WHERE id = '{$estimate_id}' AND is_deleted = 0"); if (!$estimate) { throw new Exception('견적서를 찾을 수 없습니다.'); } $bid = sql_fetch("SELECT * FROM {$this->bidding_table} WHERE id = '{$bid_id}' AND estimate_id = '{$estimate_id}'"); if (!$bid) { throw new Exception('입찰 정보를 찾을 수 없습니다.'); } // 2. 24시간 경과 확인 if ($estimate['bidding_deadline'] && strtotime($estimate['bidding_deadline']) > time()) { throw new Exception('아직 입찰 마감시간이 되지 않았습니다.'); } // 3. 선택된 입찰의 답변글 ID 찾기 $reply_write = sql_fetch(" SELECT wr_id FROM g5_write_order WHERE wr_parent = '{$estimate['wr_id']}' AND mb_id = '{$bid['dealer_id']}' AND wr_1 = '견적제안' "); if (!$reply_write) { throw new Exception('해당 견적의 답변글을 찾을 수 없습니다.'); } // 4. StatusManager를 통한 답변글 상태 변경 (견적채택) $user_id = $this->member['mb_id'] ?? 'system'; $status_result = $this->status_manager->changeStatus( $reply_write['wr_id'], '견적채택', $user_id, $user_role, "견적 선택 - 대리점: {$bid['dealer_id']}" ); if (!$status_result['success']) { throw new Exception('견적 상태 변경 실패: ' . $status_result['message']); } // 5. 견적서 테이블 업데이트 $update_result = $this->update($this->estimate_table, $estimate_id, [ 'selected_bid_id' => $bid_id, 'updated_at' => date('Y-m-d H:i:s') ]); if (!$update_result) { throw new Exception('견적서 업데이트에 실패했습니다.'); } // 6. 입찰 상태 업데이트 sql_query("UPDATE {$this->bidding_table} SET status = 'rejected' WHERE estimate_id = '{$estimate_id}'"); sql_query("UPDATE {$this->bidding_table} SET status = 'selected' WHERE id = '{$bid_id}'"); // 7. 결제 정보 계산 $payment_info = $this->calculatePaymentAmount($estimate_id, 'deposit'); // 8. 대리점 알림 발송 $this->sendDealerSelectedNotification($estimate['wr_id'], $bid['dealer_id'], $bid); sql_query("COMMIT"); return [ 'success' => true, 'data' => [ 'estimate_id' => $estimate_id, 'selected_bid_id' => $bid_id, 'dealer_id' => $bid['dealer_id'], 'total_amount' => $bid['total_amount'], 'payment_info' => $payment_info ], 'message' => '견적이 선택되었습니다. 계약금을 입금해주세요.' ]; } catch (Exception $e) { sql_query("ROLLBACK"); error_log('EstimateManager::selectBidWithStatus Error: ' . $e->getMessage()); return [ 'success' => false, 'data' => null, 'message' => $e->getMessage() ]; } } /** * 기존 호환성을 위한 selectBid 메서드 (Deprecated) * @deprecated selectBidWithStatus 사용 권장 */ public function selectBid($estimate_id, $bid_id) { $result = $this->selectBidWithStatus($estimate_id, $bid_id, 'customer'); return $result['success']; } /** * 결제 금액 계산 (개선된 버전) * * @param int $estimate_id 견적서 ID * @param string $type 결제 타입 ('deposit', 'interim', 'final') * @return array|false 결제 정보 배열 또는 실패 시 false */ public function calculatePaymentAmount($estimate_id, $type = 'deposit') { try { // 1. 견적서 정보 조회 $estimate = sql_fetch("SELECT * FROM {$this->estimate_table} WHERE id = '{$estimate_id}' AND is_deleted = 0"); if (!$estimate) { throw new Exception('견적서를 찾을 수 없습니다.'); } // 2. 선택된 입찰 정보 조회 if (!$estimate['selected_bid_id']) { throw new Exception('선택된 입찰이 없습니다.'); } $bid = sql_fetch("SELECT * FROM {$this->bidding_table} WHERE id = '{$estimate['selected_bid_id']}'"); if (!$bid) { throw new Exception('선택된 입찰 정보를 찾을 수 없습니다.'); } $total_amount = (int) $bid['total_amount']; // 3. 설정에서 결제 비율 조회 $config = $this->getOrderConfig(); $deposit_rate = (float) ($config['contract_deposit_rate'] ?? 10); $interim_rate = (float) ($config['middle_payment_rate'] ?? 40); // 잔금 비율은 자동 계산 (100 - 계약금 - 중도금) $final_rate = 100 - $deposit_rate - $interim_rate; // 4. 각 단계별 금액 계산 $deposit_amount = (int) ($total_amount * $deposit_rate / 100); $interim_amount = (int) ($total_amount * $interim_rate / 100); $final_amount = $total_amount - $deposit_amount - $interim_amount; $payment_info = [ 'estimate_id' => $estimate_id, 'total_amount' => $total_amount, 'deposit_amount' => $deposit_amount, 'interim_amount' => $interim_amount, 'final_amount' => $final_amount, 'deposit_rate' => $deposit_rate, 'interim_rate' => $interim_rate, 'final_rate' => $final_rate, 'dealer_id' => $bid['dealer_id'], 'selected_bid_id' => $bid['id'] ]; // 5. 요청된 타입에 따른 현재 결제 금액 설정 switch ($type) { case 'deposit': $payment_info['current_amount'] = $deposit_amount; $payment_info['current_type'] = '계약금'; break; case 'interim': $payment_info['current_amount'] = $interim_amount; $payment_info['current_type'] = '중도금'; break; case 'final': $payment_info['current_amount'] = $final_amount; $payment_info['current_type'] = '잔금'; break; default: $payment_info['current_amount'] = $deposit_amount; $payment_info['current_type'] = '계약금'; } // 6. 계좌 정보 추가 $payment_info['account_info'] = $config['payment_account_info'] ?? '계좌 정보가 설정되지 않았습니다.'; return $payment_info; } catch (Exception $e) { error_log('EstimateManager::calculatePaymentAmount Error: ' . $e->getMessage()); return false; } } /** * 결제 상태 업데이트 (StatusManager 연동) * * @param int $wr_id 게시물 ID * @param string $payment_type 결제 타입 (deposit, interim, final) * @param string $user_role 사용자 역할 * @return array ['success' => bool, 'data' => array, 'message' => string] */ public function updatePaymentStatus($wr_id, $payment_type, $user_role = 'admin') { try { // 결제 타입에 따른 상태 매핑 $status_map = [ 'deposit' => '입금확인', 'interim' => '중도금입금완료', 'final' => '잔금입금완료' ]; if (!isset($status_map[$payment_type])) { throw new Exception('유효하지 않은 결제 타입입니다.'); } $new_status = $status_map[$payment_type]; // StatusManager를 통한 상태 변경 $user_id = $this->member['mb_id'] ?? 'system'; $status_result = $this->status_manager->changeStatus( $wr_id, $new_status, $user_id, $user_role, "{$payment_type} 결제 확인" ); if (!$status_result['success']) { throw new Exception('상태 변경 실패: ' . $status_result['message']); } return [ 'success' => true, 'data' => [ 'wr_id' => $wr_id, 'payment_type' => $payment_type, 'new_status' => $new_status ], 'message' => "{$payment_type} 결제가 확인되었습니다." ]; } catch (Exception $e) { error_log("EstimateManager::updatePaymentStatus Error: " . $e->getMessage()); return [ 'success' => false, 'data' => null, 'message' => $e->getMessage() ]; } } /** * 견적 상세 정보 조회 (권한별 필터링) * * @param int $wr_id 게시물 ID * @param string $user_role 사용자 역할 * @param string $user_id 사용자 ID * @return array|false 견적 정보 또는 실패 시 false */ public function getEstimateDetails($wr_id, $user_role = 'guest', $user_id = '') { try { // 1. 견적 기본 정보 조회 $estimate = sql_fetch("SELECT * FROM {$this->estimate_table} WHERE wr_id = '{$wr_id}' AND is_deleted = 0"); if (!$estimate) { throw new Exception('견적을 찾을 수 없습니다.'); } // 2. 견적 항목 조회 $items_sql = "SELECT * FROM {$this->item_table} WHERE estimate_id = '{$estimate['id']}' AND is_deleted = 0 ORDER BY no ASC"; $items_result = sql_query($items_sql); $items = []; while ($item = sql_fetch_array($items_result)) { // 권한별 가격 정보 필터링 if ( $user_role === 'admin' || ($user_role === 'agent' && $item['dealer_id'] === $user_id) || ($user_role === 'customer' && empty($item['dealer_id'])) ) { $items[] = $item; } else { // 가격 정보 숨김 $item['price'] = null; $item['amount'] = null; $items[] = $item; } } // 3. 입찰 정보 조회 (권한별) $biddings = []; if ($user_role === 'admin' || $user_role === 'customer') { $biddings = $this->getBiddingList($estimate['id']); } elseif ($user_role === 'agent') { // 대리점은 자신의 입찰만 조회 $biddings_sql = "SELECT * FROM {$this->bidding_table} WHERE estimate_id = '{$estimate['id']}' AND dealer_id = '{$user_id}'"; $biddings_result = sql_query($biddings_sql); while ($bid = sql_fetch_array($biddings_result)) { $biddings[] = $bid; } } // 4. 현재 상태 정보 $status_info = $this->status_manager->getCurrentStatus($wr_id); // 5. 결제 정보 (선택된 견적이 있는 경우) $payment_info = null; if ($estimate['selected_bid_id']) { $payment_info = $this->calculatePaymentAmount($estimate['id']); } return [ 'estimate' => $estimate, 'items' => $items, 'biddings' => $biddings, 'status_info' => $status_info, 'payment_info' => $payment_info, 'user_permissions' => [ 'can_edit' => $this->canEditEstimate($estimate, $user_role, $user_id), 'can_bid' => $this->canBidOnEstimate($estimate, $user_role, $user_id), 'can_select' => $this->canSelectBid($estimate, $user_role, $user_id) ] ]; } catch (Exception $e) { error_log("EstimateManager::getEstimateDetails Error: " . $e->getMessage()); return false; } } /** * 견적 수정 권한 확인 */ private function canEditEstimate($estimate, $user_role, $user_id) { if ($user_role === 'admin') return true; if ($user_role === 'customer' && $estimate['created_by'] === $user_id && ($estimate['status'] === '견적신청중' || $estimate['status'] === 'requesting')) return true; return false; } /** * 입찰 권한 확인 */ private function canBidOnEstimate($estimate, $user_role, $user_id) { if ($user_role !== 'agent') return false; if ($estimate['status'] !== '작성완료') return false; if ($estimate['bidding_deadline'] && strtotime($estimate['bidding_deadline']) < time()) return false; return true; } /** * 견적 선택 권한 확인 */ private function canSelectBid($estimate, $user_role, $user_id) { if ($user_role !== 'customer') return false; if ($estimate['created_by'] !== $user_id) return false; if ($estimate['status'] !== '작성완료') return false; if ($estimate['bidding_deadline'] && strtotime($estimate['bidding_deadline']) > time()) return false; return true; } // ============================================================================== // 대리점 입찰 관리 기능 (Requirement 9.2) - StatusManager 연동 // ============================================================================== /** * 대리점 입찰 생성 (개선된 버전) * * @param int $estimate_id 견적서 ID * @param array $bid_data 입찰 데이터 * @param array $items_data 견적 항목 데이터 * @return array ['success' => bool, 'data' => array, 'message' => string] */ public function createBiddingWithItems($estimate_id, $bid_data, $items_data = []) { try { sql_query("START TRANSACTION"); $dealer_id = $this->member['mb_id'] ?? ''; if (empty($dealer_id)) { throw new Exception('대리점 정보가 없습니다.'); } // 1. 견적서 존재 및 입찰 가능 여부 확인 $estimate = sql_fetch("SELECT * FROM {$this->estimate_table} WHERE id = '{$estimate_id}' AND is_deleted = 0"); if (!$estimate) { throw new Exception('견적서를 찾을 수 없습니다.'); } if ($estimate['status'] !== '작성완료') { throw new Exception('입찰 가능한 상태가 아닙니다.'); } // 2. 24시간 입찰 마감시간 확인 if ($estimate['bidding_deadline'] && strtotime($estimate['bidding_deadline']) < time()) { throw new Exception('입찰 마감시간이 지났습니다.'); } // 3. 시스템 비용 계산 $dealer_level = $this->member['mb_level'] ?? 5; $commission_rate = $this->getDealerCommissionRate($dealer_level); $base_amount = (int) ($bid_data['total_amount'] ?? 0); $commission_amount = (int) ($base_amount * $commission_rate / 100); $final_amount = $base_amount + $commission_amount; // 4. 기존 입찰 확인 (수정 모드) $existing_bid = sql_fetch("SELECT id FROM {$this->bidding_table} WHERE estimate_id = '{$estimate_id}' AND dealer_id = '{$dealer_id}'"); $bidding_data = [ 'estimate_id' => $estimate_id, 'dealer_id' => $dealer_id, 'bid_amount' => $base_amount, 'commission_rate' => $commission_rate, 'commission_amount' => $commission_amount, 'total_amount' => $final_amount, 'bid_message' => trim($bid_data['message'] ?? ''), 'status' => 'bidding', 'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s') ]; if ($existing_bid) { // 기존 입찰 수정 $bidding_id = $existing_bid['id']; $this->update($this->bidding_table, $bidding_id, $bidding_data); $action = 'bidding_updated'; } else { // 새 입찰 생성 $bidding_id = $this->create($this->bidding_table, $bidding_data); $action = 'bidding_created'; } if (!$bidding_id) { throw new Exception('입찰 저장에 실패했습니다.'); } // 5. 견적 항목 저장 (대리점 정보 포함) if (!empty($items_data)) { // 기존 대리점 항목 삭제 sql_query("DELETE FROM {$this->item_table} WHERE estimate_id = '{$estimate_id}' AND dealer_id = '{$dealer_id}'"); foreach ($items_data as $index => $item) { $item_data = [ 'estimate_id' => $estimate_id, 'dealer_id' => $dealer_id, 'no' => $index + 1, 'product' => trim($item['product'] ?? ''), 'qty' => (int) ($item['qty'] ?? 1), 'price' => (int) ($item['price'] ?? 0), 'amount' => (int) ($item['amount'] ?? 0), 'brand' => trim($item['brand'] ?? ''), 'created_at' => date('Y-m-d H:i:s'), 'created_by' => $dealer_id ]; $this->create($this->item_table, $item_data); } } // 6. 게시판 답변글 상태 업데이트 (있는 경우) $reply_write = sql_fetch(" SELECT wr_id FROM g5_write_order WHERE wr_parent = '{$estimate['wr_id']}' AND mb_id = '{$dealer_id}' "); if ($reply_write) { sql_query("UPDATE g5_write_order SET wr_1 = '견적제안' WHERE wr_id = '{$reply_write['wr_id']}'"); } $this->logHistory($estimate_id, null, $action, [ 'bidding_id' => $bidding_id, 'dealer_id' => $dealer_id, 'base_amount' => $base_amount, 'commission_rate' => $commission_rate, 'total_amount' => $final_amount ]); sql_query("COMMIT"); return [ 'success' => true, 'data' => [ 'bidding_id' => $bidding_id, 'estimate_id' => $estimate_id, 'dealer_id' => $dealer_id, 'base_amount' => $base_amount, 'commission_amount' => $commission_amount, 'total_amount' => $final_amount, 'commission_rate' => $commission_rate ], 'message' => '견적 제안이 성공적으로 저장되었습니다.' ]; } catch (Exception $e) { sql_query("ROLLBACK"); error_log('EstimateManager::createBiddingWithItems Error: ' . $e->getMessage()); return [ 'success' => false, 'data' => null, 'message' => $e->getMessage() ]; } } /** * 대리점 수수료율 조회 * * @param int $dealer_level 대리점 레벨 * @return float 수수료율 (%) */ private function getDealerCommissionRate($dealer_level) { $config = $this->getOrderConfig(); switch ($dealer_level) { case 5: return (float) ($config['dealer_commission_level_5'] ?? 5); case 6: return (float) ($config['dealer_commission_level_6'] ?? 11); case 7: return (float) ($config['dealer_commission_level_7'] ?? 19); default: return 5.0; // 기본값 } } /** * 기존 호환성을 위한 createBidding 메서드 (Deprecated) * @deprecated createBiddingWithItems 사용 권장 */ public function createBidding($estimate_id, $bid_data) { $result = $this->createBiddingWithItems($estimate_id, $bid_data, []); return $result['success'] ? $result['data']['bidding_id'] : false; } // ============================================================================== // 시공 일정 관리 기능 (Task 5.2 - Requirements 4.2, 9.4) // ============================================================================== /** * 시공 일정 설정 * * @param int $wr_id 게시물 ID * @param string $construction_date 시공 예정일 (Y-m-d) * @param string $user_role 사용자 역할 * @return array ['success' => bool, 'data' => array, 'message' => string] */ public function setConstructionSchedule($wr_id, $construction_date, $user_role = 'agent') { try { // 1. 견적서 정보 조회 $estimate = sql_fetch("SELECT * FROM {$this->estimate_table} WHERE wr_id = '{$wr_id}' AND is_deleted = 0"); if (!$estimate) { throw new Exception('견적서를 찾을 수 없습니다.'); } // 2. 선택된 입찰이 있는지 확인 if (!$estimate['selected_bid_id']) { throw new Exception('선택된 견적이 없습니다.'); } $bid = sql_fetch("SELECT * FROM {$this->bidding_table} WHERE id = '{$estimate['selected_bid_id']}'"); if (!$bid) { throw new Exception('선택된 견적 정보를 찾을 수 없습니다.'); } // 3. 권한 확인 (대리점은 자신의 견적만, 관리자는 모든 견적) if ($user_role === 'agent') { $dealer_id = $this->member['mb_id'] ?? ''; if ($bid['dealer_id'] !== $dealer_id) { throw new Exception('해당 견적에 대한 권한이 없습니다.'); } } // 4. 시공일 유효성 검증 $construction_timestamp = strtotime($construction_date); if (!$construction_timestamp || $construction_timestamp < strtotime('today')) { throw new Exception('유효하지 않은 시공 예정일입니다.'); } // 5. 중도금 입금 예정일 계산 (시공 2일 전) $interim_payment_date = date('Y-m-d', strtotime($construction_date . ' -2 days')); // 6. 견적서 업데이트 (temp 컬럼 활용) $update_data = [ 'temp_6' => $construction_date, // 시공 예정일 'temp_7' => $interim_payment_date, // 중도금 입금 예정일 'temp_8' => date('Y-m-d H:i:s'), // 일정 설정 시간 'updated_at' => date('Y-m-d H:i:s') ]; $update_result = $this->update($this->estimate_table, $estimate['id'], $update_data); if (!$update_result) { throw new Exception('시공 일정 저장에 실패했습니다.'); } // 7. 상태 변경 (계약금입금완료 → 중도금입금예정) $current_status = $this->status_manager->getCurrentStatus($wr_id); if ($current_status && $current_status['status'] === '입금확인') { $user_id = $this->member['mb_id'] ?? 'system'; $this->status_manager->changeStatus( $wr_id, '중도금입금예정', $user_id, $user_role, "시공 일정 설정: {$construction_date}" ); } // 8. 이력 기록 $this->logHistory($estimate['id'], null, 'construction_scheduled', [ 'construction_date' => $construction_date, 'interim_payment_date' => $interim_payment_date, 'dealer_id' => $bid['dealer_id'] ]); return [ 'success' => true, 'data' => [ 'estimate_id' => $estimate['id'], 'wr_id' => $wr_id, 'construction_date' => $construction_date, 'interim_payment_date' => $interim_payment_date, 'dealer_id' => $bid['dealer_id'] ], 'message' => '시공 일정이 설정되었습니다.' ]; } catch (Exception $e) { error_log("EstimateManager::setConstructionSchedule Error: " . $e->getMessage()); return [ 'success' => false, 'data' => null, 'message' => $e->getMessage() ]; } } /** * 시공 일정 조회 * * @param int $wr_id 게시물 ID * @return array|false 시공 일정 정보 또는 실패 시 false */ public function getConstructionSchedule($wr_id) { try { $estimate = sql_fetch("SELECT * FROM {$this->estimate_table} WHERE wr_id = '{$wr_id}' AND is_deleted = 0"); if (!$estimate) { return false; } $schedule_info = [ 'estimate_id' => $estimate['id'], 'wr_id' => $wr_id, 'construction_date' => $estimate['temp_6'] ?? null, 'interim_payment_date' => $estimate['temp_7'] ?? null, 'schedule_set_at' => $estimate['temp_8'] ?? null, 'has_schedule' => !empty($estimate['temp_6']) ]; // 선택된 대리점 정보 추가 if ($estimate['selected_bid_id']) { $bid = sql_fetch("SELECT dealer_id FROM {$this->bidding_table} WHERE id = '{$estimate['selected_bid_id']}'"); if ($bid) { $dealer = sql_fetch("SELECT mb_name, mb_hp FROM g5_member WHERE mb_id = '{$bid['dealer_id']}'"); $schedule_info['dealer_id'] = $bid['dealer_id']; $schedule_info['dealer_name'] = $dealer['mb_name'] ?? ''; $schedule_info['dealer_phone'] = $dealer['mb_hp'] ?? ''; } } return $schedule_info; } catch (Exception $e) { error_log("EstimateManager::getConstructionSchedule Error: " . $e->getMessage()); return false; } } /** * 중도금 입금 타이밍 확인 * * @param int $wr_id 게시물 ID * @return array ['is_due' => bool, 'days_remaining' => int, 'due_date' => string] */ public function checkInterimPaymentTiming($wr_id) { try { $schedule = $this->getConstructionSchedule($wr_id); if (!$schedule || !$schedule['interim_payment_date']) { return [ 'is_due' => false, 'days_remaining' => null, 'due_date' => null, 'message' => '시공 일정이 설정되지 않았습니다.' ]; } $due_date = $schedule['interim_payment_date']; $due_timestamp = strtotime($due_date); $today_timestamp = strtotime('today'); $days_remaining = (int) (($due_timestamp - $today_timestamp) / (24 * 60 * 60)); $is_due = $days_remaining <= 0; return [ 'is_due' => $is_due, 'days_remaining' => $days_remaining, 'due_date' => $due_date, 'construction_date' => $schedule['construction_date'], 'message' => $is_due ? '중도금 입금 기한이 도래했습니다.' : "중도금 입금까지 {$days_remaining}일 남았습니다." ]; } catch (Exception $e) { error_log("EstimateManager::checkInterimPaymentTiming Error: " . $e->getMessage()); return [ 'is_due' => false, 'days_remaining' => null, 'due_date' => null, 'message' => '중도금 입금 타이밍 확인 중 오류가 발생했습니다.' ]; } } /** * 대리점별 시공 일정 목록 조회 * * @param string $dealer_id 대리점 ID (null이면 전체) * @param string $status_filter 상태 필터 * @return array 시공 일정 목록 */ public function getConstructionScheduleList($dealer_id = null, $status_filter = null) { try { $where_conditions = ["e.is_deleted = 0", "e.selected_bid_id IS NOT NULL", "e.temp_6 IS NOT NULL"]; if ($dealer_id) { $where_conditions[] = "b.dealer_id = '{$dealer_id}'"; } if ($status_filter) { $where_conditions[] = "w.wr_1 = '{$status_filter}'"; } $where_clause = implode(' AND ', $where_conditions); $sql = " SELECT e.id as estimate_id, e.wr_id, e.temp_6 as construction_date, e.temp_7 as interim_payment_date, e.temp_8 as schedule_set_at, b.dealer_id, b.total_amount, m.mb_name as dealer_name, m.mb_hp as dealer_phone, w.wr_subject, w.wr_1 as status, w.mb_id as customer_id, cm.mb_name as customer_name, cm.mb_hp as customer_phone FROM {$this->estimate_table} e LEFT JOIN {$this->bidding_table} b ON e.selected_bid_id = b.id LEFT JOIN g5_member m ON b.dealer_id = m.mb_id LEFT JOIN g5_write_order w ON e.wr_id = w.wr_id LEFT JOIN g5_member cm ON w.mb_id = cm.mb_id WHERE {$where_clause} ORDER BY e.temp_6 ASC "; $result = sql_query($sql); $schedules = []; while ($row = sql_fetch_array($result)) { // 중도금 입금 타이밍 정보 추가 $timing_info = $this->checkInterimPaymentTiming($row['wr_id']); $row['interim_payment_timing'] = $timing_info; $schedules[] = $row; } return $schedules; } catch (Exception $e) { error_log("EstimateManager::getConstructionScheduleList Error: " . $e->getMessage()); return []; } } /** * 시공 완료 처리 * * @param int $wr_id 게시물 ID * @param string $completion_notes 완료 메모 * @param string $user_role 사용자 역할 * @return array ['success' => bool, 'data' => array, 'message' => string] */ public function completeConstruction($wr_id, $completion_notes = '', $user_role = 'agent') { try { // 1. 견적서 정보 조회 $estimate = sql_fetch("SELECT * FROM {$this->estimate_table} WHERE wr_id = '{$wr_id}' AND is_deleted = 0"); if (!$estimate) { throw new Exception('견적서를 찾을 수 없습니다.'); } // 2. 권한 확인 if ($user_role === 'agent') { $dealer_id = $this->member['mb_id'] ?? ''; $bid = sql_fetch("SELECT dealer_id FROM {$this->bidding_table} WHERE id = '{$estimate['selected_bid_id']}'"); if (!$bid || $bid['dealer_id'] !== $dealer_id) { throw new Exception('해당 견적에 대한 권한이 없습니다.'); } } // 3. 완료 정보 저장 (temp 컬럼 활용) $update_data = [ 'temp_9' => date('Y-m-d'), // 시공 완료일 'temp_10' => $completion_notes, // 완료 메모 'updated_at' => date('Y-m-d H:i:s') ]; $update_result = $this->update($this->estimate_table, $estimate['id'], $update_data); if (!$update_result) { throw new Exception('시공 완료 정보 저장에 실패했습니다.'); } // 4. 상태 변경 (시공완료) $user_id = $this->member['mb_id'] ?? 'system'; $status_result = $this->status_manager->changeStatus( $wr_id, '시공완료', $user_id, $user_role, "시공 완료: {$completion_notes}" ); if (!$status_result['success']) { throw new Exception('상태 변경 실패: ' . $status_result['message']); } // 5. 이력 기록 $this->logHistory($estimate['id'], null, 'construction_completed', [ 'completion_date' => date('Y-m-d'), 'completion_notes' => $completion_notes, 'completed_by' => $user_id ]); return [ 'success' => true, 'data' => [ 'estimate_id' => $estimate['id'], 'wr_id' => $wr_id, 'completion_date' => date('Y-m-d'), 'completion_notes' => $completion_notes ], 'message' => '시공이 완료되었습니다.' ]; } catch (Exception $e) { error_log("EstimateManager::completeConstruction Error: " . $e->getMessage()); return [ 'success' => false, 'data' => null, 'message' => $e->getMessage() ]; } } /** * 견적서의 모든 입찰 조회 * @param int $estimate_id 견적서 ID * @return array 입찰 목록 */ public function getBiddingList($estimate_id) { $sql = "SELECT b.*, m.mb_name as dealer_name FROM {$this->bidding_table} b LEFT JOIN g5_member m ON b.dealer_id = m.mb_id WHERE b.estimate_id = '{$estimate_id}' ORDER BY b.created_at ASC"; $result = sql_query($sql); $biddings = []; while ($row = sql_fetch_array($result)) { $biddings[] = $row; } return $biddings; } // ============================================================================== // 기존 메서드들 (호환성 유지) // ============================================================================== /** * 고객의 견적 요청을 생성하거나 업데이트합니다. * @param int $wr_id 게시물 ID * @param array $post_data 폼 데이터 * @return int|bool estimate_id 또는 실패 시 false */ public function saveCustomerRequest($wr_id, $post_data) { $_POST['from_save_estimate'] = true; $this->logHistory($wr_id, null, 'saveCustomerRequest', [$post_data]); sql_query("START TRANSACTION"); try { $estimate = sql_fetch("SELECT id FROM {$this->estimate_table} WHERE wr_id = '{$wr_id}'"); error_log("saveCustomerRequest 호출 - wr_id: {$wr_id}, POST 데이터: " . json_encode($post_data)); $customer_id = $this->member['mb_id'] ?? ''; $estimate_data = [ 'wr_id' => $wr_id, 'zip_code' => trim($post_data['zip_code'] ?? ''), 'address1' => trim($post_data['address1'] ?? ''), 'address2' => trim($post_data['address2'] ?? ''), 'address3' => trim($post_data['address3'] ?? ''), 'house_type' => trim($post_data['house_type'] ?? ''), 'house_size' => trim($post_data['house_size'] ?? ''), 'material' => trim($post_data['material'] ?? ''), 'color' => trim($post_data['common_color'] ?? ''), 'temp_2' => trim($post_data['common_color_custom'] ?? ''), 'glass_thickness' => trim($post_data['common_glass_thickness'] ?? ''), 'install' => trim($post_data['construction_main'] ?? ''), 'temp_1' => trim($post_data['product_main'] ?? ''), 'updated_by' => $customer_id, 'updated_at' => date('Y-m-d H:i:s'), ]; if ($estimate) { $estimate_id = $estimate['id']; $this->update($this->estimate_table, $estimate_id, $estimate_data); } else { // 💡 [수정] 신규 생성 시에만 status와 created_by, created_at를 추가 $estimate_data['status'] = '견적신청중'; $estimate_data['created_by'] = $customer_id; $estimate_data['created_at'] = date('Y-m-d H:i:s'); $estimate_id = $this->create($this->estimate_table, $estimate_data); } $this->logHistory($wr_id, null, 'saveCustomerRequest', ['estimate_data' => $estimate_data, 'estimate_id' => $estimate_id]); if (!$estimate_id) throw new Exception('견적서 생성/조회 실패'); // 삭제 대상이 될 아이템들의 현재 상태를 확인하기 위해 로그를 추가합니다. $items_before_delete_sql = "SELECT * FROM {$this->item_table} WHERE estimate_id = '{$estimate_id}'"; $items_before_delete_result = sql_query($items_before_delete_sql); $items_to_check = []; while ($item = sql_fetch_array($items_before_delete_result)) { $items_to_check[] = $item; } $this->logHistory($wr_id, null, 'ITEMS_BEFORE_DELETE', ['estimate_id' => $estimate_id, 'items' => $items_to_check]); $tempsql ="DELETE FROM {$this->item_table} WHERE estimate_id = '{$estimate_id}' AND (dealer_id IS NULL OR dealer_id = '')"; $this->logHistory($wr_id, null, 'DELETE_QUERY_EXECUTING', ['query' => $tempsql]); $affected_rows = sql_query($tempsql); $this->logHistory($wr_id, null, 'DELETE_QUERY_RESULT', [ 'affected_rows' => $affected_rows, 'estimate_id' => $estimate_id ]); $items = $post_data['estimate'] ?? []; $this->logHistory($wr_id, null, 'saveCustomerRequest_items_count', ['count' => count($items)]); foreach ($items as $index => $item) { $item['no'] = $index + 1; $item['estimate_id'] = $estimate_id; unset($item['dealer_id']); $this->logHistory($wr_id, null, 'saveCustomerRequest_item_data', ['item_index' => $index, 'item_data' => $item]); $result= $this->createEstimateItem($estimate_id, $item); $this->logHistory($wr_id, null, 'createEstimateItem_result', ['item_index' => $index, 'result' => $result]); } sql_query("COMMIT"); return $estimate_id; } catch (Exception $e) { sql_query("ROLLBACK"); error_log("saveCustomerRequest 오류: " . $e->getMessage()); return false; } } /** * wr_id를 기반으로 견적 마스터 데이터와 아이템 데이터를 로드합니다. * @param int $wr_id 게시물 ID * @return array|null 견적 데이터 (마스터 + 아이템) 또는 null */ public function loadEstimateDataByWrId($wr_id) { $estimate_master = sql_fetch("SELECT * FROM {$this->estimate_table} WHERE wr_id = '{$wr_id}' AND is_deleted = 0"); if (!$estimate_master) { return null; } $estimate_items = sql_query("SELECT * FROM {$this->item_table} WHERE estimate_id = '{$estimate_master['id']}' ORDER BY no ASC"); $items_array = []; while ($row = sql_fetch_array($estimate_items)) { $items_array[] = $row; } $estimate_master['items'] = $items_array; return $estimate_master; } // ============================================================================== // 🔥 새로 추가되는 메서드들 (기존 코드에 영향 없음) // ============================================================================== /** * 답변 글의 원본 글 ID를 찾습니다. * @param int $current_wr_id 현재 답변 글 ID * @return int|null 원본 글 ID 또는 null */ private function findOriginalWrId($current_wr_id) { global $g5; // write 테이블명 (order 게시판) $write_table = $g5['write_prefix'] . 'order'; // 1. 현재 글 정보 조회 $current_post = sql_fetch("SELECT wr_num, wr_reply FROM {$write_table} WHERE wr_id = '{$current_wr_id}'"); if (!$current_post) { return null; } // 2. 🔥 wr_reply가 빈 값이면 이미 원본 글 if (empty($current_post['wr_reply'])) { return (int)$current_wr_id; } // 3. 🔥 wr_reply가 있으면 답변 글이므로, 같은 wr_num에서 wr_reply가 빈 원본 글을 찾기 $original_post = sql_fetch("SELECT wr_id FROM {$write_table} WHERE wr_num = '{$current_post['wr_num']}' AND wr_reply = ''"); if ($original_post) { return (int)$original_post['wr_id']; } // 4. 찾지 못한 경우 null 반환 return null; } /** * 대리점의 견적 제안을 저장합니다. * @param int $current_wr_id 현재 답변 글의 ID * @param array $post_data 폼 데이터 * @return int|bool estimate_id 또는 실패 시 false */ public function saveAgentProposal($current_wr_id, $post_data) { $_POST['from_save_estimate'] = true; // $this->logHistory($current_wr_id, null, 'saveAgentProposal', ["1",$current_wr_id,$post_data]); try { sql_query("START TRANSACTION"); // 1. 원본 글 ID 찾기 $original_wr_id = $this->findOriginalWrId($current_wr_id); // $this->logHistory($current_wr_id, null, 'saveAgentProposal', ["2",$current_wr_id,$original_wr_id]); if (!$original_wr_id) { throw new Exception('원본 글을 찾을 수 없습니다.'); } // 2. 원본 견적서 정보 조회 (고객의 기본 정보 복사용) $original_estimate = sql_fetch("SELECT * FROM {$this->estimate_table} WHERE wr_id = '{$original_wr_id}' AND is_deleted = 0"); if (!$original_estimate) { throw new Exception('원본 견적서를 찾을 수 없습니다.'); } // $this->logHistory($current_wr_id, null, 'saveAgentProposal', ["3",$current_wr_id,$this->is_admin,$original_estimate]); $dealer_id = $this->member['mb_id'] ?? ''; // 🔥 관리자(Level 10) 또는 전문가(Level 9)인 경우, 글 작성자가 본인일 수 있음 // 기존 코드: if($this->is_admin) { ... } // 여기서는 $dealer_id가 현재 로그인한 멤버의 ID임. // 관리자가 대리점을 대신해서 올리는 경우 등은 별도 로직이 필요할 수 있으나, // 요구사항은 "전문가나 관리자가 등록을 해야되" 이므로 본인 ID로 등록하면 됨. // 기존 코드 유지 (관리자일 때 created_by 가져오는 부분은 상황에 따라 다를 수 있음) if($this->is_admin) { $tmpuser = sql_fetch("SELECT created_by FROM {$this->estimate_table} WHERE wr_id = '{$current_wr_id}' AND is_deleted = 0") ?? ''; if ($tmpuser) { // $dealer_id = $tmpuser['created_by']; // 주의: 위 로직은 이미 생성된 견적이 있을 때 작성자를 가져오는 것임. // 신규 작성 시에는 현재 로그인한 관리자의 ID를 사용해야 함. // 따라서 $dealer_id는 기본적으로 $member['mb_id']를 사용하고, // 수정 시에는 기존 작성자를 유지하는 방식이 안전함. } } // $this->logHistory($current_wr_id, null, 'saveAgentProposal', ["3-1",$dealer_id]); if (empty($dealer_id)) { throw new Exception('대리점 정보가 없습니다.'); } // 3. 🔥 [핵심] 대리점용 견적 마스터 정보 생성 // 기존 견적이 있는지 확인 (대리점이 이미 제안한 경우) // $sql ="SELECT id FROM {$this->estimate_table} WHERE wr_id = '{$current_wr_id}' AND is_deleted = 0"; $existing_agent_estimate = sql_fetch("SELECT id FROM {$this->estimate_table} WHERE wr_id = '{$current_wr_id}' AND is_deleted = 0"); // 1. 최종 금액을 폼 데이터에서 직접 가져옵니다. $final_amount = (int)($post_data['estimate']['final_amount'] ?? 0); // 만약 final_amount가 없으면 item들의 합계로 계산 if ($final_amount == 0 && isset($post_data['estimate']) && is_array($post_data['estimate'])) { foreach ($post_data['estimate'] as $item) { if(is_array($item)) { $final_amount += (int)($item['amount'] ?? 0); } } } // $this->logHistory($current_wr_id, null, 'saveAgentProposal', ["4",$current_wr_id,$sql,$existing_agent_estimate]); if ($existing_agent_estimate) { // 기존 대리점 견적 수정 $estimate_id = $existing_agent_estimate['id']; // 대리점 견적 마스터 정보 업데이트 (필요한 경우) $update_data = [ 'status' => '견적제안', 'commission_fee' => $final_amount, // 🔥 commission_fee 필드를 최종 금액 저장용으로 사용 'updated_at' => date('Y-m-d H:i:s'), 'updated_by' => $dealer_id, ]; $this->update($this->estimate_table, $estimate_id, $update_data); // $this->logHistory($current_wr_id, null, 'saveAgentProposal', ["4-2",$this->update($this->estimate_table, $estimate_id, $update_data)]); } else { // 🔥 새로운 대리점 견적 마스터 생성 $agent_estimate_data = [ 'wr_id' => $current_wr_id, // 🔥 답변 글 ID로 설정 'status' => '견적제안', // 고객의 기본 정보 복사 'zip_code' => $original_estimate['zip_code'], 'address1' => $original_estimate['address1'], 'address2' => $original_estimate['address2'], 'address3' => $original_estimate['address3'], 'house_type' => $original_estimate['house_type'], 'house_size' => $original_estimate['house_size'], 'material' => $original_estimate['material'], 'color' => $original_estimate['color'], 'glass_thickness' => $original_estimate['glass_thickness'], 'install' => $original_estimate['install'], 'commission_fee' => $final_amount, // 🔥 commission_fee 필드를 최종 금액 저장용으로 사용 // 대리점 정보 'created_by' => $dealer_id, 'updated_by' => $dealer_id, 'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'), ]; $estimate_id = $this->create($this->estimate_table, $agent_estimate_data); if (!$estimate_id) { throw new Exception('대리점 견적 마스터 생성 실패'); } error_log("대리점 견적 마스터 생성 완료 - estimate_id: {$estimate_id}, current_wr_id: {$current_wr_id}, dealer_id: {$dealer_id}"); } // 💡 [추가] 입찰(Bidding) 정보도 생성/업데이트해야 함 // 견적(Estimate) 테이블은 상세 내용을 담고, 입찰(Bidding) 테이블은 금액과 대리점 정보를 담아 입찰 프로세스를 관리함. // EstimateManager::createBiddingWithItems 메서드 로직을 참조하여 여기서도 Bidding 정보를 생성해야 함. // 원본 글 ID에 대한 입찰 정보를 생성해야 함. // $original_wr_id -> $original_estimate['id'] $bidding_data = [ 'total_amount' => $final_amount, 'message' => $post_data['wr_content'] ?? '', // 게시판 본문 내용 ]; // createBiddingWithItems는 estimate_id(고객요청)를 기준으로 동작함. // 하지만 여기서는 saveAgentProposal이 대리점용 estimate 레코드를 별도로 생성하고 있음($estimate_id). // 시스템 구조가 다소 혼재되어 있어 보임. // 1. 고객 estimate -> 대리점 bidding (일반적인 구조) // 2. 고객 estimate -> 대리점 estimate (상세 견적 제안용) // 요구사항은 "대리점처럼 될수 있게" 이므로, 입찰 정보도 생성해주는 것이 좋음. // createBiddingWithItems($original_estimate['id'], $bidding_data) 호출 고려. $this->createBiddingWithItems($original_estimate['id'], $bidding_data); // 4. 기존 대리점 견적 아이템 삭제 (수정 시) // 여기서는 위에서 생성한 대리점용 estimate_id에 대한 아이템을 관리함 $sql1 ="DELETE FROM {$this->item_table} WHERE estimate_id = '{$estimate_id}' AND dealer_id = '{$dealer_id}'"; $sql2 ="DELETE FROM {$this->item_table} WHERE estimate_id = '{$estimate_id}' AND (dealer_id IS NULL OR dealer_id = '')"; // $this->logHistory($current_wr_id, null, 'saveAgentProposal', ["4-4",$sql1,$sql2]); sql_query("DELETE FROM {$this->item_table} WHERE estimate_id = '{$estimate_id}' AND dealer_id = '{$dealer_id}'"); sql_query("DELETE FROM {$this->item_table} WHERE estimate_id = '{$estimate_id}' AND (dealer_id IS NULL OR dealer_id = '')"); // 5. 🔥 대리점 견적 아이템 저장 $items = $post_data['estimate'] ?? []; foreach ($items as $index => $item) { if(!is_array($item))continue;; // $this->logHistory($current_wr_id, null, 'saveAgentProposal', ["4-5",$item]); $item_data = [ 'estimate_id' => $estimate_id, // 🔥 대리점의 estimate_id 사용 'dealer_id' => $dealer_id, 'no' => $index + 1, // 고객이 입력한 기본 정보 'location' => trim($item['location'] ?? ''), 'spec_width' => trim($item['spec_width'] ?? ''), 'spec_height' => trim($item['spec_height'] ?? ''), 'window_main_type' => trim($item['window_main_type'] ?? ''), 'windowType' => trim($item['windowType'] ?? ''), 'windowRatio' => trim($item['windowRatio'] ?? ''), 'glass_color' => trim($item['glass_color'] ?? ''), 'handle' => trim($item['handle'] ?? ''), 'replacePart' => trim($item['replacePart'] ?? ''), 'extra_1' => trim($item['extra_1'] ?? ''), // 🔥 대리점이 입력한 견적 정보 'product' => trim($item['product'] ?? ''), 'qty' => (int)($item['qty'] ?? 1), 'price' => (int)($item['price'] ?? 0), 'amount' => (int)($item['amount'] ?? 0), 'brand' => trim($item['brand'] ?? ''), 'created_by' => $dealer_id, 'updated_by' => $dealer_id, // 'dealer_id'=>$dealer_id, ]; $result = $this->create($this->item_table, $item_data); if (!$result) { throw new Exception("견적 아이템 저장 실패 - 인덱스: {$index}"); } } $this->logHistory($estimate_id, null, 'saveAgentProposal', [ 'current_wr_id' => $current_wr_id, 'original_wr_id' => $original_wr_id, 'dealer_id' => $dealer_id, 'items_count' => count($items) ]); sql_query("COMMIT"); return $estimate_id; } catch (Exception $e) { sql_query("ROLLBACK"); error_log('saveAgentProposal Error: ' . $e->getMessage()); throw $e; } } /** * 견적서의 상태를 변경합니다. * @param int $id estimate_id * @param string $new_status 새로운 상태 * @param array $extra 추가 데이터 * @return bool */ public function updateEstimateStatus($id, $new_status, $extra = []) { $old = sql_fetch("SELECT status FROM {$this->estimate_table} WHERE id = '{$id}'"); if (!$old) return false; $set_clauses = [ 'status' => $new_status, 'updated_by' => $this->member['mb_id'] ?? '', 'updated_at' => date('Y-m-d H:i:s'), ]; if ($new_status === '작성완료') { $deadline = date('Y-m-d H:i:s', strtotime('+24 hours')); $set_clauses['bidding_deadline'] = $deadline; } $result = $this->update($this->estimate_table, $id, $set_clauses); if ($result) { $this->logHistory($id, null, 'status_change', ['old' => $old['status'], 'new' => $new_status, 'extra' => $extra]); } return $result; } // ============================================================================== // 헬퍼 메서드들 // ============================================================================== /** * 주문 설정 조회 */ private function getOrderConfig() { $config = []; $result = sql_query("SELECT config_key, config_value FROM order_config"); while ($row = sql_fetch_array($result)) { $config[$row['config_key']] = $row['config_value']; } return $config; } private function createEstimateItem($estimate_id, $item) { // $resylt = $this->create($this->item_table, $item); // $this->write_debug_log("[createEstimateItem] $resylt: {$resylt}"); return $this->create($this->item_table, $item); } private function create($table, $data) { $fields = array_keys($data); $values = array_values($data); $fields_str = '`' . implode('`, `', $fields) . '`'; $values_str = "'" . implode("', '", array_map('sql_real_escape_string', $values)) . "'"; $sql = "INSERT INTO `{$table}` ({$fields_str}) VALUES ({$values_str})"; // $this->write_debug_log("[create] sql: {$sql}"); $result = sql_query($sql); // $this->write_debug_log("[create] result: {$result}"); return $result ? sql_insert_id() : false; } private function update($table, $id, $data) { if (is_array($data)) { $set_clauses = []; foreach ($data as $key => $value) { $set_clauses[] = "`{$key}` = '" . sql_real_escape_string($value) . "'"; } $set_str = implode(', ', $set_clauses); } else { $set_str = $data; } $sql = "UPDATE `{$table}` SET {$set_str} WHERE id = '{$id}'"; return sql_query($sql); } private function logHistory($estimate_id, $item_id, $action, $details) { $history_data = [ 'estimate_id' => $estimate_id, 'item_id' => $item_id, 'action' => $action, 'change_details' => json_encode($details, JSON_UNESCAPED_UNICODE), 'changed_by' => $this->member['mb_id'] ?? '', 'changed_at' => date('Y-m-d H:i:s') ]; $this->create($this->history_table, $history_data); } /** * 대리점 선택 알림 발송 * * @param int $wr_id 게시물 ID * @param string $dealer_id 대리점 ID * @param array $bid_info 입찰 정보 */ private function sendDealerSelectedNotification($wr_id, $dealer_id, $bid_info) { try { // DealerNotification 클래스 로드 if (file_exists(G5_PATH . '/adm/order_manage/dealer_notification.php')) { require_once G5_PATH . '/adm/order_manage/dealer_notification.php'; $dealer_notification = new DealerNotification(); $result = $dealer_notification->sendBidSelectedNotification($wr_id, $dealer_id, $bid_info); if (!$result['success']) { error_log("Dealer notification failed: " . $result['message']); } } } catch (Exception $e) { error_log("EstimateManager::sendDealerSelectedNotification Error: " . $e->getMessage()); } } // ============================================================================== // 전문가 방문 시스템 (Task 6.1 - Requirements 5.1, 5.2, 5.3, 13.1, 13.2, 13.3) // ============================================================================== /** * 전문가 방문 요청 * * @param int $wr_id 게시물 ID * @param string $user_role 사용자 역할 * @return array ['success' => bool, 'data' => array, 'message' => string] */ public function requestExpertVisit($wr_id, $user_role = 'customer') { try { // 1. 견적서 정보 조회 $estimate = sql_fetch("SELECT * FROM {$this->estimate_table} WHERE wr_id = '{$wr_id}' AND is_deleted = 0"); if (!$estimate) { throw new Exception('견적서를 찾을 수 없습니다.'); } // 2. 권한 확인 (고객 본인 또는 관리자만) if ($user_role === 'customer') { $customer_id = $this->member['mb_id'] ?? ''; if ($estimate['created_by'] !== $customer_id) { throw new Exception('해당 견적에 대한 권한이 없습니다.'); } } // 3. 이미 전문가 방문이 요청되었는지 확인 if ($estimate['temp_1'] === 'Y') { throw new Exception('이미 전문가 방문이 요청되었습니다.'); } // 4. 전문가 방문 비용 계산 $config = $this->getOrderConfig(); $visit_fee = (int) ($config['expert_visit_fee'] ?? 50000); // 5. 견적서 업데이트 (temp 컬럼 활용) $update_data = [ 'temp_1' => 'Y', // 전문가 방문 요청 여부 'temp_2' => 'requested', // 전문가 방문 상태 'temp_3' => $visit_fee, // 전문가 방문 비용 'temp_4' => null, // 전문가 방문 일정 (추후 설정) 'temp_5' => '전문가 방문 요청됨', // 전문가 방문 메모 'updated_at' => date('Y-m-d H:i:s') ]; $update_result = $this->update($this->estimate_table, $estimate['id'], $update_data); if (!$update_result) { throw new Exception('전문가 방문 요청 저장에 실패했습니다.'); } // 6. 상태 변경 (전문가방문요청) $user_id = $this->member['mb_id'] ?? 'system'; $status_result = $this->status_manager->changeStatus( $wr_id, '전문가방문요청', $user_id, $user_role, "전문가 방문 요청 - 비용: {$visit_fee}원" ); if (!$status_result['success']) { throw new Exception('상태 변경 실패: ' . $status_result['message']); } // 7. 이력 기록 $this->logHistory($estimate['id'], null, 'expert_visit_requested', [ 'visit_fee' => $visit_fee, 'requested_by' => $user_id, 'request_date' => date('Y-m-d H:i:s') ]); // 8. 고객 알림 발송 (결제 안내) $this->sendExpertVisitNotification($wr_id, 'request', [ 'visit_fee' => $visit_fee, 'account_info' => $config['payment_account_info'] ?? '' ]); return [ 'success' => true, 'data' => [ 'estimate_id' => $estimate['id'], 'wr_id' => $wr_id, 'visit_fee' => $visit_fee, 'status' => 'requested', 'account_info' => $config['payment_account_info'] ?? '' ], 'message' => '전문가 방문이 요청되었습니다. 방문 비용을 입금해주세요.' ]; } catch (Exception $e) { error_log("EstimateManager::requestExpertVisit Error: " . $e->getMessage()); return [ 'success' => false, 'data' => null, 'message' => $e->getMessage() ]; } } /** * 전문가 방문 정보 조회 * * @param int $wr_id 게시물 ID * @return array|false 전문가 방문 정보 또는 실패 시 false */ public function getExpertVisitInfo($wr_id) { try { $estimate = sql_fetch("SELECT * FROM {$this->estimate_table} WHERE wr_id = '{$wr_id}' AND is_deleted = 0"); if (!$estimate) { return false; } $visit_info = [ 'estimate_id' => $estimate['id'], 'wr_id' => $wr_id, 'is_requested' => $estimate['temp_1'] === 'Y', 'status' => $estimate['temp_2'] ?? 'not_requested', 'visit_fee' => (int) ($estimate['temp_3'] ?? 0), 'visit_datetime' => $estimate['temp_4'] ?? null, 'visit_notes' => $estimate['temp_5'] ?? '', 'expert_id' => $estimate['extra_4'] ?? '', 'completed_date' => $estimate['extra_5'] ?? null ]; // 전문가 정보 추가 if ($visit_info['expert_id']) { $expert = sql_fetch("SELECT mb_name, mb_hp FROM g5_member WHERE mb_id = '{$visit_info['expert_id']}'"); if ($expert) { $visit_info['expert_name'] = $expert['mb_name']; $visit_info['expert_phone'] = $expert['mb_hp']; } } return $visit_info; } catch (Exception $e) { error_log("EstimateManager::getExpertVisitInfo Error: " . $e->getMessage()); return false; } } /** * 전문가 방문 결제 확인 * * @param int $wr_id 게시물 ID * @param string $user_role 사용자 역할 * @return array ['success' => bool, 'data' => array, 'message' => string] */ public function confirmExpertVisitPayment($wr_id, $user_role = 'admin') { try { // 1. 견적서 정보 조회 $estimate = sql_fetch("SELECT * FROM {$this->estimate_table} WHERE wr_id = '{$wr_id}' AND is_deleted = 0"); if (!$estimate) { throw new Exception('견적서를 찾을 수 없습니다.'); } // 2. 전문가 방문 요청 상태 확인 if ($estimate['temp_1'] !== 'Y' || $estimate['temp_2'] !== 'requested') { throw new Exception('전문가 방문 요청 상태가 아닙니다.'); } // 3. 견적서 상태 업데이트 $update_data = [ 'temp_2' => 'payment_confirmed', // 전문가 방문 상태 'temp_5' => '전문가 방문 비용 입금 확인됨', 'updated_at' => date('Y-m-d H:i:s') ]; $update_result = $this->update($this->estimate_table, $estimate['id'], $update_data); if (!$update_result) { throw new Exception('결제 확인 정보 저장에 실패했습니다.'); } // 4. 상태 변경 (전문가방문결제완료) $user_id = $this->member['mb_id'] ?? 'system'; $status_result = $this->status_manager->changeStatus( $wr_id, '전문가방문결제완료', $user_id, $user_role, '전문가 방문 비용 결제 확인' ); if (!$status_result['success']) { throw new Exception('상태 변경 실패: ' . $status_result['message']); } // 5. 이력 기록 $this->logHistory($estimate['id'], null, 'expert_visit_payment_confirmed', [ 'confirmed_by' => $user_id, 'confirmed_date' => date('Y-m-d H:i:s') ]); return [ 'success' => true, 'data' => [ 'estimate_id' => $estimate['id'], 'wr_id' => $wr_id, 'status' => 'payment_confirmed' ], 'message' => '전문가 방문 비용 결제가 확인되었습니다.' ]; } catch (Exception $e) { error_log("EstimateManager::confirmExpertVisitPayment Error: " . $e->getMessage()); return [ 'success' => false, 'data' => null, 'message' => $e->getMessage() ]; } } /** * 전문가 방문 일정 설정 * * @param int $wr_id 게시물 ID * @param string $visit_datetime 방문 일시 (Y-m-d H:i) * @param string $expert_id 전문가 ID * @param string $user_role 사용자 역할 * @return array ['success' => bool, 'data' => array, 'message' => string] */ public function scheduleExpertVisit($wr_id, $visit_datetime, $expert_id = '', $user_role = 'admin') { try { // 1. 견적서 정보 조회 $estimate = sql_fetch("SELECT * FROM {$this->estimate_table} WHERE wr_id = '{$wr_id}' AND is_deleted = 0"); if (!$estimate) { throw new Exception('견적서를 찾을 수 없습니다.'); } // 2. 결제 확인 상태 체크 if ($estimate['temp_2'] !== 'payment_confirmed') { throw new Exception('전문가 방문 비용 결제가 확인되지 않았습니다.'); } // 3. 일시 유효성 검증 $visit_timestamp = strtotime($visit_datetime); if (!$visit_timestamp || $visit_timestamp < time()) { throw new Exception('유효하지 않은 방문 일시입니다.'); } // 4. 견적서 업데이트 $update_data = [ 'temp_2' => 'scheduled', // 전문가 방문 상태 'temp_4' => $visit_datetime, // 전문가 방문 일정 'temp_5' => "전문가 방문 일정: {$visit_datetime}" . ($expert_id ? " (담당: {$expert_id})" : ''), 'extra_4' => $expert_id, // 담당 전문가 ID (extra 컬럼 활용) 'updated_at' => date('Y-m-d H:i:s') ]; $update_result = $this->update($this->estimate_table, $estimate['id'], $update_data); if (!$update_result) { throw new Exception('방문 일정 저장에 실패했습니다.'); } // 5. 상태 변경 (전문가방문예정) $user_id = $this->member['mb_id'] ?? 'system'; $status_result = $this->status_manager->changeStatus( $wr_id, '전문가방문예정', $user_id, $user_role, "전문가 방문 일정: {$visit_datetime}" ); if (!$status_result['success']) { throw new Exception('상태 변경 실패: ' . $status_result['message']); } // 6. 이력 기록 $this->logHistory($estimate['id'], null, 'expert_visit_scheduled', [ 'visit_datetime' => $visit_datetime, 'expert_id' => $expert_id, 'scheduled_by' => $user_id ]); return [ 'success' => true, 'data' => [ 'estimate_id' => $estimate['id'], 'wr_id' => $wr_id, 'visit_datetime' => $visit_datetime, 'expert_id' => $expert_id, 'status' => 'scheduled' ], 'message' => '전문가 방문 일정이 설정되었습니다.' ]; } catch (Exception $e) { error_log("EstimateManager::scheduleExpertVisit Error: " . $e->getMessage()); return [ 'success' => false, 'data' => null, 'message' => $e->getMessage() ]; } } /** * 전문가 방문 완료 처리 * * @param int $wr_id 게시물 ID * @param string $visit_notes 방문 메모 * @param string $user_role 사용자 역할 * @return array ['success' => bool, 'data' => array, 'message' => string] */ public function completeExpertVisit($wr_id, $visit_notes = '', $user_role = 'admin') { try { // 1. 견적서 정보 조회 $estimate = sql_fetch("SELECT * FROM {$this->estimate_table} WHERE wr_id = '{$wr_id}' AND is_deleted = 0"); if (!$estimate) { throw new Exception('견적서를 찾을 수 없습니다.'); } // 2. 방문 예정 상태 확인 if ($estimate['temp_2'] !== 'scheduled') { throw new Exception('전문가 방문 예정 상태가 아닙니다.'); } // 3. 견적서 업데이트 $update_data = [ 'temp_2' => 'completed', // 전문가 방문 상태 'temp_5' => "전문가 방문 완료: {$visit_notes}", 'extra_5' => date('Y-m-d H:i:s'), // 완료 시간 (extra 컬럼 활용) 'updated_at' => date('Y-m-d H:i:s') ]; $update_result = $this->update($this->estimate_table, $estimate['id'], $update_data); if (!$update_result) { throw new Exception('방문 완료 정보 저장에 실패했습니다.'); } // 4. 상태 변경 (전문가방문완료) $user_id = $this->member['mb_id'] ?? 'system'; $status_result = $this->status_manager->changeStatus( $wr_id, '전문가방문완료', $user_id, $user_role, "전문가 방문 완료: {$visit_notes}" ); if (!$status_result['success']) { throw new Exception('상태 변경 실패: ' . $status_result['message']); } // 5. 이력 기록 $this->logHistory($estimate['id'], null, 'expert_visit_completed', [ 'visit_notes' => $visit_notes, 'completed_by' => $user_id, 'completed_date' => date('Y-m-d H:i:s') ]); return [ 'success' => true, 'data' => [ 'estimate_id' => $estimate['id'], 'wr_id' => $wr_id, 'visit_notes' => $visit_notes, 'completed_date' => date('Y-m-d H:i:s'), 'status' => 'completed' ], 'message' => '전문가 방문이 완료되었습니다.' ]; } catch (Exception $e) { error_log("EstimateManager::completeExpertVisit Error: " . $e->getMessage()); return [ 'success' => false, 'data' => null, 'message' => $e->getMessage() ]; } } /** * 전문가 방문 목록 조회 (관리자용) * * @param string $status_filter 상태 필터 * @param string $date_from 시작 날짜 * @param string $date_to 종료 날짜 * @return array 전문가 방문 목록 */ public function getExpertVisitList($status_filter = null, $date_from = null, $date_to = null) { try { $where_conditions = ["e.is_deleted = 0", "e.temp_1 = 'Y'"]; if ($status_filter) { $where_conditions[] = "e.temp_2 = '{$status_filter}'"; } if ($date_from) { $where_conditions[] = "DATE(e.temp_4) >= '{$date_from}'"; } if ($date_to) { $where_conditions[] = "DATE(e.temp_4) <= '{$date_to}'"; } $where_clause = implode(' AND ', $where_conditions); $sql = " SELECT e.id as estimate_id, e.wr_id, e.temp_1 as is_requested, e.temp_2 as status, e.temp_3 as visit_fee, e.temp_4 as visit_datetime, e.temp_5 as visit_notes, e.extra_4 as expert_id, e.extra_5 as completed_date, w.wr_subject, w.mb_id as customer_id, cm.mb_name as customer_name, cm.mb_hp as customer_phone, em.mb_name as expert_name, em.mb_hp as expert_phone FROM {$this->estimate_table} e LEFT JOIN g5_write_order w ON e.wr_id = w.wr_id LEFT JOIN g5_member cm ON w.mb_id = cm.mb_id LEFT JOIN g5_member em ON e.extra_4 = em.mb_id WHERE {$where_clause} ORDER BY e.temp_4 ASC, e.id DESC "; $result = sql_query($sql); $visits = []; while ($row = sql_fetch_array($result)) { $visits[] = $row; } return $visits; } catch (Exception $e) { error_log("EstimateManager::getExpertVisitList Error: " . $e->getMessage()); return []; } } /** * 전문가 방문 알림 발송 * * @param int $wr_id 게시물 ID * @param string $notification_type 알림 타입 * @param array $additional_data 추가 데이터 */ private function sendExpertVisitNotification($wr_id, $notification_type, $additional_data = []) { try { // NotificationHelper 클래스 로드 if (file_exists(G5_PATH . '/adm/order_manage/classes/NotificationHelper.class.php')) { require_once G5_PATH . '/adm/order_manage/classes/NotificationHelper.class.php'; $notification_helper = new NotificationHelper(); // 고객 정보 조회 $write = sql_fetch("SELECT * FROM g5_write_order WHERE wr_id = '{$wr_id}'"); if ($write) { $customer = sql_fetch("SELECT * FROM g5_member WHERE mb_id = '{$write['mb_id']}'"); $notification_data = array_merge([ 'customer_name' => $customer['mb_name'] ?? '고객', 'customer_email' => $customer['mb_email'] ?? '', 'customer_phone' => $customer['mb_hp'] ?? '', 'estimate_id' => $wr_id, 'estimate_subject' => $write['wr_subject'] ], $additional_data); // 알림 타입별 템플릿 키 결정 $template_key = "expert_visit_{$notification_type}"; $result = $notification_helper->sendCustomerNotification($template_key, $notification_data); if (!$result['success']) { error_log("Expert visit notification failed: " . $result['message']); } } } } catch (Exception $e) { error_log("EstimateManager::sendExpertVisitNotification Error: " . $e->getMessage()); } } // ============================================================================== // 통계 및 리포트 시스템 (Task 7.2 - Requirements 12.1, 12.2, 12.3, 12.4) // ============================================================================== /** * 견적 요청 현황 통계 * * @param string $date_from 시작 날짜 * @param string $date_to 종료 날짜 * @return array 통계 데이터 */ public function getEstimateStatistics($date_from = null, $date_to = null) { try { $where_conditions = ["e.is_deleted = 0"]; $trend_where_conditions = ["e.is_deleted = 0"]; // 월별 트렌드용 WHERE 조건 if ($date_from) { $where_conditions[] = "DATE(e.created_at) >= '{$date_from}'"; $trend_where_conditions[] = "DATE(e.created_at) >= '{$date_from}'"; } if ($date_to) { $where_conditions[] = "DATE(e.created_at) <= '{$date_to}'"; $trend_where_conditions[] = "DATE(e.created_at) <= '{$date_to}'"; } $where_clause = implode(' AND ', $where_conditions); $trend_where_clause = implode(' AND ', $trend_where_conditions); // 월별 트렌드용 WHERE 절 // 1. 전체 견적 현황 $total_sql = " SELECT COUNT(*) as total_estimates, COUNT(CASE WHEN e.status = '작성완료' THEN 1 END) as completed_estimates, COUNT(CASE WHEN e.selected_bid_id IS NOT NULL THEN 1 END) as selected_estimates, COUNT(CASE WHEN e.temp_1 = 'Y' THEN 1 END) as expert_visit_requests FROM {$this->estimate_table} e WHERE {$where_clause} "; $total_stats = sql_fetch($total_sql); // 2. 상태별 분포 $status_sql = " SELECT w.wr_1 as status, COUNT(*) as count FROM {$this->estimate_table} e LEFT JOIN g5_write_order w ON e.wr_id = w.wr_id WHERE {$where_clause} GROUP BY w.wr_1 ORDER BY count DESC "; $status_result = sql_query($status_sql); $status_distribution = []; while ($row = sql_fetch_array($status_result)) { $status_distribution[] = [ 'status' => $row['status'] ?: '미분류', 'count' => (int) $row['count'] ]; } // 3. 월별 트렌드 (사용자 지정 기간) $trend_sql = " SELECT DATE_FORMAT(e.created_at, '%Y-%m') as month, COUNT(*) as total_count, COUNT(CASE WHEN e.selected_bid_id IS NOT NULL THEN 1 END) as selected_count FROM {$this->estimate_table} e WHERE {$trend_where_clause} GROUP BY DATE_FORMAT(e.created_at, '%Y-%m') ORDER BY month ASC "; $trend_result = sql_query($trend_sql); $monthly_trend = []; while ($row = sql_fetch_array($trend_result)) { $monthly_trend[] = [ 'month' => $row['month'], 'total_count' => (int) $row['total_count'], 'selected_count' => (int) $row['selected_count'], 'conversion_rate' => $row['total_count'] > 0 ? round(($row['selected_count'] / $row['total_count']) * 100, 2) : 0 ]; } // 4. 전환율 계산 $conversion_rate = $total_stats['total_estimates'] > 0 ? round(($total_stats['selected_estimates'] / $total_stats['total_estimates']) * 100, 2) : 0; return [ 'total_statistics' => array_merge($total_stats, ['conversion_rate' => $conversion_rate]), 'status_distribution' => $status_distribution, 'monthly_trend' => $monthly_trend, // 이미 ASC로 정렬했으므로 array_reverse 제거 'date_range' => [ 'from' => $date_from, 'to' => $date_to ] ]; } catch (Exception $e) { error_log("EstimateManager::getEstimateStatistics Error: " . $e->getMessage()); return [ 'total_statistics' => [], 'status_distribution' => [], 'monthly_trend' => [], 'date_range' => [] ]; } } /** * 대리점별 성과 분석 * * @param string $date_from 시작 날짜 * @param string $date_to 종료 날짜 * @return array 대리점 성과 데이터 */ public function getDealerPerformance($date_from = null, $date_to = null) { try { $where_conditions = ["b.created_at IS NOT NULL"]; if ($date_from) { $where_conditions[] = "DATE(b.created_at) >= '{$date_from}'"; } if ($date_to) { $where_conditions[] = "DATE(b.created_at) <= '{$date_to}'"; } $where_clause = implode(' AND ', $where_conditions); $sql = " SELECT b.dealer_id, m.mb_name as dealer_name, m.mb_level as dealer_level, COUNT(*) as total_bids, COUNT(CASE WHEN b.status = 'selected' THEN 1 END) as selected_bids, AVG(b.total_amount) as avg_bid_amount, SUM(CASE WHEN b.status = 'selected' THEN b.total_amount ELSE 0 END) as total_revenue, MIN(b.created_at) as first_bid_date, MAX(b.created_at) as last_bid_date FROM {$this->bidding_table} b LEFT JOIN g5_member m ON b.dealer_id = m.mb_id WHERE {$where_clause} GROUP BY b.dealer_id, m.mb_name, m.mb_level ORDER BY selected_bids DESC, total_bids DESC "; $result = sql_query($sql); $dealer_performance = []; while ($row = sql_fetch_array($result)) { $success_rate = $row['total_bids'] > 0 ? round(($row['selected_bids'] / $row['total_bids']) * 100, 2) : 0; $dealer_performance[] = [ 'dealer_id' => $row['dealer_id'], 'dealer_name' => $row['dealer_name'] ?: $row['dealer_id'], 'dealer_level' => (int) $row['dealer_level'], 'total_bids' => (int) $row['total_bids'], 'selected_bids' => (int) $row['selected_bids'], 'success_rate' => $success_rate, 'avg_bid_amount' => (int) $row['avg_bid_amount'], 'total_revenue' => (int) $row['total_revenue'], 'first_bid_date' => $row['first_bid_date'], 'last_bid_date' => $row['last_bid_date'] ]; } return $dealer_performance; } catch (Exception $e) { error_log("EstimateManager::getDealerPerformance Error: " . $e->getMessage()); return []; } } /** * 매출 및 결제 현황 * * @param string $date_from 시작 날짜 * @param string $date_to 종료 날짜 * @return array 매출 데이터 */ public function getRevenueStatistics($date_from = null, $date_to = null) { try { $where_conditions = ["e.is_deleted = 0", "e.selected_bid_id IS NOT NULL"]; if ($date_from) { $where_conditions[] = "DATE(e.created_at) >= '{$date_from}'"; } if ($date_to) { $where_conditions[] = "DATE(e.created_at) <= '{$date_to}'"; } $where_clause = implode(' AND ', $where_conditions); // 1. 전체 매출 현황 $revenue_sql = " SELECT COUNT(*) as total_orders, SUM(b.total_amount) as total_revenue, AVG(b.total_amount) as avg_order_value, COUNT(CASE WHEN w.wr_1 IN ('입금확인', '중도금입금완료', '잔금입금완료', '시공완료') THEN 1 END) as paid_orders, SUM(CASE WHEN w.wr_1 IN ('입금확인', '중도금입금완료', '잔금입금완료', '시공완료') THEN b.total_amount ELSE 0 END) as paid_revenue FROM {$this->estimate_table} e LEFT JOIN {$this->bidding_table} b ON e.selected_bid_id = b.id LEFT JOIN g5_write_order w ON e.wr_id = w.wr_id WHERE {$where_clause} "; $revenue_stats = sql_fetch($revenue_sql); // 2. 결제 단계별 현황 $payment_sql = " SELECT w.wr_1 as payment_status, COUNT(*) as count, SUM(b.total_amount) as revenue FROM {$this->estimate_table} e LEFT JOIN {$this->bidding_table} b ON e.selected_bid_id = b.id LEFT JOIN g5_write_order w ON e.wr_id = w.wr_id WHERE {$where_clause} AND w.wr_1 IN ('견적채택', '입금예정', '입금확인', '중도금입금예정', '중도금입금완료', '잔금입금예정', '잔금입금완료', '시공완료') GROUP BY w.wr_1 ORDER BY CASE w.wr_1 WHEN '견적채택' THEN 1 WHEN '입금예정' THEN 2 WHEN '입금확인' THEN 3 WHEN '중도금입금예정' THEN 4 WHEN '중도금입금완료' THEN 5 WHEN '잔금입금예정' THEN 6 WHEN '잔금입금완료' THEN 7 WHEN '시공완료' THEN 8 ELSE 9 END "; $payment_result = sql_query($payment_sql); $payment_breakdown = []; while ($row = sql_fetch_array($payment_result)) { $payment_breakdown[] = [ 'status' => $row['payment_status'], 'count' => (int) $row['count'], 'revenue' => (int) $row['revenue'] ]; } // 3. 월별 매출 트렌드 $monthly_revenue_sql = " SELECT DATE_FORMAT(e.created_at, '%Y-%m') as month, COUNT(*) as order_count, SUM(b.total_amount) as monthly_revenue, AVG(b.total_amount) as avg_monthly_order FROM {$this->estimate_table} e LEFT JOIN {$this->bidding_table} b ON e.selected_bid_id = b.id WHERE e.is_deleted = 0 AND e.selected_bid_id IS NOT NULL AND e.created_at >= DATE_SUB(NOW(), INTERVAL 12 MONTH) GROUP BY DATE_FORMAT(e.created_at, '%Y-%m') ORDER BY month DESC LIMIT 12 "; $monthly_result = sql_query($monthly_revenue_sql); $monthly_revenue = []; while ($row = sql_fetch_array($monthly_result)) { $monthly_revenue[] = [ 'month' => $row['month'], 'order_count' => (int) $row['order_count'], 'revenue' => (int) $row['monthly_revenue'], 'avg_order_value' => (int) $row['avg_monthly_order'] ]; } return [ 'total_revenue' => $revenue_stats, 'payment_breakdown' => $payment_breakdown, 'monthly_revenue' => array_reverse($monthly_revenue), 'date_range' => [ 'from' => $date_from, 'to' => $date_to ] ]; } catch (Exception $e) { error_log("EstimateManager::getRevenueStatistics Error: " . $e->getMessage()); return [ 'total_revenue' => [], 'payment_breakdown' => [], 'monthly_revenue' => [], 'date_range' => [] ]; } } /** * 전문가 방문 통계 * * @param string $date_from 시작 날짜 * @param string $date_to 종료 날짜 * @return array 전문가 방문 통계 */ public function getExpertVisitStatistics($date_from = null, $date_to = null) { try { $where_conditions = ["e.is_deleted = 0", "e.temp_1 = 'Y'"]; if ($date_from) { $where_conditions[] = "DATE(e.created_at) >= '{$date_from}'"; } if ($date_to) { $where_conditions[] = "DATE(e.created_at) <= '{$date_to}'"; } $where_clause = implode(' AND ', $where_conditions); // 1. 전문가 방문 현황 $visit_sql = " SELECT COUNT(*) as total_requests, COUNT(CASE WHEN e.temp_2 = 'payment_confirmed' THEN 1 END) as paid_requests, COUNT(CASE WHEN e.temp_2 = 'scheduled' THEN 1 END) as scheduled_visits, COUNT(CASE WHEN e.temp_2 = 'completed' THEN 1 END) as completed_visits, SUM(CASE WHEN e.temp_3 > 0 THEN e.temp_3 ELSE 0 END) as total_visit_revenue, AVG(CASE WHEN e.temp_3 > 0 THEN e.temp_3 ELSE NULL END) as avg_visit_fee FROM {$this->estimate_table} e WHERE {$where_clause} "; $visit_stats = sql_fetch($visit_sql); // 2. 전문가별 방문 현황 $expert_sql = " SELECT e.extra_4 as expert_id, m.mb_name as expert_name, COUNT(*) as assigned_visits, COUNT(CASE WHEN e.temp_2 = 'completed' THEN 1 END) as completed_visits FROM {$this->estimate_table} e LEFT JOIN g5_member m ON e.extra_4 = m.mb_id WHERE {$where_clause} AND e.extra_4 IS NOT NULL AND e.extra_4 != '' GROUP BY e.extra_4, m.mb_name ORDER BY completed_visits DESC, assigned_visits DESC "; $expert_result = sql_query($expert_sql); $expert_performance = []; while ($row = sql_fetch_array($expert_result)) { $completion_rate = $row['assigned_visits'] > 0 ? round(($row['completed_visits'] / $row['assigned_visits']) * 100, 2) : 0; $expert_performance[] = [ 'expert_id' => $row['expert_id'], 'expert_name' => $row['expert_name'] ?: $row['expert_id'], 'assigned_visits' => (int) $row['assigned_visits'], 'completed_visits' => (int) $row['completed_visits'], 'completion_rate' => $completion_rate ]; } return [ 'visit_statistics' => $visit_stats, 'expert_performance' => $expert_performance, 'date_range' => [ 'from' => $date_from, 'to' => $date_to ] ]; } catch (Exception $e) { error_log("EstimateManager::getExpertVisitStatistics Error: " . $e->getMessage()); return [ 'visit_statistics' => [], 'expert_performance' => [], 'date_range' => [] ]; } } /** * 통계 데이터 CSV 내보내기 * * @param string $report_type 리포트 타입 (estimates, dealers, revenue, expert_visits) * @param string $date_from 시작 날짜 * @param string $date_to 종료 날짜 * @return string CSV 데이터 */ public function exportStatisticsCSV($report_type, $date_from = null, $date_to = null) { try { $csv_data = ''; $headers = []; $rows = []; switch ($report_type) { case 'estimates': $stats = $this->getEstimateStatistics($date_from, $date_to); $headers = ['월', '총 견적수', '선택된 견적수', '전환율(%)']; foreach ($stats['monthly_trend'] as $trend) { $rows[] = [ $trend['month'], $trend['total_count'], $trend['selected_count'], $trend['conversion_rate'] ]; } break; case 'dealers': $dealers = $this->getDealerPerformance($date_from, $date_to); $headers = ['대리점ID', '대리점명', '레벨', '총 입찰수', '선택된 입찰수', '성공률(%)', '평균 입찰금액', '총 매출']; foreach ($dealers as $dealer) { $rows[] = [ $dealer['dealer_id'], $dealer['dealer_name'], $dealer['dealer_level'], $dealer['total_bids'], $dealer['selected_bids'], $dealer['success_rate'], number_format($dealer['avg_bid_amount']), number_format($dealer['total_revenue']) ]; } break; case 'revenue': $revenue = $this->getRevenueStatistics($date_from, $date_to); $headers = ['월', '주문수', '매출', '평균 주문금액']; foreach ($revenue['monthly_revenue'] as $monthly) { $rows[] = [ $monthly['month'], $monthly['order_count'], number_format($monthly['revenue']), number_format($monthly['avg_order_value']) ]; } break; case 'expert_visits': $visits = $this->getExpertVisitStatistics($date_from, $date_to); $headers = ['전문가ID', '전문가명', '배정된 방문수', '완료된 방문수', '완료율(%)']; foreach ($visits['expert_performance'] as $expert) { $rows[] = [ $expert['expert_id'], $expert['expert_name'], $expert['assigned_visits'], $expert['completed_visits'], $expert['completion_rate'] ]; } break; default: throw new Exception('유효하지 않은 리포트 타입입니다.'); } // CSV 헤더 추가 $csv_data .= implode(',', $headers) . "\n"; // CSV 데이터 추가 foreach ($rows as $row) { $csv_data .= implode(',', $row) . "\n"; } return $csv_data; } catch (Exception $e) { error_log("EstimateManager::exportStatisticsCSV Error: " . $e->getMessage()); return ''; } } private function write_debug_log($message) { $log_dir = G5_PATH . '/log'; // 1. 디렉토리 존재 여부 확인 및 생성 if (!is_dir($log_dir)) { if (!@mkdir($log_dir, 0755, true) && !is_dir($log_dir)) { error_log("--- NotificationSender ERROR: 디버그 로그 디렉토리 생성 실패. '{$log_dir}' 경로를 확인하거나 수동으로 생성 후 웹서버 쓰기 권한을 부여해주세요."); return; } } // 2. 디렉토리 쓰기 권한 확인 if (!is_writable($log_dir)) { error_log("--- NotificationSender ERROR: 디버그 로그 쓰기 오류. '{$log_dir}' 디렉토리에 쓰기 권한이 없습니다. 웹서버의 폴더 권한을 확인해주세요."); return; } // 3. 로그 파일에 내용 기록 $log_file = $log_dir . '/notification_debug.log'; $log_message = date("[Y-m-d H:i:s]") . " " . $message . "\n"; if (file_put_contents($log_file, $log_message, FILE_APPEND | LOCK_EX) === false) { error_log("--- NotificationSender ERROR: 디버그 로그 파일 쓰기 실패. '{$log_file}' 파일에 내용을 쓸 수 없습니다."); } } }