Files
desk-moloni/modules/desk_moloni/libraries/ProductSyncService.php
Emanuel Almeida c19f6fd9ee fix(perfexcrm module): align version to 3.0.1, unify entrypoint, and harden routes/views
- Bump DESK_MOLONI version to 3.0.1 across module
- Normalize hooks to after_client_* and instantiate PerfexHooks safely
- Fix OAuthController view path and API client class name
- Add missing admin views for webhook config/logs; adjust view loading
- Harden client portal routes and admin routes mapping
- Make Dashboard/Logs/Queue tolerant to optional model methods
- Align log details query with existing schema; avoid broken joins

This makes the module operational in Perfex (admin + client), reduces 404s,
and avoids fatal errors due to inconsistent tables/methods.
2025-09-11 17:38:45 +01:00

1091 lines
38 KiB
PHP

<?php
defined('BASEPATH') or exit('No direct script access allowed');
/**
* Product Synchronization Service
* Enhanced bidirectional sync service for products between Perfex CRM and Moloni ERP
*
* @package DeskMoloni
* @subpackage Libraries
* @category ProductSync
* @author Descomplicar® - PHP Fullstack Engineer
* @version 1.0.0
*/
namespace DeskMoloni\Libraries;
use DeskMoloni\Libraries\EntityMappingService;
use DeskMoloni\Libraries\ErrorHandler;
use DeskMoloni\Libraries\MoloniApiClient;
class ProductSyncService
{
protected $CI;
protected $api_client;
protected $entity_mapping;
protected $error_handler;
protected $model;
// Product matching configuration
const MATCH_SCORE_EXACT = 100;
const MATCH_SCORE_HIGH = 85;
const MATCH_SCORE_MEDIUM = 65;
const MATCH_SCORE_LOW = 45;
// Product types mapping
const TYPE_PRODUCT = 'P';
const TYPE_SERVICE = 'S';
const TYPE_SUBSCRIPTION = 'U';
public function __construct()
{
$this->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;
}
}