CI = &get_instance(); $this->CI->load->model('desk_moloni_model'); $this->CI->load->model('estimates_model'); $this->model = $this->CI->desk_moloni_model; $this->api_client = new MoloniApiClient(); $this->entity_mapping = new EntityMappingService(); $this->error_handler = new ErrorHandler(); $this->client_sync = new ClientSyncService(); $this->product_sync = new ProductSyncService(); log_activity('EstimateSyncService initialized'); } /** * Sync estimate from Perfex to Moloni * * @param int $perfex_estimate_id * @param bool $force_update * @param array $additional_data * @return array */ public function sync_perfex_to_moloni($perfex_estimate_id, $force_update = false, $additional_data = []) { $start_time = microtime(true); try { // Get Perfex estimate data $perfex_estimate = $this->get_perfex_estimate($perfex_estimate_id); if (!$perfex_estimate) { throw new \Exception("Perfex estimate ID {$perfex_estimate_id} not found"); } // Check existing mapping $mapping = $this->entity_mapping->get_mapping_by_perfex_id( EntityMappingService::ENTITY_ESTIMATE, $perfex_estimate_id ); // Validate sync conditions if (!$this->should_sync_to_moloni($mapping, $force_update)) { return [ 'success' => true, 'message' => 'Estimate already synced and up to date', 'mapping_id' => $mapping ? $mapping->id : null, 'moloni_estimate_id' => $mapping ? $mapping->moloni_id : null, 'skipped' => true ]; } // Check for conflicts if mapping exists if ($mapping && !$force_update) { $conflict_check = $this->check_sync_conflicts($mapping); if ($conflict_check['has_conflict']) { return $this->handle_sync_conflict($mapping, $conflict_check); } } // Ensure client is synced first $client_result = $this->ensure_client_synced($perfex_estimate); if (!$client_result['success']) { throw new \Exception("Failed to sync client: " . $client_result['message']); } // Sync estimate items/products $products_result = $this->sync_estimate_products($perfex_estimate); if (!$products_result['success']) { log_message('warning', "Some products failed to sync for estimate {$perfex_estimate_id}: " . $products_result['message']); } // Transform Perfex data to Moloni format $moloni_data = $this->map_perfex_to_moloni_estimate($perfex_estimate, $additional_data); // Create or update estimate in Moloni $moloni_result = $this->create_or_update_moloni_estimate($moloni_data, $mapping); if (!$moloni_result['success']) { throw new \Exception("Moloni API error: " . $moloni_result['message']); } $moloni_estimate_id = $moloni_result['estimate_id']; $action = $moloni_result['action']; // Update or create mapping $mapping_id = $this->update_or_create_mapping( EntityMappingService::ENTITY_ESTIMATE, $perfex_estimate_id, $moloni_estimate_id, EntityMappingService::DIRECTION_PERFEX_TO_MOLONI, $mapping ); // Log sync activity $execution_time = microtime(true) - $start_time; $this->log_sync_activity([ 'entity_type' => 'estimate', 'entity_id' => $perfex_estimate_id, 'action' => $action, 'direction' => 'perfex_to_moloni', 'status' => 'success', 'mapping_id' => $mapping_id, 'request_data' => json_encode($moloni_data), 'response_data' => json_encode($moloni_result), 'processing_time' => $execution_time, 'perfex_data_hash' => $this->calculate_data_hash($perfex_estimate), 'moloni_data_hash' => $this->calculate_data_hash($moloni_result['data'] ?? []) ]); return [ 'success' => true, 'message' => "Estimate {$action}d successfully in Moloni", 'mapping_id' => $mapping_id, 'moloni_estimate_id' => $moloni_estimate_id, 'action' => $action, 'execution_time' => $execution_time, 'data_changes' => $this->detect_data_changes($perfex_estimate, $moloni_result['data'] ?? []) ]; } catch (\Exception $e) { return $this->handle_sync_error($e, [ 'entity_type' => 'estimate', 'entity_id' => $perfex_estimate_id, 'direction' => 'perfex_to_moloni', 'execution_time' => microtime(true) - $start_time, 'mapping' => $mapping ?? null ]); } } /** * Sync estimate from Moloni to Perfex * * @param int $moloni_estimate_id * @param bool $force_update * @param array $additional_data * @return array */ public function sync_moloni_to_perfex($moloni_estimate_id, $force_update = false, $additional_data = []) { $start_time = microtime(true); try { // Get Moloni estimate data $moloni_response = $this->api_client->get_estimate($moloni_estimate_id); if (!$moloni_response['success']) { throw new \Exception("Moloni estimate ID {$moloni_estimate_id} not found: " . $moloni_response['message']); } $moloni_estimate = $moloni_response['data']; // Check existing mapping $mapping = $this->entity_mapping->get_mapping_by_moloni_id( EntityMappingService::ENTITY_ESTIMATE, $moloni_estimate_id ); // Validate sync conditions if (!$this->should_sync_to_perfex($mapping, $force_update)) { return [ 'success' => true, 'message' => 'Estimate already synced and up to date', 'mapping_id' => $mapping ? $mapping->id : null, 'perfex_estimate_id' => $mapping ? $mapping->perfex_id : null, 'skipped' => true ]; } // Check for conflicts if mapping exists if ($mapping && !$force_update) { $conflict_check = $this->check_sync_conflicts($mapping); if ($conflict_check['has_conflict']) { return $this->handle_sync_conflict($mapping, $conflict_check); } } // Ensure client is synced first $client_result = $this->ensure_moloni_client_synced($moloni_estimate); if (!$client_result['success']) { throw new \Exception("Failed to sync client: " . $client_result['message']); } // Transform Moloni data to Perfex format $perfex_data = $this->map_moloni_to_perfex_estimate($moloni_estimate, $additional_data); // Create or update estimate in Perfex $perfex_result = $this->create_or_update_perfex_estimate($perfex_data, $mapping); if (!$perfex_result['success']) { throw new \Exception("Perfex CRM error: " . $perfex_result['message']); } $perfex_estimate_id = $perfex_result['estimate_id']; $action = $perfex_result['action']; // Sync estimate items $this->sync_moloni_estimate_items($moloni_estimate, $perfex_estimate_id); // Update or create mapping $mapping_id = $this->update_or_create_mapping( EntityMappingService::ENTITY_ESTIMATE, $perfex_estimate_id, $moloni_estimate_id, EntityMappingService::DIRECTION_MOLONI_TO_PERFEX, $mapping ); // Log sync activity $execution_time = microtime(true) - $start_time; $this->log_sync_activity([ 'entity_type' => 'estimate', 'entity_id' => $perfex_estimate_id, 'action' => $action, 'direction' => 'moloni_to_perfex', 'status' => 'success', 'mapping_id' => $mapping_id, 'request_data' => json_encode($moloni_estimate), 'response_data' => json_encode($perfex_result), 'processing_time' => $execution_time, 'moloni_data_hash' => $this->calculate_data_hash($moloni_estimate), 'perfex_data_hash' => $this->calculate_data_hash($perfex_result['data'] ?? []) ]); return [ 'success' => true, 'message' => "Estimate {$action}d successfully in Perfex", 'mapping_id' => $mapping_id, 'perfex_estimate_id' => $perfex_estimate_id, 'action' => $action, 'execution_time' => $execution_time, 'data_changes' => $this->detect_data_changes($moloni_estimate, $perfex_result['data'] ?? []) ]; } catch (\Exception $e) { return $this->handle_sync_error($e, [ 'entity_type' => 'estimate', 'entity_id' => $moloni_estimate_id, 'direction' => 'moloni_to_perfex', 'execution_time' => microtime(true) - $start_time, 'mapping' => $mapping ?? null ]); } } /** * Check for synchronization conflicts * * @param object $mapping * @return array */ public function check_sync_conflicts($mapping) { try { $conflicts = []; // Get current data from both systems $perfex_estimate = $this->get_perfex_estimate($mapping->perfex_id); $moloni_response = $this->api_client->get_estimate($mapping->moloni_id); if (!$perfex_estimate || !$moloni_response['success']) { return ['has_conflict' => false]; } $moloni_estimate = $moloni_response['data']; // Check modification timestamps $perfex_modified = $this->get_perfex_modification_time($mapping->perfex_id); $moloni_modified = $this->get_moloni_modification_time($mapping->moloni_id); $last_sync = max( strtotime($mapping->last_sync_perfex ?: '1970-01-01'), strtotime($mapping->last_sync_moloni ?: '1970-01-01') ); $perfex_changed_after_sync = $perfex_modified > $last_sync; $moloni_changed_after_sync = $moloni_modified > $last_sync; if ($perfex_changed_after_sync && $moloni_changed_after_sync) { // Both sides modified since last sync - check for field conflicts $field_conflicts = $this->detect_estimate_field_conflicts($perfex_estimate, $moloni_estimate); if (!empty($field_conflicts)) { $conflicts = [ 'type' => 'data_conflict', 'message' => 'Both systems have been modified since last sync', 'field_conflicts' => $field_conflicts, 'perfex_modified' => date('Y-m-d H:i:s', $perfex_modified), 'moloni_modified' => date('Y-m-d H:i:s', $moloni_modified), 'last_sync' => $mapping->last_sync_perfex ?: $mapping->last_sync_moloni ]; } } // Check for status conflicts if ($this->has_status_conflicts($perfex_estimate, $moloni_estimate)) { $conflicts['status_conflict'] = [ 'perfex_status' => $perfex_estimate['status'], 'moloni_status' => $moloni_estimate['status'], 'message' => 'Estimate status differs between systems' ]; } return [ 'has_conflict' => !empty($conflicts), 'conflict_details' => $conflicts ]; } catch (\Exception $e) { $this->error_handler->log_error('sync', 'ESTIMATE_CONFLICT_CHECK_FAILED', $e->getMessage(), [ 'mapping_id' => $mapping->id ]); return ['has_conflict' => false]; } } /** * Map Perfex estimate to Moloni format * * @param array $perfex_estimate * @param array $additional_data * @return array */ protected function map_perfex_to_moloni_estimate($perfex_estimate, $additional_data = []) { // Get client mapping $client_mapping = $this->entity_mapping->get_mapping_by_perfex_id( EntityMappingService::ENTITY_CUSTOMER, $perfex_estimate['clientid'] ); if (!$client_mapping) { throw new \Exception("Client {$perfex_estimate['clientid']} must be synced before estimate sync"); } // Get estimate items $estimate_items = $this->CI->estimates_model->get_estimate_items($perfex_estimate['id']); $moloni_products = []; foreach ($estimate_items as $item) { $moloni_products[] = $this->map_perfex_estimate_item_to_moloni($item); } $mapped_data = [ 'document_type' => $this->get_moloni_document_type($perfex_estimate), 'customer_id' => $client_mapping->moloni_id, 'document_set_id' => $this->get_default_document_set(), 'date' => $perfex_estimate['date'], 'expiration_date' => $perfex_estimate['expirydate'], 'your_reference' => $perfex_estimate['estimate_number'], 'our_reference' => $perfex_estimate['admin_note'] ?? '', 'financial_discount' => (float)$perfex_estimate['discount_percent'], 'special_discount' => (float)$perfex_estimate['discount_total'], 'exchange_currency_id' => $this->convert_currency($perfex_estimate['currency'] ?? get_base_currency()->id), 'exchange_rate' => 1.0, 'notes' => $this->build_estimate_notes($perfex_estimate), 'status' => $this->convert_perfex_status_to_moloni($perfex_estimate['status']), 'products' => $moloni_products, 'valid_until' => $perfex_estimate['expirydate'] ]; // Add tax summary $mapped_data['tax_exemption'] = $this->get_tax_exemption_reason($perfex_estimate); // Apply additional data overrides $mapped_data = array_merge($mapped_data, $additional_data); // Clean and validate data return $this->clean_moloni_estimate_data($mapped_data); } /** * Map Moloni estimate to Perfex format * * @param array $moloni_estimate * @param array $additional_data * @return array */ protected function map_moloni_to_perfex_estimate($moloni_estimate, $additional_data = []) { // Get client mapping $client_mapping = $this->entity_mapping->get_mapping_by_moloni_id( EntityMappingService::ENTITY_CUSTOMER, $moloni_estimate['customer_id'] ); if (!$client_mapping) { throw new \Exception("Customer {$moloni_estimate['customer_id']} must be synced before estimate sync"); } $mapped_data = [ 'clientid' => $client_mapping->perfex_id, 'number' => $moloni_estimate['document_number'] ?? '', 'date' => $moloni_estimate['date'], 'expirydate' => $moloni_estimate['valid_until'] ?? $moloni_estimate['expiration_date'], 'currency' => $this->convert_moloni_currency_to_perfex($moloni_estimate['exchange_currency_id']), 'subtotal' => (float)$moloni_estimate['net_value'], 'total_tax' => (float)$moloni_estimate['tax_value'], 'total' => (float)$moloni_estimate['gross_value'], 'discount_percent' => (float)$moloni_estimate['financial_discount'], 'discount_total' => (float)$moloni_estimate['special_discount'], 'status' => $this->convert_moloni_status_to_perfex($moloni_estimate['status']), 'adminnote' => $moloni_estimate['our_reference'] ?? '', 'clientnote' => $moloni_estimate['notes'] ?? '' ]; // Apply additional data overrides $mapped_data = array_merge($mapped_data, $additional_data); // Clean and validate data return $this->clean_perfex_estimate_data($mapped_data); } /** * Map Perfex estimate item to Moloni product format * * @param array $item * @return array */ protected function map_perfex_estimate_item_to_moloni($item) { // Try to get product mapping $product_mapping = null; if (!empty($item['rel_id']) && $item['rel_type'] === 'item') { $product_mapping = $this->entity_mapping->get_mapping_by_perfex_id( EntityMappingService::ENTITY_PRODUCT, $item['rel_id'] ); } return [ 'product_id' => $product_mapping ? $product_mapping->moloni_id : null, 'name' => $item['description'], 'summary' => $item['long_description'] ?? '', 'qty' => (float)$item['qty'], 'price' => (float)$item['rate'], 'discount' => 0, 'order' => (int)$item['item_order'], 'exemption_reason' => '', 'taxes' => $this->get_item_tax_data($item) ]; } /** * Ensure client is synced before estimate sync * * @param array $perfex_estimate * @return array */ protected function ensure_client_synced($perfex_estimate) { $mapping = $this->entity_mapping->get_mapping_by_perfex_id( EntityMappingService::ENTITY_CUSTOMER, $perfex_estimate['clientid'] ); if (!$mapping) { // Sync client first return $this->client_sync->sync_perfex_to_moloni($perfex_estimate['clientid'], false); } return ['success' => true, 'message' => 'Client already synced']; } /** * Ensure Moloni client is synced * * @param array $moloni_estimate * @return array */ protected function ensure_moloni_client_synced($moloni_estimate) { $mapping = $this->entity_mapping->get_mapping_by_moloni_id( EntityMappingService::ENTITY_CUSTOMER, $moloni_estimate['customer_id'] ); if (!$mapping) { // Sync client first return $this->client_sync->sync_moloni_to_perfex($moloni_estimate['customer_id'], false); } return ['success' => true, 'message' => 'Client already synced']; } /** * Sync estimate products * * @param array $perfex_estimate * @return array */ protected function sync_estimate_products($perfex_estimate) { $results = ['success' => true, 'synced' => 0, 'errors' => []]; $estimate_items = $this->CI->estimates_model->get_estimate_items($perfex_estimate['id']); foreach ($estimate_items as $item) { if (!empty($item['rel_id']) && $item['rel_type'] === 'item') { try { $sync_result = $this->product_sync->sync_perfex_to_moloni($item['rel_id'], false); if ($sync_result['success']) { $results['synced']++; } else { $results['errors'][] = "Product {$item['rel_id']}: " . $sync_result['message']; } } catch (\Exception $e) { $results['errors'][] = "Product {$item['rel_id']}: " . $e->getMessage(); } } } if (!empty($results['errors'])) { $results['success'] = false; $results['message'] = "Some products failed to sync: " . implode(', ', array_slice($results['errors'], 0, 3)); } return $results; } /** * Create or update estimate in Moloni * * @param array $moloni_data * @param object $mapping * @return array */ protected function create_or_update_moloni_estimate($moloni_data, $mapping = null) { if ($mapping && $mapping->moloni_id) { // Update existing estimate $response = $this->api_client->update_estimate($mapping->moloni_id, $moloni_data); if ($response['success']) { return [ 'success' => true, 'estimate_id' => $mapping->moloni_id, 'action' => 'update', 'data' => $response['data'] ]; } } // Create new estimate or fallback to create if update failed $response = $this->api_client->create_estimate($moloni_data); if ($response['success']) { return [ 'success' => true, 'estimate_id' => $response['data']['document_id'], 'action' => 'create', 'data' => $response['data'] ]; } return [ 'success' => false, 'message' => $response['message'] ?? 'Unknown error creating/updating estimate in Moloni' ]; } /** * Create or update estimate in Perfex * * @param array $perfex_data * @param object $mapping * @return array */ protected function create_or_update_perfex_estimate($perfex_data, $mapping = null) { if ($mapping && $mapping->perfex_id) { // Update existing estimate $result = $this->CI->estimates_model->update($perfex_data, $mapping->perfex_id); if ($result) { return [ 'success' => true, 'estimate_id' => $mapping->perfex_id, 'action' => 'update', 'data' => $perfex_data ]; } } // Create new estimate or fallback to create if update failed $estimate_id = $this->CI->estimates_model->add($perfex_data); if ($estimate_id) { return [ 'success' => true, 'estimate_id' => $estimate_id, 'action' => 'create', 'data' => $perfex_data ]; } return [ 'success' => false, 'message' => 'Failed to create/update estimate in Perfex CRM' ]; } /** * Get Perfex estimate data * * @param int $estimate_id * @return array|null */ protected function get_perfex_estimate($estimate_id) { $estimate = $this->CI->estimates_model->get($estimate_id); return $estimate ? (array)$estimate : null; } /** * Convert Perfex status to Moloni status * * @param int $perfex_status * @return string */ protected function convert_perfex_status_to_moloni($perfex_status) { $status_mapping = [ self::STATUS_DRAFT => 'draft', self::STATUS_SENT => 'sent', self::STATUS_DECLINED => 'declined', self::STATUS_ACCEPTED => 'accepted', self::STATUS_EXPIRED => 'expired' ]; return $status_mapping[$perfex_status] ?? 'draft'; } /** * Convert Moloni status to Perfex status * * @param string $moloni_status * @return int */ protected function convert_moloni_status_to_perfex($moloni_status) { $status_mapping = [ 'draft' => self::STATUS_DRAFT, 'sent' => self::STATUS_SENT, 'declined' => self::STATUS_DECLINED, 'accepted' => self::STATUS_ACCEPTED, 'expired' => self::STATUS_EXPIRED ]; return $status_mapping[$moloni_status] ?? self::STATUS_DRAFT; } /** * Calculate data hash for change detection * * @param array $data * @return string */ protected function calculate_data_hash($data) { ksort($data); return md5(serialize($data)); } /** * Handle sync error * * @param \Exception $e * @param array $context * @return array */ protected function handle_sync_error($e, $context) { $execution_time = $context['execution_time']; // Update mapping with error if exists if (isset($context['mapping']) && $context['mapping']) { $this->entity_mapping->update_mapping_status( $context['mapping']->id, EntityMappingService::STATUS_ERROR, $e->getMessage() ); } // Log error $this->error_handler->log_error('sync', 'ESTIMATE_SYNC_FAILED', $e->getMessage(), $context); // Log sync activity $this->log_sync_activity([ 'entity_type' => $context['entity_type'], 'entity_id' => $context['entity_id'], 'action' => 'sync', 'direction' => $context['direction'], 'status' => 'error', 'error_message' => $e->getMessage(), 'processing_time' => $execution_time ]); return [ 'success' => false, 'message' => $e->getMessage(), 'execution_time' => $execution_time, 'error_code' => $e->getCode() ]; } /** * Log sync activity * * @param array $data */ protected function log_sync_activity($data) { $this->model->log_sync_activity($data); } // Additional helper methods for specific estimate functionality... protected function should_sync_to_moloni($mapping, $force_update) { return true; } protected function should_sync_to_perfex($mapping, $force_update) { return true; } protected function handle_sync_conflict($mapping, $conflict_check) { return ['success' => false, 'message' => 'Conflict detected']; } protected function detect_data_changes($old_data, $new_data) { return []; } protected function update_or_create_mapping($entity_type, $perfex_id, $moloni_id, $direction, $mapping) { return 1; } protected function detect_estimate_field_conflicts($perfex_estimate, $moloni_estimate) { return []; } protected function has_status_conflicts($perfex_estimate, $moloni_estimate) { return false; } protected function get_moloni_document_type($perfex_estimate) { return self::MOLONI_DOC_TYPE_QUOTE; } protected function get_default_document_set() { return 1; } protected function convert_currency($currency_id) { return 1; } protected function build_estimate_notes($perfex_estimate) { return $perfex_estimate['clientnote'] ?? ''; } protected function get_tax_exemption_reason($perfex_estimate) { return ''; } protected function clean_moloni_estimate_data($data) { return $data; } protected function clean_perfex_estimate_data($data) { return $data; } protected function convert_moloni_currency_to_perfex($currency_id) { return 1; } protected function get_item_tax_data($item) { return []; } protected function sync_moloni_estimate_items($moloni_estimate, $perfex_estimate_id) { return true; } protected function get_perfex_modification_time($estimate_id) { return time(); } protected function get_moloni_modification_time($estimate_id) { return time(); } }