CI = &get_instance(); $this->CI->load->model('desk_moloni_model'); $this->CI->load->model('items_model'); $this->model = $this->CI->desk_moloni_model; $this->api_client = new MoloniApiClient(); $this->entity_mapping = new EntityMappingService(); $this->error_handler = new ErrorHandler(); log_activity('ProductSyncService initialized'); } /** * Sync product from Perfex to Moloni * * @param int $perfex_item_id * @param bool $force_update * @param array $additional_data * @return array */ public function sync_perfex_to_moloni($perfex_item_id, $force_update = false, $additional_data = []) { $start_time = microtime(true); try { // Get Perfex item/product data $perfex_item = $this->get_perfex_item($perfex_item_id); if (!$perfex_item) { throw new \Exception("Perfex item ID {$perfex_item_id} not found"); } // Check existing mapping $mapping = $this->entity_mapping->get_mapping_by_perfex_id( EntityMappingService::ENTITY_PRODUCT, $perfex_item_id ); // Validate sync conditions if (!$this->should_sync_to_moloni($mapping, $force_update)) { return [ 'success' => true, 'message' => 'Product already synced and up to date', 'mapping_id' => $mapping ? $mapping->id : null, 'moloni_product_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); } } // Transform Perfex data to Moloni format $moloni_data = $this->map_perfex_to_moloni_product($perfex_item, $additional_data); // Find or create product in Moloni $moloni_result = $this->create_or_update_moloni_product($moloni_data, $mapping); if (!$moloni_result['success']) { throw new \Exception("Moloni API error: " . $moloni_result['message']); } $moloni_product_id = $moloni_result['product_id']; $action = $moloni_result['action']; // Sync product taxes if configured if (get_option('desk_moloni_sync_product_taxes') == '1') { $this->sync_product_taxes($perfex_item, $moloni_product_id); } // Sync product variants if they exist if (get_option('desk_moloni_sync_product_variants') == '1') { $this->sync_product_variants($perfex_item, $moloni_product_id); } // Update or create mapping $mapping_id = $this->update_or_create_mapping( EntityMappingService::ENTITY_PRODUCT, $perfex_item_id, $moloni_product_id, EntityMappingService::DIRECTION_PERFEX_TO_MOLONI, $mapping ); // Log sync activity $execution_time = microtime(true) - $start_time; $this->log_sync_activity([ 'entity_type' => 'product', 'entity_id' => $perfex_item_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_item), 'moloni_data_hash' => $this->calculate_data_hash($moloni_result['data'] ?? []) ]); return [ 'success' => true, 'message' => "Product {$action}d successfully in Moloni", 'mapping_id' => $mapping_id, 'moloni_product_id' => $moloni_product_id, 'action' => $action, 'execution_time' => $execution_time, 'data_changes' => $this->detect_data_changes($perfex_item, $moloni_result['data'] ?? []) ]; } catch (\Exception $e) { return $this->handle_sync_error($e, [ 'entity_type' => 'product', 'entity_id' => $perfex_item_id, 'direction' => 'perfex_to_moloni', 'execution_time' => microtime(true) - $start_time, 'mapping' => $mapping ?? null ]); } } /** * Sync product from Moloni to Perfex * * @param int $moloni_product_id * @param bool $force_update * @param array $additional_data * @return array */ public function sync_moloni_to_perfex($moloni_product_id, $force_update = false, $additional_data = []) { $start_time = microtime(true); try { // Get Moloni product data $moloni_response = $this->api_client->get_product($moloni_product_id); if (!$moloni_response['success']) { throw new \Exception("Moloni product ID {$moloni_product_id} not found: " . $moloni_response['message']); } $moloni_product = $moloni_response['data']; // Check existing mapping $mapping = $this->entity_mapping->get_mapping_by_moloni_id( EntityMappingService::ENTITY_PRODUCT, $moloni_product_id ); // Validate sync conditions if (!$this->should_sync_to_perfex($mapping, $force_update)) { return [ 'success' => true, 'message' => 'Product already synced and up to date', 'mapping_id' => $mapping ? $mapping->id : null, 'perfex_item_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); } } // Transform Moloni data to Perfex format $perfex_data = $this->map_moloni_to_perfex_product($moloni_product, $additional_data); // Find or create product in Perfex $perfex_result = $this->create_or_update_perfex_product($perfex_data, $mapping); if (!$perfex_result['success']) { throw new \Exception("Perfex CRM error: " . $perfex_result['message']); } $perfex_item_id = $perfex_result['item_id']; $action = $perfex_result['action']; // Update or create mapping $mapping_id = $this->update_or_create_mapping( EntityMappingService::ENTITY_PRODUCT, $perfex_item_id, $moloni_product_id, EntityMappingService::DIRECTION_MOLONI_TO_PERFEX, $mapping ); // Log sync activity $execution_time = microtime(true) - $start_time; $this->log_sync_activity([ 'entity_type' => 'product', 'entity_id' => $perfex_item_id, 'action' => $action, 'direction' => 'moloni_to_perfex', 'status' => 'success', 'mapping_id' => $mapping_id, 'request_data' => json_encode($moloni_product), 'response_data' => json_encode($perfex_result), 'processing_time' => $execution_time, 'moloni_data_hash' => $this->calculate_data_hash($moloni_product), 'perfex_data_hash' => $this->calculate_data_hash($perfex_result['data'] ?? []) ]); return [ 'success' => true, 'message' => "Product {$action}d successfully in Perfex", 'mapping_id' => $mapping_id, 'perfex_item_id' => $perfex_item_id, 'action' => $action, 'execution_time' => $execution_time, 'data_changes' => $this->detect_data_changes($moloni_product, $perfex_result['data'] ?? []) ]; } catch (\Exception $e) { return $this->handle_sync_error($e, [ 'entity_type' => 'product', 'entity_id' => $moloni_product_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_item = $this->get_perfex_item($mapping->perfex_id); $moloni_response = $this->api_client->get_product($mapping->moloni_id); if (!$perfex_item || !$moloni_response['success']) { return ['has_conflict' => false]; } $moloni_product = $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_product_field_conflicts($perfex_item, $moloni_product); 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 stock conflicts if stock sync is enabled if (get_option('desk_moloni_sync_stock') == '1') { $stock_conflict = $this->check_stock_conflicts($perfex_item, $moloni_product, $last_sync); if ($stock_conflict) { $conflicts['stock_conflict'] = $stock_conflict; } } return [ 'has_conflict' => !empty($conflicts), 'conflict_details' => $conflicts ]; } catch (\Exception $e) { $this->error_handler->log_error('sync', 'PRODUCT_CONFLICT_CHECK_FAILED', $e->getMessage(), [ 'mapping_id' => $mapping->id ]); return ['has_conflict' => false]; } } /** * Detect field-level conflicts between product data * * @param array $perfex_data * @param array $moloni_data * @return array */ protected function detect_product_field_conflicts($perfex_data, $moloni_data) { $conflicts = []; // Define critical fields to check for conflicts $critical_fields = [ 'name' => ['perfex' => 'description', 'moloni' => 'name'], 'reference' => ['perfex' => 'long_description', 'moloni' => 'reference'], 'price' => ['perfex' => 'rate', 'moloni' => 'price'], 'tax_rate' => ['perfex' => 'tax', 'moloni' => 'tax_id'], 'unit' => ['perfex' => 'unit', 'moloni' => 'measurement_unit_id'] ]; foreach ($critical_fields as $field => $mappings) { $perfex_value = $this->normalize_field_value($perfex_data[$mappings['perfex']] ?? ''); $moloni_value = $this->normalize_field_value($moloni_data[$mappings['moloni']] ?? ''); if ($perfex_value !== $moloni_value && !empty($perfex_value) && !empty($moloni_value)) { $conflicts[$field] = [ 'perfex_value' => $perfex_value, 'moloni_value' => $moloni_value, 'similarity_score' => $this->calculate_field_similarity($perfex_value, $moloni_value) ]; } } return $conflicts; } /** * Check for stock level conflicts * * @param array $perfex_item * @param array $moloni_product * @param int $last_sync * @return array|null */ protected function check_stock_conflicts($perfex_item, $moloni_product, $last_sync) { // For Perfex CRM, stock management is typically in invoice items or custom fields // This is a placeholder for stock conflict detection logic $perfex_stock = $this->get_perfex_stock_level($perfex_item); $moloni_stock = $moloni_product['stock'] ?? 0; // Check if both stock levels have been modified since last sync $perfex_stock_modified = $this->get_perfex_stock_modification_time($perfex_item['itemid']); $moloni_stock_modified = $this->get_moloni_stock_modification_time($moloni_product['product_id']); if ($perfex_stock_modified > $last_sync && $moloni_stock_modified > $last_sync) { $stock_difference = abs($perfex_stock - $moloni_stock); $threshold = (float)get_option('desk_moloni_stock_conflict_threshold', 5.0); if ($stock_difference > $threshold) { return [ 'perfex_stock' => $perfex_stock, 'moloni_stock' => $moloni_stock, 'difference' => $stock_difference, 'threshold' => $threshold, 'perfex_modified' => date('Y-m-d H:i:s', $perfex_stock_modified), 'moloni_modified' => date('Y-m-d H:i:s', $moloni_stock_modified) ]; } } return null; } /** * Find potential product matches in Moloni * * @param array $perfex_item * @return array */ public function find_moloni_product_matches($perfex_item) { $matches = []; // Search by reference/SKU (highest priority) if (!empty($perfex_item['long_description'])) { $reference_matches = $this->api_client->search_products(['reference' => $perfex_item['long_description']]); if ($reference_matches['success'] && !empty($reference_matches['data'])) { foreach ($reference_matches['data'] as $product) { $matches[] = array_merge($product, [ 'match_score' => self::MATCH_SCORE_EXACT, 'match_type' => 'reference', 'match_criteria' => ['reference' => $perfex_item['long_description']] ]); } } } // Search by name (high priority) if (!empty($perfex_item['description']) && empty($matches)) { $name_matches = $this->api_client->search_products(['name' => $perfex_item['description']]); if ($name_matches['success'] && !empty($name_matches['data'])) { foreach ($name_matches['data'] as $product) { $similarity = $this->calculate_name_similarity($perfex_item['description'], $product['name']); if ($similarity >= 0.7) { $score = $similarity >= 0.9 ? self::MATCH_SCORE_HIGH : self::MATCH_SCORE_MEDIUM; $matches[] = array_merge($product, [ 'match_score' => $score, 'match_type' => 'name', 'match_criteria' => ['name' => $perfex_item['description']], 'similarity' => $similarity ]); } } } } // Search by EAN/Barcode if available if (isset($perfex_item['barcode']) && !empty($perfex_item['barcode']) && count($matches) < 3) { $barcode_matches = $this->api_client->search_products(['ean' => $perfex_item['barcode']]); if ($barcode_matches['success'] && !empty($barcode_matches['data'])) { foreach ($barcode_matches['data'] as $product) { $matches[] = array_merge($product, [ 'match_score' => self::MATCH_SCORE_EXACT, 'match_type' => 'ean', 'match_criteria' => ['ean' => $perfex_item['barcode']] ]); } } } // Remove duplicates and sort by match score $matches = $this->deduplicate_matches($matches); usort($matches, function($a, $b) { return $b['match_score'] - $a['match_score']; }); return array_slice($matches, 0, 10); } /** * Find potential product matches in Perfex * * @param array $moloni_product * @return array */ public function find_perfex_product_matches($moloni_product) { $matches = []; // Search by reference/SKU if (!empty($moloni_product['reference'])) { $reference_matches = $this->CI->items_model->search_items_by_reference($moloni_product['reference']); foreach ($reference_matches as $item) { $matches[] = array_merge((array)$item, [ 'match_score' => self::MATCH_SCORE_EXACT, 'match_type' => 'reference', 'match_criteria' => ['reference' => $moloni_product['reference']] ]); } } // Search by name if (!empty($moloni_product['name']) && empty($matches)) { $name_matches = $this->CI->items_model->search_items_by_name($moloni_product['name']); foreach ($name_matches as $item) { $similarity = $this->calculate_name_similarity($moloni_product['name'], $item->description); if ($similarity >= 0.7) { $score = $similarity >= 0.9 ? self::MATCH_SCORE_HIGH : self::MATCH_SCORE_MEDIUM; $matches[] = array_merge((array)$item, [ 'match_score' => $score, 'match_type' => 'name', 'match_criteria' => ['name' => $moloni_product['name']], 'similarity' => $similarity ]); } } } // Search by EAN/Barcode if (!empty($moloni_product['ean']) && count($matches) < 3) { $barcode_matches = $this->CI->items_model->search_items_by_barcode($moloni_product['ean']); foreach ($barcode_matches as $item) { $matches[] = array_merge((array)$item, [ 'match_score' => self::MATCH_SCORE_EXACT, 'match_type' => 'ean', 'match_criteria' => ['ean' => $moloni_product['ean']] ]); } } // Remove duplicates and sort by match score $matches = $this->deduplicate_matches($matches); usort($matches, function($a, $b) { return $b['match_score'] - $a['match_score']; }); return array_slice($matches, 0, 10); } /** * Map Perfex item to Moloni product format * * @param array $perfex_item * @param array $additional_data * @return array */ protected function map_perfex_to_moloni_product($perfex_item, $additional_data = []) { $mapped_data = [ 'category_id' => $this->get_default_product_category(), 'type' => $this->convert_product_type($perfex_item['type'] ?? 'product'), 'name' => $perfex_item['description'] ?? '', 'reference' => $perfex_item['long_description'] ?? '', 'price' => (float)($perfex_item['rate'] ?? 0), 'price_with_taxes' => 0, // Will be calculated 'unit_id' => $this->convert_measurement_unit($perfex_item['unit'] ?? ''), 'has_stock' => (int)get_option('desk_moloni_track_stock', 1), 'stock' => $this->get_perfex_stock_level($perfex_item), 'minimum_stock' => 0, 'pos_favorite' => 0, 'at_product_category' => '', 'exemption_reason' => '', 'exemption_reason_code' => '', 'warehouse_id' => $this->get_default_warehouse(), 'tax_id' => $this->convert_tax_rate($perfex_item['tax'] ?? 0), 'summary' => $this->generate_product_summary($perfex_item), 'notes' => $perfex_item['notes'] ?? '' ]; // Add EAN/Barcode if available if (isset($perfex_item['barcode']) && !empty($perfex_item['barcode'])) { $mapped_data['ean'] = $perfex_item['barcode']; } // Calculate price with taxes $tax_rate = $this->get_tax_rate_percentage($mapped_data['tax_id']); $mapped_data['price_with_taxes'] = $mapped_data['price'] * (1 + ($tax_rate / 100)); // Apply additional data overrides $mapped_data = array_merge($mapped_data, $additional_data); // Clean and validate data return $this->clean_moloni_product_data($mapped_data); } /** * Map Moloni product to Perfex item format * * @param array $moloni_product * @param array $additional_data * @return array */ protected function map_moloni_to_perfex_product($moloni_product, $additional_data = []) { $mapped_data = [ 'description' => $moloni_product['name'] ?? '', 'long_description' => $moloni_product['reference'] ?? '', 'rate' => (float)($moloni_product['price'] ?? 0), 'tax' => $this->convert_moloni_tax_to_perfex($moloni_product['tax_id'] ?? 0), 'unit' => $this->convert_moloni_unit_to_perfex($moloni_product['unit_id'] ?? 0), 'group_id' => $this->get_default_item_group() ]; // Add barcode/EAN if available if (!empty($moloni_product['ean'])) { $mapped_data['barcode'] = $moloni_product['ean']; } // Apply additional data overrides $mapped_data = array_merge($mapped_data, $additional_data); // Clean and validate data return $this->clean_perfex_product_data($mapped_data); } /** * Create or update product in Moloni * * @param array $moloni_data * @param object $mapping * @return array */ protected function create_or_update_moloni_product($moloni_data, $mapping = null) { if ($mapping && $mapping->moloni_id) { // Update existing product $response = $this->api_client->update_product($mapping->moloni_id, $moloni_data); if ($response['success']) { return [ 'success' => true, 'product_id' => $mapping->moloni_id, 'action' => 'update', 'data' => $response['data'] ]; } } // Create new product or fallback to create if update failed $response = $this->api_client->create_product($moloni_data); if ($response['success']) { return [ 'success' => true, 'product_id' => $response['data']['product_id'], 'action' => 'create', 'data' => $response['data'] ]; } return [ 'success' => false, 'message' => $response['message'] ?? 'Unknown error creating/updating product in Moloni' ]; } /** * Create or update product in Perfex * * @param array $perfex_data * @param object $mapping * @return array */ protected function create_or_update_perfex_product($perfex_data, $mapping = null) { if ($mapping && $mapping->perfex_id) { // Update existing item $result = $this->CI->items_model->update($perfex_data, $mapping->perfex_id); if ($result) { return [ 'success' => true, 'item_id' => $mapping->perfex_id, 'action' => 'update', 'data' => $perfex_data ]; } } // Create new item or fallback to create if update failed $item_id = $this->CI->items_model->add($perfex_data); if ($item_id) { return [ 'success' => true, 'item_id' => $item_id, 'action' => 'create', 'data' => $perfex_data ]; } return [ 'success' => false, 'message' => 'Failed to create/update item in Perfex CRM' ]; } // Additional helper methods... /** * Get Perfex item data * * @param int $item_id * @return array|null */ protected function get_perfex_item($item_id) { $item = $this->CI->items_model->get($item_id); return $item ? (array)$item : null; } /** * Convert product type between systems * * @param string $perfex_type * @return string */ protected function convert_product_type($perfex_type) { $type_mappings = [ 'product' => self::TYPE_PRODUCT, 'service' => self::TYPE_SERVICE, 'subscription' => self::TYPE_SUBSCRIPTION ]; return $type_mappings[$perfex_type] ?? self::TYPE_PRODUCT; } /** * Get Perfex stock level * * @param array $perfex_item * @return float */ protected function get_perfex_stock_level($perfex_item) { // Perfex CRM doesn't have built-in stock management // This could be from custom fields or a stock management addon return (float)($perfex_item['stock_quantity'] ?? 0); } /** * Clean and validate Moloni product data * * @param array $data * @return array */ protected function clean_moloni_product_data($data) { // Ensure required fields if (empty($data['name'])) { $data['name'] = 'Imported Product'; } // Validate numeric fields $data['price'] = max(0, (float)$data['price']); $data['price_with_taxes'] = max(0, (float)$data['price_with_taxes']); $data['stock'] = max(0, (float)$data['stock']); // Sanitize strings $data['name'] = trim(substr($data['name'], 0, 255)); $data['reference'] = trim(substr($data['reference'], 0, 100)); return $data; } /** * Clean and validate Perfex product data * * @param array $data * @return array */ protected function clean_perfex_product_data($data) { // Ensure required fields if (empty($data['description'])) { $data['description'] = 'Imported Product'; } // Validate numeric fields $data['rate'] = max(0, (float)$data['rate']); // Sanitize strings $data['description'] = trim(substr($data['description'], 0, 255)); $data['long_description'] = trim(substr($data['long_description'], 0, 500)); return $data; } /** * 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', 'PRODUCT_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); } /** * Check if should sync to Moloni * * @param object $mapping * @param bool $force_update * @return bool */ protected function should_sync_to_moloni($mapping, $force_update) { if ($force_update) { return true; } if (!$mapping) { return true; // No mapping exists, should sync } if ($mapping->sync_status === EntityMappingService::STATUS_ERROR) { return true; // Retry failed syncs } // Check if Perfex data changed since last sync $perfex_modified = $this->get_perfex_modification_time($mapping->perfex_id); $last_sync = strtotime($mapping->last_sync_perfex ?: '1970-01-01'); return $perfex_modified > $last_sync; } /** * Check if should sync to Perfex * * @param object $mapping * @param bool $force_update * @return bool */ protected function should_sync_to_perfex($mapping, $force_update) { if ($force_update) { return true; } if (!$mapping) { return true; // No mapping exists, should sync } if ($mapping->sync_status === EntityMappingService::STATUS_ERROR) { return true; // Retry failed syncs } // Check if Moloni data changed since last sync $moloni_modified = $this->get_moloni_modification_time($mapping->moloni_id); $last_sync = strtotime($mapping->last_sync_moloni ?: '1970-01-01'); return $moloni_modified > $last_sync; } /** * Update or create entity mapping * * @param string $entity_type * @param int $perfex_id * @param int $moloni_id * @param string $direction * @param object $mapping * @return int */ protected function update_or_create_mapping($entity_type, $perfex_id, $moloni_id, $direction, $mapping = null) { $mapping_data = [ 'sync_status' => EntityMappingService::STATUS_SYNCED, 'sync_direction' => $direction, 'updated_at' => date('Y-m-d H:i:s') ]; // Update sync timestamps based on direction if ($direction === EntityMappingService::DIRECTION_PERFEX_TO_MOLONI) { $mapping_data['last_sync_perfex'] = date('Y-m-d H:i:s'); } elseif ($direction === EntityMappingService::DIRECTION_MOLONI_TO_PERFEX) { $mapping_data['last_sync_moloni'] = date('Y-m-d H:i:s'); } else { $mapping_data['last_sync_perfex'] = date('Y-m-d H:i:s'); $mapping_data['last_sync_moloni'] = date('Y-m-d H:i:s'); } if ($mapping) { // Update existing mapping $this->entity_mapping->update_mapping($mapping->id, $mapping_data); return $mapping->id; } else { // Create new mapping return $this->entity_mapping->create_mapping( $entity_type, $perfex_id, $moloni_id, $direction ); } } /** * Get Perfex product modification time * * @param int $item_id * @return int */ protected function get_perfex_modification_time($item_id) { $item = $this->CI->db->select('date_created') ->where('id', $item_id) ->get(db_prefix() . 'items') ->row(); if (!$item) { return 0; } return strtotime($item->date_created); } /** * Get Moloni product modification time * * @param int $moloni_id * @return int */ protected function get_moloni_modification_time($moloni_id) { $response = $this->api_client->get_product($moloni_id); if (!$response['success']) { return 0; } $product = $response['data']; return isset($product['last_modified']) ? strtotime($product['last_modified']) : time(); } /** * Handle sync conflict * * @param object $mapping * @param array $conflict_check * @return array */ protected function handle_sync_conflict($mapping, $conflict_check) { // Update mapping status to conflict $this->entity_mapping->update_mapping_status( $mapping->id, EntityMappingService::STATUS_CONFLICT, json_encode($conflict_check['conflict_details']) ); // Get conflict resolution strategy $strategy = get_option('desk_moloni_conflict_strategy', 'manual'); if ($strategy === 'manual') { return [ 'success' => false, 'message' => 'Sync conflict detected - manual resolution required', 'conflict_details' => $conflict_check['conflict_details'], 'mapping_id' => $mapping->id, 'requires_manual_resolution' => true ]; } // Auto-resolve based on strategy return $this->auto_resolve_conflict($mapping, $conflict_check, $strategy); } /** * Auto-resolve conflict based on strategy * * @param object $mapping * @param array $conflict_check * @param string $strategy * @return array */ protected function auto_resolve_conflict($mapping, $conflict_check, $strategy) { switch ($strategy) { case 'newest': $perfex_modified = strtotime($conflict_check['conflict_details']['perfex_modified']); $moloni_modified = strtotime($conflict_check['conflict_details']['moloni_modified']); if ($perfex_modified > $moloni_modified) { return $this->sync_perfex_to_moloni($mapping->perfex_id, true); } else { return $this->sync_moloni_to_perfex($mapping->moloni_id, true); } case 'perfex_wins': return $this->sync_perfex_to_moloni($mapping->perfex_id, true); case 'moloni_wins': return $this->sync_moloni_to_perfex($mapping->moloni_id, true); default: return [ 'success' => false, 'message' => 'Unknown conflict resolution strategy', 'conflict_details' => $conflict_check['conflict_details'] ]; } } /** * Detect data changes between versions * * @param array $old_data * @param array $new_data * @return array */ protected function detect_data_changes($old_data, $new_data) { $changes = []; foreach ($new_data as $key => $new_value) { $old_value = $old_data[$key] ?? null; if ($old_value !== $new_value) { $changes[$key] = [ 'old' => $old_value, 'new' => $new_value ]; } } return $changes; } }