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.
This commit is contained in:
657
modules/desk_moloni/models/Config_model.php
Normal file
657
modules/desk_moloni/models/Config_model.php
Normal file
@@ -0,0 +1,657 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Config_model.php
|
||||
*
|
||||
* Configuration management model for Desk-Moloni v3.0
|
||||
* Handles secure storage of API credentials, OAuth tokens, and module configuration
|
||||
* Supports encryption for sensitive data and OAuth token management with expiration
|
||||
*
|
||||
* @package DeskMoloni\Models
|
||||
* @author PHP Fullstack Engineer
|
||||
* @version 3.0
|
||||
*/
|
||||
|
||||
defined('BASEPATH') or exit('No direct script access allowed');
|
||||
|
||||
require_once(dirname(__FILE__) . '/Desk_moloni_model.php');
|
||||
|
||||
class Config_model extends Desk_moloni_model
|
||||
{
|
||||
/**
|
||||
* Table name
|
||||
*/
|
||||
private $table = 'desk_moloni_config';
|
||||
|
||||
/**
|
||||
* Configuration cache
|
||||
*/
|
||||
private static $config_cache = [];
|
||||
|
||||
/**
|
||||
* Cache TTL in seconds (5 minutes)
|
||||
*/
|
||||
private $cache_ttl = 300;
|
||||
|
||||
/**
|
||||
* Sensitive configuration keys that should be encrypted
|
||||
*/
|
||||
private $sensitiveKeys = [
|
||||
'oauth_client_secret',
|
||||
'oauth_access_token',
|
||||
'oauth_refresh_token',
|
||||
'api_key',
|
||||
'webhook_secret'
|
||||
];
|
||||
|
||||
/**
|
||||
* Default configuration values
|
||||
*/
|
||||
private $defaultConfig = [
|
||||
'module_version' => '3.0.0',
|
||||
'api_base_url' => 'https://api.moloni.pt/v1/',
|
||||
'api_timeout' => '30',
|
||||
'sync_enabled' => '1',
|
||||
'sync_interval_minutes' => '15',
|
||||
'max_retry_attempts' => '3',
|
||||
'log_retention_days' => '365',
|
||||
'queue_batch_size' => '50',
|
||||
'encryption_algorithm' => 'AES-256-GCM'
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->table = $this->getTableName('config');
|
||||
$this->initializeDefaults();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration value by key
|
||||
*
|
||||
* @param string $key Configuration key
|
||||
* @param mixed $default Default value if key not found
|
||||
* @return mixed Configuration value
|
||||
*/
|
||||
public function get($key, $default = null)
|
||||
{
|
||||
try {
|
||||
// Validate key
|
||||
if (empty($key)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$query = $this->db->where('setting_key', $key)->get($this->table);
|
||||
|
||||
if ($query->num_rows() === 0) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$row = $query->row();
|
||||
|
||||
// Decrypt if encrypted
|
||||
if ($row->encrypted == 1) {
|
||||
return $this->decryptData($row->setting_value);
|
||||
}
|
||||
|
||||
return $row->setting_value;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni config get error: ' . $e->getMessage());
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration value
|
||||
*
|
||||
* @param string $key Configuration key
|
||||
* @param mixed $value Configuration value
|
||||
* @param bool $forceEncryption Force encryption regardless of key type
|
||||
* @return bool Success status
|
||||
* @throws InvalidArgumentException If key is empty or invalid
|
||||
*/
|
||||
public function set($key, $value, $forceEncryption = false)
|
||||
{
|
||||
try {
|
||||
// Validate key
|
||||
if (empty($key)) {
|
||||
throw new InvalidArgumentException('Configuration key cannot be empty');
|
||||
}
|
||||
|
||||
// Validate input
|
||||
$validationErrors = $this->validateConfigData(['setting_key' => $key, 'setting_value' => $value]);
|
||||
if (!empty($validationErrors)) {
|
||||
throw new Exception('Validation failed: ' . implode(', ', $validationErrors));
|
||||
}
|
||||
|
||||
// Determine if value should be encrypted
|
||||
$shouldEncrypt = $forceEncryption || $this->isSensitiveKey($key);
|
||||
|
||||
// Prepare data
|
||||
$data = [
|
||||
'setting_key' => $key,
|
||||
'setting_value' => $shouldEncrypt ? $this->encryptData($value) : $value,
|
||||
'encrypted' => $shouldEncrypt ? 1 : 0,
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
// Check if key exists
|
||||
$existing = $this->db->where('setting_key', $key)->get($this->table);
|
||||
|
||||
if ($existing->num_rows() > 0) {
|
||||
// Update existing
|
||||
$result = $this->db->where('setting_key', $key)->update($this->table, $data);
|
||||
$this->logDatabaseOperation('update', $this->table, $data, $existing->row()->id);
|
||||
} else {
|
||||
// Insert new
|
||||
$data['created_at'] = date('Y-m-d H:i:s');
|
||||
$result = $this->db->insert($this->table, $data);
|
||||
$this->logDatabaseOperation('create', $this->table, $data, $this->db->insert_id());
|
||||
}
|
||||
|
||||
return $result;
|
||||
|
||||
} catch (InvalidArgumentException $e) {
|
||||
throw $e; // Re-throw validation exceptions
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni config set error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set encrypted configuration value
|
||||
*
|
||||
* @param string $key Configuration key
|
||||
* @param mixed $value Configuration value
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function set_encrypted($key, $value)
|
||||
{
|
||||
return $this->set($key, $value, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get encrypted configuration value (decrypted)
|
||||
*
|
||||
* @param string $key Configuration key
|
||||
* @param mixed $default Default value if key not found
|
||||
* @return mixed Decrypted configuration value
|
||||
*/
|
||||
public function get_encrypted($key, $default = null)
|
||||
{
|
||||
return $this->get($key, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set OAuth token with expiration
|
||||
*
|
||||
* @param string $token OAuth token
|
||||
* @param int $expires_at Unix timestamp when token expires
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function set_oauth_token($token, $expires_at)
|
||||
{
|
||||
try {
|
||||
$success = true;
|
||||
$success &= $this->set('oauth_access_token', $token, true);
|
||||
$success &= $this->set('oauth_token_expires_at', date('Y-m-d H:i:s', $expires_at));
|
||||
|
||||
return $success;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni OAuth token set error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OAuth token with metadata
|
||||
*
|
||||
* @return array Token data array
|
||||
*/
|
||||
public function get_oauth_token()
|
||||
{
|
||||
try {
|
||||
$token = $this->get('oauth_access_token');
|
||||
$expires_at = $this->get('oauth_token_expires_at');
|
||||
|
||||
if (empty($token)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'token' => $token,
|
||||
'expires_at' => $expires_at ? strtotime($expires_at) : null,
|
||||
'created_at' => time(),
|
||||
'type' => 'oauth_token'
|
||||
];
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni OAuth token get error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if OAuth token is valid and not expired
|
||||
*
|
||||
* @return bool True if token is valid
|
||||
*/
|
||||
public function is_oauth_token_valid()
|
||||
{
|
||||
try {
|
||||
$accessToken = $this->get('oauth_access_token');
|
||||
$expiresAt = $this->get('oauth_token_expires_at');
|
||||
|
||||
if (empty($accessToken) || empty($expiresAt)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if token is expired (with 5-minute buffer)
|
||||
$expirationTime = strtotime($expiresAt) - 300; // 5 minutes buffer
|
||||
return time() < $expirationTime;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni OAuth validation error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete configuration key
|
||||
*
|
||||
* @param string $key Configuration key
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function delete($key)
|
||||
{
|
||||
try {
|
||||
if (empty($key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$existing = $this->db->where('setting_key', $key)->get($this->table);
|
||||
|
||||
if ($existing->num_rows() === 0) {
|
||||
return true; // Already doesn't exist
|
||||
}
|
||||
|
||||
$result = $this->db->where('setting_key', $key)->delete($this->table);
|
||||
$this->logDatabaseOperation('delete', $this->table, ['setting_key' => $key], $existing->row()->id);
|
||||
|
||||
return $result;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni config delete error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all configuration values
|
||||
*
|
||||
* @param bool $includeEncrypted Whether to decrypt encrypted values
|
||||
* @return array Configuration array
|
||||
*/
|
||||
public function get_all($includeEncrypted = true)
|
||||
{
|
||||
try {
|
||||
$query = $this->db->get($this->table);
|
||||
$config = [];
|
||||
|
||||
foreach ($query->result() as $row) {
|
||||
if ($row->encrypted == 1 && $includeEncrypted) {
|
||||
$config[$row->setting_key] = $this->decryptData($row->setting_value);
|
||||
} elseif ($row->encrypted == 0) {
|
||||
$config[$row->setting_key] = $row->setting_value;
|
||||
}
|
||||
// Skip encrypted values if includeEncrypted is false
|
||||
}
|
||||
|
||||
// Add default values for missing keys
|
||||
foreach ($this->defaultConfig as $key => $defaultValue) {
|
||||
if (!isset($config[$key])) {
|
||||
$config[$key] = $defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
return $config;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni config get_all error: ' . $e->getMessage());
|
||||
return $this->defaultConfig;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set multiple configuration values in batch
|
||||
*
|
||||
* @param array $config_batch Array of key => value pairs
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function set_batch($config_batch)
|
||||
{
|
||||
try {
|
||||
if (!is_array($config_batch) || empty($config_batch)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$success = true;
|
||||
|
||||
// Use transaction for batch operations
|
||||
return $this->executeTransaction(function() use ($config_batch, &$success) {
|
||||
foreach ($config_batch as $key => $value) {
|
||||
if (!$this->set($key, $value)) {
|
||||
$success = false;
|
||||
throw new Exception("Failed to set config key: {$key}");
|
||||
}
|
||||
}
|
||||
return $success;
|
||||
});
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni config set_batch error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration keys by pattern
|
||||
*
|
||||
* @param string $pattern LIKE pattern for key matching
|
||||
* @param bool $includeEncrypted Whether to decrypt encrypted values
|
||||
* @return array Matching configuration
|
||||
*/
|
||||
public function getByPattern($pattern, $includeEncrypted = true)
|
||||
{
|
||||
try {
|
||||
$query = $this->db->like('setting_key', $pattern)->get($this->table);
|
||||
$config = [];
|
||||
|
||||
foreach ($query->result() as $row) {
|
||||
if ($row->encrypted == 1 && $includeEncrypted) {
|
||||
$config[$row->setting_key] = $this->decryptData($row->setting_value);
|
||||
} elseif ($row->encrypted == 0) {
|
||||
$config[$row->setting_key] = $row->setting_value;
|
||||
}
|
||||
}
|
||||
|
||||
return $config;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni config getByPattern error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OAuth configuration
|
||||
*
|
||||
* @return array OAuth configuration
|
||||
*/
|
||||
public function getOAuthConfig()
|
||||
{
|
||||
$oauthKeys = [
|
||||
'oauth_client_id',
|
||||
'oauth_client_secret',
|
||||
'oauth_access_token',
|
||||
'oauth_refresh_token',
|
||||
'oauth_token_expires_at'
|
||||
];
|
||||
|
||||
$config = [];
|
||||
foreach ($oauthKeys as $key) {
|
||||
$config[$key] = $this->get($key);
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set OAuth tokens
|
||||
*
|
||||
* @param array $tokens OAuth token data
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function setOAuthTokens($tokens)
|
||||
{
|
||||
try {
|
||||
$requiredTokens = ['access_token', 'refresh_token', 'expires_in'];
|
||||
|
||||
foreach ($requiredTokens as $required) {
|
||||
if (!isset($tokens[$required])) {
|
||||
throw new Exception("Missing required OAuth token: {$required}");
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate expiration timestamp
|
||||
$expiresAt = date('Y-m-d H:i:s', time() + (int)$tokens['expires_in']);
|
||||
|
||||
$success = true;
|
||||
$success &= $this->set('oauth_access_token', $tokens['access_token'], true);
|
||||
$success &= $this->set('oauth_refresh_token', $tokens['refresh_token'], true);
|
||||
$success &= $this->set('oauth_token_expires_at', $expiresAt);
|
||||
|
||||
return $success;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni OAuth tokens error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all OAuth tokens (for logout/revoke)
|
||||
*
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function clearOAuthTokens()
|
||||
{
|
||||
$success = true;
|
||||
$oauthKeys = ['oauth_access_token', 'oauth_refresh_token', 'oauth_token_expires_at'];
|
||||
|
||||
foreach ($oauthKeys as $key) {
|
||||
$success &= $this->delete($key);
|
||||
}
|
||||
|
||||
return $success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API configuration
|
||||
*
|
||||
* @return array API configuration
|
||||
*/
|
||||
public function getAPIConfig()
|
||||
{
|
||||
return [
|
||||
'base_url' => $this->get('api_base_url', 'https://api.moloni.pt/v1/'),
|
||||
'timeout' => (int)$this->get('api_timeout', 30),
|
||||
'company_id' => $this->get('moloni_company_id'),
|
||||
'access_token' => $this->get('oauth_access_token')
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if configuration key is sensitive and should be encrypted
|
||||
*
|
||||
* @param string $key Configuration key
|
||||
* @return bool True if key is sensitive
|
||||
*/
|
||||
private function isSensitiveKey($key)
|
||||
{
|
||||
return in_array($key, $this->sensitiveKeys) ||
|
||||
strpos($key, 'password') !== false ||
|
||||
strpos($key, 'secret') !== false ||
|
||||
strpos($key, 'token') !== false ||
|
||||
strpos($key, 'key') !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate configuration data
|
||||
*
|
||||
* @param array $data Configuration data to validate
|
||||
* @return array Validation errors
|
||||
*/
|
||||
private function validateConfigData($data)
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
// Required fields
|
||||
$requiredFields = ['setting_key'];
|
||||
$errors = array_merge($errors, $this->validateRequiredFields($data, $requiredFields));
|
||||
|
||||
// Field length limits
|
||||
$fieldLimits = [
|
||||
'setting_key' => 255
|
||||
];
|
||||
$errors = array_merge($errors, $this->validateFieldLengths($data, $fieldLimits));
|
||||
|
||||
// Key format validation
|
||||
if (isset($data['setting_key'])) {
|
||||
if (!preg_match('/^[a-z0-9_]+$/', $data['setting_key'])) {
|
||||
$errors[] = 'Setting key must contain only lowercase letters, numbers, and underscores';
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize default configuration values
|
||||
*
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function initializeDefaults()
|
||||
{
|
||||
$success = true;
|
||||
|
||||
foreach ($this->defaultConfig as $key => $value) {
|
||||
// Only set if not already exists
|
||||
if ($this->get($key) === null) {
|
||||
$success &= $this->set($key, $value);
|
||||
}
|
||||
}
|
||||
|
||||
return $success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export configuration for backup (excluding sensitive data)
|
||||
*
|
||||
* @return array Non-sensitive configuration data
|
||||
*/
|
||||
public function exportConfig()
|
||||
{
|
||||
try {
|
||||
$allConfig = $this->get_all(false); // Don't include encrypted values
|
||||
|
||||
// Remove sensitive keys entirely from export
|
||||
foreach ($this->sensitiveKeys as $sensitiveKey) {
|
||||
unset($allConfig[$sensitiveKey]);
|
||||
}
|
||||
|
||||
return $allConfig;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni config export error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration value with caching
|
||||
*
|
||||
* @param string $key Configuration key
|
||||
* @param mixed $default Default value if key not found
|
||||
* @return mixed Configuration value
|
||||
*/
|
||||
public function get_cached($key, $default = null)
|
||||
{
|
||||
$cache_key = 'config_' . $key;
|
||||
|
||||
// Check if cached and not expired
|
||||
if (isset(self::$config_cache[$cache_key])) {
|
||||
$cached = self::$config_cache[$cache_key];
|
||||
if ((time() - $cached['timestamp']) < $this->cache_ttl) {
|
||||
desk_moloni_log('debug', "Config cache hit for key: $key", [], 'cache');
|
||||
return $cached['value'];
|
||||
} else {
|
||||
// Cache expired, remove it
|
||||
unset(self::$config_cache[$cache_key]);
|
||||
desk_moloni_log('debug', "Config cache expired for key: $key", [], 'cache');
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss, get from database
|
||||
$value = $this->get($key, $default);
|
||||
|
||||
// Cache the result
|
||||
self::$config_cache[$cache_key] = [
|
||||
'value' => $value,
|
||||
'timestamp' => time()
|
||||
];
|
||||
|
||||
desk_moloni_log('debug', "Config cached for key: $key", ['ttl' => $this->cache_ttl], 'cache');
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration value and invalidate cache
|
||||
*
|
||||
* @param string $key Configuration key
|
||||
* @param mixed $value Configuration value
|
||||
* @param bool $forceEncryption Force encryption even for non-sensitive keys
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function set_cached($key, $value, $forceEncryption = false)
|
||||
{
|
||||
$result = $this->set($key, $value, $forceEncryption);
|
||||
|
||||
if ($result) {
|
||||
// Invalidate cache for this key
|
||||
$cache_key = 'config_' . $key;
|
||||
unset(self::$config_cache[$cache_key]);
|
||||
desk_moloni_log('debug', "Config cache invalidated for key: $key", [], 'cache');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all configuration cache
|
||||
*/
|
||||
public function clear_cache()
|
||||
{
|
||||
$count = count(self::$config_cache);
|
||||
self::$config_cache = [];
|
||||
desk_moloni_log('info', "Configuration cache cleared", ['cached_items' => $count], 'cache');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
*
|
||||
* @return array Cache statistics
|
||||
*/
|
||||
public function get_cache_stats()
|
||||
{
|
||||
$stats = [
|
||||
'cached_items' => count(self::$config_cache),
|
||||
'cache_ttl' => $this->cache_ttl,
|
||||
'items' => []
|
||||
];
|
||||
|
||||
foreach (self::$config_cache as $key => $data) {
|
||||
$age = time() - $data['timestamp'];
|
||||
$stats['items'][] = [
|
||||
'key' => $key,
|
||||
'age_seconds' => $age,
|
||||
'expires_in' => $this->cache_ttl - $age
|
||||
];
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
}
|
||||
418
modules/desk_moloni/models/Desk_moloni_config_model.php
Normal file
418
modules/desk_moloni/models/Desk_moloni_config_model.php
Normal file
@@ -0,0 +1,418 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Desk_moloni_config_model.php
|
||||
*
|
||||
* Model for desk_moloni_config table
|
||||
* Handles secure storage of API credentials and module configuration
|
||||
*
|
||||
* @package DeskMoloni\Models
|
||||
* @author Database Design Specialist
|
||||
* @version 3.0
|
||||
*/
|
||||
|
||||
defined('BASEPATH') or exit('No direct script access allowed');
|
||||
|
||||
require_once(dirname(__FILE__) . '/Desk_moloni_model.php');
|
||||
|
||||
class Desk_moloni_config_model extends Desk_moloni_model
|
||||
{
|
||||
/**
|
||||
* Table name - must match Perfex CRM naming convention
|
||||
*/
|
||||
private $table = 'tbldeskmoloni_config';
|
||||
|
||||
/**
|
||||
* Sensitive configuration keys that should be encrypted
|
||||
*/
|
||||
private $sensitiveKeys = [
|
||||
'oauth_client_secret',
|
||||
'oauth_access_token',
|
||||
'oauth_refresh_token',
|
||||
'api_key',
|
||||
'webhook_secret'
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
// Use Perfex CRM table naming convention: tbl + module_prefix + table_name
|
||||
$this->table = 'tbldeskmoloni_config';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration value by key
|
||||
*
|
||||
* @param string $key Configuration key
|
||||
* @param mixed $default Default value if key not found
|
||||
* @return mixed Configuration value
|
||||
*/
|
||||
public function get($key, $default = null)
|
||||
{
|
||||
try {
|
||||
$query = $this->db->where('setting_key', $key)->get($this->table);
|
||||
|
||||
if ($query->num_rows() === 0) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$row = $query->row();
|
||||
|
||||
// Decrypt if encrypted
|
||||
if ($row->encrypted == 1) {
|
||||
return $this->decryptData($row->setting_value);
|
||||
}
|
||||
|
||||
return $row->setting_value;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni config get error: ' . $e->getMessage());
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration value
|
||||
*
|
||||
* @param string $key Configuration key
|
||||
* @param mixed $value Configuration value
|
||||
* @param bool $forceEncryption Force encryption regardless of key type
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function set($key, $value, $forceEncryption = false)
|
||||
{
|
||||
try {
|
||||
// Validate input
|
||||
$validationErrors = $this->validateConfigData(['setting_key' => $key, 'setting_value' => $value]);
|
||||
if (!empty($validationErrors)) {
|
||||
throw new Exception('Validation failed: ' . implode(', ', $validationErrors));
|
||||
}
|
||||
|
||||
// Determine if value should be encrypted
|
||||
$shouldEncrypt = $forceEncryption || $this->isSensitiveKey($key);
|
||||
|
||||
// Prepare data
|
||||
$data = [
|
||||
'setting_key' => $key,
|
||||
'setting_value' => $shouldEncrypt ? $this->encryptData($value) : $value,
|
||||
'encrypted' => $shouldEncrypt ? 1 : 0,
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
// Check if key exists
|
||||
$existing = $this->db->where('setting_key', $key)->get($this->table);
|
||||
|
||||
if ($existing->num_rows() > 0) {
|
||||
// Update existing
|
||||
$result = $this->db->where('setting_key', $key)->update($this->table, $data);
|
||||
$this->logDatabaseOperation('update', $this->table, $data, $existing->row()->id);
|
||||
} else {
|
||||
// Insert new
|
||||
$data['created_at'] = date('Y-m-d H:i:s');
|
||||
$result = $this->db->insert($this->table, $data);
|
||||
$this->logDatabaseOperation('create', $this->table, $data, $this->db->insert_id());
|
||||
}
|
||||
|
||||
return $result;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni config set error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete configuration key
|
||||
*
|
||||
* @param string $key Configuration key
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function delete($key)
|
||||
{
|
||||
try {
|
||||
$existing = $this->db->where('setting_key', $key)->get($this->table);
|
||||
|
||||
if ($existing->num_rows() === 0) {
|
||||
return true; // Already doesn't exist
|
||||
}
|
||||
|
||||
$result = $this->db->where('setting_key', $key)->delete($this->table);
|
||||
$this->logDatabaseOperation('delete', $this->table, ['setting_key' => $key], $existing->row()->id);
|
||||
|
||||
return $result;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni config delete error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all configuration values
|
||||
*
|
||||
* @param bool $includeEncrypted Whether to decrypt encrypted values
|
||||
* @return array Configuration array
|
||||
*/
|
||||
public function getAll($includeEncrypted = true)
|
||||
{
|
||||
try {
|
||||
$query = $this->db->get($this->table);
|
||||
$config = [];
|
||||
|
||||
foreach ($query->result() as $row) {
|
||||
if ($row->encrypted == 1 && $includeEncrypted) {
|
||||
$config[$row->setting_key] = $this->decryptData($row->setting_value);
|
||||
} elseif ($row->encrypted == 0) {
|
||||
$config[$row->setting_key] = $row->setting_value;
|
||||
}
|
||||
// Skip encrypted values if includeEncrypted is false
|
||||
}
|
||||
|
||||
return $config;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni config getAll error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration keys by pattern
|
||||
*
|
||||
* @param string $pattern LIKE pattern for key matching
|
||||
* @param bool $includeEncrypted Whether to decrypt encrypted values
|
||||
* @return array Matching configuration
|
||||
*/
|
||||
public function getByPattern($pattern, $includeEncrypted = true)
|
||||
{
|
||||
try {
|
||||
$query = $this->db->like('setting_key', $pattern)->get($this->table);
|
||||
$config = [];
|
||||
|
||||
foreach ($query->result() as $row) {
|
||||
if ($row->encrypted == 1 && $includeEncrypted) {
|
||||
$config[$row->setting_key] = $this->decryptData($row->setting_value);
|
||||
} elseif ($row->encrypted == 0) {
|
||||
$config[$row->setting_key] = $row->setting_value;
|
||||
}
|
||||
}
|
||||
|
||||
return $config;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni config getByPattern error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OAuth configuration
|
||||
*
|
||||
* @return array OAuth configuration
|
||||
*/
|
||||
public function getOAuthConfig()
|
||||
{
|
||||
$oauthKeys = [
|
||||
'oauth_client_id',
|
||||
'oauth_client_secret',
|
||||
'oauth_access_token',
|
||||
'oauth_refresh_token',
|
||||
'oauth_token_expires_at'
|
||||
];
|
||||
|
||||
$config = [];
|
||||
foreach ($oauthKeys as $key) {
|
||||
$config[$key] = $this->get($key);
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set OAuth tokens
|
||||
*
|
||||
* @param array $tokens OAuth token data
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function setOAuthTokens($tokens)
|
||||
{
|
||||
try {
|
||||
$requiredTokens = ['access_token', 'refresh_token', 'expires_in'];
|
||||
|
||||
foreach ($requiredTokens as $required) {
|
||||
if (!isset($tokens[$required])) {
|
||||
throw new Exception("Missing required OAuth token: {$required}");
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate expiration timestamp
|
||||
$expiresAt = date('Y-m-d H:i:s', time() + (int)$tokens['expires_in']);
|
||||
|
||||
$success = true;
|
||||
$success &= $this->set('oauth_access_token', $tokens['access_token']);
|
||||
$success &= $this->set('oauth_refresh_token', $tokens['refresh_token']);
|
||||
$success &= $this->set('oauth_token_expires_at', $expiresAt);
|
||||
|
||||
return $success;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni OAuth tokens error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if OAuth tokens are valid and not expired
|
||||
*
|
||||
* @return bool True if tokens are valid
|
||||
*/
|
||||
public function isOAuthValid()
|
||||
{
|
||||
try {
|
||||
$accessToken = $this->get('oauth_access_token');
|
||||
$expiresAt = $this->get('oauth_token_expires_at');
|
||||
|
||||
if (empty($accessToken) || empty($expiresAt)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if token is expired (with 5-minute buffer)
|
||||
$expirationTime = strtotime($expiresAt) - 300; // 5 minutes buffer
|
||||
return time() < $expirationTime;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni OAuth validation error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API configuration
|
||||
*
|
||||
* @return array API configuration
|
||||
*/
|
||||
public function getAPIConfig()
|
||||
{
|
||||
return [
|
||||
'base_url' => $this->get('api_base_url', 'https://api.moloni.pt/v1/'),
|
||||
'timeout' => (int)$this->get('api_timeout', 30),
|
||||
'company_id' => $this->get('moloni_company_id'),
|
||||
'access_token' => $this->get('oauth_access_token')
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if configuration key is sensitive and should be encrypted
|
||||
*
|
||||
* @param string $key Configuration key
|
||||
* @return bool True if key is sensitive
|
||||
*/
|
||||
private function isSensitiveKey($key)
|
||||
{
|
||||
return in_array($key, $this->sensitiveKeys) ||
|
||||
strpos($key, 'password') !== false ||
|
||||
strpos($key, 'secret') !== false ||
|
||||
strpos($key, 'token') !== false ||
|
||||
strpos($key, 'key') !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate configuration data
|
||||
*
|
||||
* @param array $data Configuration data to validate
|
||||
* @return array Validation errors
|
||||
*/
|
||||
private function validateConfigData($data)
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
// Required fields
|
||||
$requiredFields = ['setting_key'];
|
||||
$errors = array_merge($errors, $this->validateRequiredFields($data, $requiredFields));
|
||||
|
||||
// Field length limits
|
||||
$fieldLimits = [
|
||||
'setting_key' => 255
|
||||
];
|
||||
$errors = array_merge($errors, $this->validateFieldLengths($data, $fieldLimits));
|
||||
|
||||
// Key format validation
|
||||
if (isset($data['setting_key'])) {
|
||||
if (!preg_match('/^[a-z0-9_]+$/', $data['setting_key'])) {
|
||||
$errors[] = 'Setting key must contain only lowercase letters, numbers, and underscores';
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize default configuration values
|
||||
*
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function initializeDefaults()
|
||||
{
|
||||
$defaults = [
|
||||
'api_base_url' => 'https://api.moloni.pt/v1/',
|
||||
'api_timeout' => '30',
|
||||
'sync_enabled' => '1',
|
||||
'sync_interval_minutes' => '15',
|
||||
'max_retry_attempts' => '3',
|
||||
'log_retention_days' => '365',
|
||||
'queue_batch_size' => '50',
|
||||
'encryption_algorithm' => 'AES-256-GCM'
|
||||
];
|
||||
|
||||
$success = true;
|
||||
|
||||
foreach ($defaults as $key => $value) {
|
||||
// Only set if not already exists
|
||||
if ($this->get($key) === null) {
|
||||
$success &= $this->set($key, $value);
|
||||
}
|
||||
}
|
||||
|
||||
return $success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export configuration for backup (excluding sensitive data)
|
||||
*
|
||||
* @return array Non-sensitive configuration data
|
||||
*/
|
||||
public function exportConfig()
|
||||
{
|
||||
try {
|
||||
$allConfig = $this->getAll(false); // Don't include encrypted values
|
||||
|
||||
// Remove sensitive keys entirely from export
|
||||
foreach ($this->sensitiveKeys as $sensitiveKey) {
|
||||
unset($allConfig[$sensitiveKey]);
|
||||
}
|
||||
|
||||
return $allConfig;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni config export error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all OAuth tokens (for logout/revoke)
|
||||
*
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function clearOAuthTokens()
|
||||
{
|
||||
$success = true;
|
||||
$oauthKeys = ['oauth_access_token', 'oauth_refresh_token', 'oauth_token_expires_at'];
|
||||
|
||||
foreach ($oauthKeys as $key) {
|
||||
$success &= $this->delete($key);
|
||||
}
|
||||
|
||||
return $success;
|
||||
}
|
||||
}
|
||||
493
modules/desk_moloni/models/Desk_moloni_invoice_model.php
Normal file
493
modules/desk_moloni/models/Desk_moloni_invoice_model.php
Normal file
@@ -0,0 +1,493 @@
|
||||
<?php
|
||||
|
||||
defined('BASEPATH') or exit('No direct script access allowed');
|
||||
|
||||
/**
|
||||
* Desk-Moloni Invoice Model
|
||||
*
|
||||
* Manages invoice data, synchronization, and business logic
|
||||
* Handles invoice operations between Perfex CRM and Moloni
|
||||
*
|
||||
* @package DeskMoloni
|
||||
* @subpackage Models
|
||||
* @version 3.0.0
|
||||
* @author Descomplicar®
|
||||
*/
|
||||
class Desk_moloni_invoice_model extends CI_Model
|
||||
{
|
||||
private $table = 'tblinvoices';
|
||||
private $moloni_invoice_table = 'tbldeskmoloni_invoices';
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
// Create Moloni invoice mapping table if it doesn't exist
|
||||
$this->create_moloni_invoice_table();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Moloni invoice mapping table
|
||||
*/
|
||||
private function create_moloni_invoice_table()
|
||||
{
|
||||
if (!$this->db->table_exists($this->moloni_invoice_table)) {
|
||||
$this->db->query("
|
||||
CREATE TABLE IF NOT EXISTS `{$this->moloni_invoice_table}` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`perfex_invoice_id` int(11) NOT NULL,
|
||||
`moloni_invoice_id` int(11) DEFAULT NULL,
|
||||
`moloni_document_id` varchar(255) DEFAULT NULL,
|
||||
`moloni_document_number` varchar(100) DEFAULT NULL,
|
||||
`moloni_document_type` varchar(50) DEFAULT 'invoice',
|
||||
`sync_status` enum('pending','synced','failed','partial') DEFAULT 'pending',
|
||||
`last_sync_at` datetime DEFAULT NULL,
|
||||
`sync_error` text DEFAULT NULL,
|
||||
`moloni_data` longtext DEFAULT NULL,
|
||||
`pdf_url` varchar(500) DEFAULT NULL,
|
||||
`pdf_downloaded` tinyint(1) DEFAULT 0,
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_perfex_invoice` (`perfex_invoice_id`),
|
||||
KEY `idx_moloni_invoice` (`moloni_invoice_id`),
|
||||
KEY `idx_sync_status` (`sync_status`),
|
||||
KEY `idx_last_sync` (`last_sync_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get invoice with Moloni mapping data
|
||||
*
|
||||
* @param int $invoice_id Perfex invoice ID
|
||||
* @return array|null Invoice data with Moloni mapping
|
||||
*/
|
||||
public function get_invoice_with_moloni_data($invoice_id)
|
||||
{
|
||||
$this->db->select('i.*, mi.*');
|
||||
$this->db->from("{$this->table} i");
|
||||
$this->db->join("{$this->moloni_invoice_table} mi", 'mi.perfex_invoice_id = i.id', 'left');
|
||||
$this->db->where('i.id', $invoice_id);
|
||||
|
||||
$query = $this->db->get();
|
||||
return $query->row_array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get invoices for synchronization
|
||||
*
|
||||
* @param array $filters Filtering options
|
||||
* @return array Invoices needing sync
|
||||
*/
|
||||
public function get_invoices_for_sync($filters = [])
|
||||
{
|
||||
$this->db->select('i.*, mi.sync_status, mi.moloni_invoice_id, mi.last_sync_at');
|
||||
$this->db->from("{$this->table} i");
|
||||
$this->db->join("{$this->moloni_invoice_table} mi", 'mi.perfex_invoice_id = i.id', 'left');
|
||||
|
||||
// Default filters
|
||||
if (!isset($filters['include_synced'])) {
|
||||
$this->db->where("(mi.sync_status IS NULL OR mi.sync_status != 'synced')");
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if (isset($filters['status'])) {
|
||||
$this->db->where('i.status', $filters['status']);
|
||||
}
|
||||
|
||||
// Date range filter
|
||||
if (isset($filters['date_from'])) {
|
||||
$this->db->where('i.date >=', $filters['date_from']);
|
||||
}
|
||||
|
||||
if (isset($filters['date_to'])) {
|
||||
$this->db->where('i.date <=', $filters['date_to']);
|
||||
}
|
||||
|
||||
// Client filter
|
||||
if (isset($filters['clientid'])) {
|
||||
$this->db->where('i.clientid', $filters['clientid']);
|
||||
}
|
||||
|
||||
// Sync status filter
|
||||
if (isset($filters['sync_status'])) {
|
||||
$this->db->where('mi.sync_status', $filters['sync_status']);
|
||||
}
|
||||
|
||||
// Limit
|
||||
if (isset($filters['limit'])) {
|
||||
$this->db->limit($filters['limit']);
|
||||
}
|
||||
|
||||
$this->db->order_by('i.date', 'DESC');
|
||||
|
||||
$query = $this->db->get();
|
||||
return $query->result_array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update Moloni invoice mapping
|
||||
*
|
||||
* @param int $perfex_invoice_id Perfex invoice ID
|
||||
* @param array $moloni_data Moloni invoice data
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function save_moloni_mapping($perfex_invoice_id, $moloni_data)
|
||||
{
|
||||
$mapping_data = [
|
||||
'perfex_invoice_id' => $perfex_invoice_id,
|
||||
'moloni_invoice_id' => $moloni_data['document_id'] ?? null,
|
||||
'moloni_document_id' => $moloni_data['document_id'] ?? null,
|
||||
'moloni_document_number' => $moloni_data['number'] ?? null,
|
||||
'moloni_document_type' => $moloni_data['document_type'] ?? 'invoice',
|
||||
'sync_status' => $moloni_data['sync_status'] ?? 'synced',
|
||||
'last_sync_at' => date('Y-m-d H:i:s'),
|
||||
'moloni_data' => json_encode($moloni_data),
|
||||
'pdf_url' => $moloni_data['pdf_url'] ?? null
|
||||
];
|
||||
|
||||
// Check if mapping exists
|
||||
$existing = $this->db->get_where($this->moloni_invoice_table,
|
||||
['perfex_invoice_id' => $perfex_invoice_id])->row_array();
|
||||
|
||||
if ($existing) {
|
||||
$mapping_data['updated_at'] = date('Y-m-d H:i:s');
|
||||
$this->db->where('perfex_invoice_id', $perfex_invoice_id);
|
||||
return $this->db->update($this->moloni_invoice_table, $mapping_data);
|
||||
} else {
|
||||
$mapping_data['created_at'] = date('Y-m-d H:i:s');
|
||||
return $this->db->insert($this->moloni_invoice_table, $mapping_data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update sync status for invoice
|
||||
*
|
||||
* @param int $perfex_invoice_id Perfex invoice ID
|
||||
* @param string $status Sync status
|
||||
* @param string|null $error Error message if failed
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function update_sync_status($perfex_invoice_id, $status, $error = null)
|
||||
{
|
||||
$update_data = [
|
||||
'sync_status' => $status,
|
||||
'last_sync_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
if ($error) {
|
||||
$update_data['sync_error'] = $error;
|
||||
} else {
|
||||
$update_data['sync_error'] = null;
|
||||
}
|
||||
|
||||
// Check if mapping exists
|
||||
$existing = $this->db->get_where($this->moloni_invoice_table,
|
||||
['perfex_invoice_id' => $perfex_invoice_id])->row_array();
|
||||
|
||||
if ($existing) {
|
||||
$this->db->where('perfex_invoice_id', $perfex_invoice_id);
|
||||
return $this->db->update($this->moloni_invoice_table, $update_data);
|
||||
} else {
|
||||
$update_data['perfex_invoice_id'] = $perfex_invoice_id;
|
||||
$update_data['created_at'] = date('Y-m-d H:i:s');
|
||||
return $this->db->insert($this->moloni_invoice_table, $update_data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get invoice line items with product mapping
|
||||
*
|
||||
* @param int $invoice_id Invoice ID
|
||||
* @return array Line items with mapping data
|
||||
*/
|
||||
public function get_invoice_items_with_mapping($invoice_id)
|
||||
{
|
||||
$this->db->select('ii.*, pm.moloni_product_id, pm.mapping_data');
|
||||
$this->db->from('tblinvoiceitems ii');
|
||||
$this->db->join('tbldeskmoloni_mapping pm',
|
||||
"pm.perfex_id = ii.rel_id AND pm.entity_type = 'product'", 'left');
|
||||
$this->db->where('ii.rel_type', 'invoice');
|
||||
$this->db->where('ii.rel_id', $invoice_id);
|
||||
$this->db->order_by('ii.item_order', 'ASC');
|
||||
|
||||
$query = $this->db->get();
|
||||
return $query->result_array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate invoice totals for validation
|
||||
*
|
||||
* @param int $invoice_id Invoice ID
|
||||
* @return array Invoice totals
|
||||
*/
|
||||
public function calculate_invoice_totals($invoice_id)
|
||||
{
|
||||
$invoice = $this->get_invoice_with_moloni_data($invoice_id);
|
||||
$items = $this->get_invoice_items_with_mapping($invoice_id);
|
||||
|
||||
$subtotal = 0;
|
||||
$tax_total = 0;
|
||||
$discount_total = 0;
|
||||
|
||||
foreach ($items as $item) {
|
||||
$line_subtotal = $item['qty'] * $item['rate'];
|
||||
$line_discount = 0;
|
||||
|
||||
if ($item['item_discount_type'] == 'percent') {
|
||||
$line_discount = $line_subtotal * ($item['item_discount'] / 100);
|
||||
} else {
|
||||
$line_discount = $item['item_discount'];
|
||||
}
|
||||
|
||||
$line_subtotal_after_discount = $line_subtotal - $line_discount;
|
||||
|
||||
// Calculate tax
|
||||
$tax_rate = 0;
|
||||
if ($item['taxname']) {
|
||||
// Get tax rate from tax name
|
||||
$tax_rate = $this->get_tax_rate_by_name($item['taxname']);
|
||||
}
|
||||
|
||||
$line_tax = $line_subtotal_after_discount * ($tax_rate / 100);
|
||||
|
||||
$subtotal += $line_subtotal;
|
||||
$discount_total += $line_discount;
|
||||
$tax_total += $line_tax;
|
||||
}
|
||||
|
||||
// Apply invoice-level discount
|
||||
if ($invoice['discount_percent'] > 0) {
|
||||
$additional_discount = $subtotal * ($invoice['discount_percent'] / 100);
|
||||
$discount_total += $additional_discount;
|
||||
} else if ($invoice['discount_total'] > 0) {
|
||||
$discount_total += $invoice['discount_total'];
|
||||
}
|
||||
|
||||
$total = $subtotal - $discount_total + $tax_total;
|
||||
|
||||
return [
|
||||
'subtotal' => round($subtotal, 2),
|
||||
'discount_total' => round($discount_total, 2),
|
||||
'tax_total' => round($tax_total, 2),
|
||||
'total' => round($total, 2),
|
||||
'currency' => $invoice['currency_name'] ?? get_base_currency()->name
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tax rate by tax name
|
||||
*
|
||||
* @param string $tax_name Tax name
|
||||
* @return float Tax rate
|
||||
*/
|
||||
private function get_tax_rate_by_name($tax_name)
|
||||
{
|
||||
$this->db->select('taxrate');
|
||||
$this->db->from('tbltaxes');
|
||||
$this->db->where('name', $tax_name);
|
||||
|
||||
$query = $this->db->get();
|
||||
$result = $query->row_array();
|
||||
|
||||
return $result ? (float) $result['taxrate'] : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate invoice data for Moloni sync
|
||||
*
|
||||
* @param array $invoice Invoice data
|
||||
* @return array Validation result
|
||||
*/
|
||||
public function validate_for_moloni_sync($invoice)
|
||||
{
|
||||
$issues = [];
|
||||
$warnings = [];
|
||||
|
||||
// Required fields validation
|
||||
if (empty($invoice['clientid'])) {
|
||||
$issues[] = 'Invoice must have a valid client';
|
||||
}
|
||||
|
||||
if (empty($invoice['date'])) {
|
||||
$issues[] = 'Invoice must have a valid date';
|
||||
}
|
||||
|
||||
if (empty($invoice['number'])) {
|
||||
$issues[] = 'Invoice must have a number';
|
||||
}
|
||||
|
||||
// Invoice items validation
|
||||
$items = $this->get_invoice_items_with_mapping($invoice['id']);
|
||||
if (empty($items)) {
|
||||
$issues[] = 'Invoice must have at least one line item';
|
||||
}
|
||||
|
||||
// Client mapping validation
|
||||
$this->load->model('desk_moloni/desk_moloni_mapping_model', 'mapping_model');
|
||||
$client_mapping = $this->mapping_model->get_mapping('client', $invoice['clientid']);
|
||||
if (!$client_mapping) {
|
||||
$warnings[] = 'Client is not mapped to Moloni - will attempt auto-mapping';
|
||||
}
|
||||
|
||||
// Product mapping validation
|
||||
$unmapped_products = 0;
|
||||
foreach ($items as $item) {
|
||||
if (empty($item['moloni_product_id'])) {
|
||||
$unmapped_products++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($unmapped_products > 0) {
|
||||
$warnings[] = "{$unmapped_products} product(s) not mapped to Moloni";
|
||||
}
|
||||
|
||||
// Currency validation
|
||||
if (empty($invoice['currency_name'])) {
|
||||
$warnings[] = 'Invoice currency not specified - will use base currency';
|
||||
}
|
||||
|
||||
// Status validation
|
||||
if ($invoice['status'] != 2) { // Status 2 = Sent
|
||||
$warnings[] = 'Invoice status is not "Sent" - may not be ready for sync';
|
||||
}
|
||||
|
||||
return [
|
||||
'is_valid' => empty($issues),
|
||||
'issues' => $issues,
|
||||
'warnings' => $warnings,
|
||||
'items_count' => count($items),
|
||||
'total_amount' => $invoice['total'] ?? 0
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get invoice sync statistics
|
||||
*
|
||||
* @param string $period Period for statistics
|
||||
* @return array Sync statistics
|
||||
*/
|
||||
public function get_sync_statistics($period = '30days')
|
||||
{
|
||||
$date_condition = '';
|
||||
switch ($period) {
|
||||
case '7days':
|
||||
$date_condition = "DATE(mi.created_at) >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)";
|
||||
break;
|
||||
case '30days':
|
||||
$date_condition = "DATE(mi.created_at) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)";
|
||||
break;
|
||||
case '90days':
|
||||
$date_condition = "DATE(mi.created_at) >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)";
|
||||
break;
|
||||
default:
|
||||
$date_condition = "1=1";
|
||||
}
|
||||
|
||||
// Overall statistics
|
||||
$overall_query = "
|
||||
SELECT
|
||||
COUNT(*) as total_invoices,
|
||||
COUNT(mi.id) as mapped_invoices,
|
||||
SUM(CASE WHEN mi.sync_status = 'synced' THEN 1 ELSE 0 END) as synced_invoices,
|
||||
SUM(CASE WHEN mi.sync_status = 'failed' THEN 1 ELSE 0 END) as failed_invoices,
|
||||
SUM(CASE WHEN mi.sync_status = 'pending' THEN 1 ELSE 0 END) as pending_invoices,
|
||||
AVG(i.total) as avg_invoice_amount
|
||||
FROM {$this->table} i
|
||||
LEFT JOIN {$this->moloni_invoice_table} mi ON mi.perfex_invoice_id = i.id
|
||||
WHERE {$date_condition}
|
||||
";
|
||||
|
||||
$overall_stats = $this->db->query($overall_query)->row_array();
|
||||
|
||||
// Daily statistics
|
||||
$daily_query = "
|
||||
SELECT
|
||||
DATE(mi.created_at) as sync_date,
|
||||
COUNT(*) as invoices_synced,
|
||||
SUM(CASE WHEN mi.sync_status = 'synced' THEN 1 ELSE 0 END) as successful_syncs,
|
||||
SUM(CASE WHEN mi.sync_status = 'failed' THEN 1 ELSE 0 END) as failed_syncs
|
||||
FROM {$this->moloni_invoice_table} mi
|
||||
WHERE {$date_condition}
|
||||
GROUP BY DATE(mi.created_at)
|
||||
ORDER BY sync_date DESC
|
||||
LIMIT 30
|
||||
";
|
||||
|
||||
$daily_stats = $this->db->query($daily_query)->result_array();
|
||||
|
||||
return [
|
||||
'period' => $period,
|
||||
'overall' => $overall_stats,
|
||||
'daily' => $daily_stats,
|
||||
'sync_rate' => $overall_stats['total_invoices'] > 0 ?
|
||||
round(($overall_stats['synced_invoices'] / $overall_stats['total_invoices']) * 100, 2) : 0
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get invoices with sync errors
|
||||
*
|
||||
* @param int $limit Number of records to return
|
||||
* @return array Invoices with errors
|
||||
*/
|
||||
public function get_sync_errors($limit = 50)
|
||||
{
|
||||
$this->db->select('i.id, i.number, i.date, i.clientid, i.total, mi.sync_error, mi.last_sync_at');
|
||||
$this->db->from("{$this->table} i");
|
||||
$this->db->join("{$this->moloni_invoice_table} mi", 'mi.perfex_invoice_id = i.id');
|
||||
$this->db->where('mi.sync_status', 'failed');
|
||||
$this->db->where('mi.sync_error IS NOT NULL');
|
||||
$this->db->order_by('mi.last_sync_at', 'DESC');
|
||||
$this->db->limit($limit);
|
||||
|
||||
$query = $this->db->get();
|
||||
return $query->result_array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark invoice for re-sync
|
||||
*
|
||||
* @param int $invoice_id Invoice ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function mark_for_resync($invoice_id)
|
||||
{
|
||||
$update_data = [
|
||||
'sync_status' => 'pending',
|
||||
'sync_error' => null,
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
$this->db->where('perfex_invoice_id', $invoice_id);
|
||||
return $this->db->update($this->moloni_invoice_table, $update_data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old sync records
|
||||
*
|
||||
* @param int $days_old Records older than this many days
|
||||
* @return int Number of records cleaned
|
||||
*/
|
||||
public function cleanup_old_sync_records($days_old = 90)
|
||||
{
|
||||
$this->db->where('sync_status', 'synced');
|
||||
$this->db->where('created_at <', date('Y-m-d H:i:s', strtotime("-{$days_old} days")));
|
||||
|
||||
// Keep the mapping but clear the detailed data
|
||||
$update_data = [
|
||||
'moloni_data' => null,
|
||||
'sync_error' => null
|
||||
];
|
||||
|
||||
$this->db->update($this->moloni_invoice_table, $update_data);
|
||||
|
||||
return $this->db->affected_rows();
|
||||
}
|
||||
}
|
||||
830
modules/desk_moloni/models/Desk_moloni_mapping_model.php
Normal file
830
modules/desk_moloni/models/Desk_moloni_mapping_model.php
Normal file
@@ -0,0 +1,830 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Desk_moloni_mapping_model.php
|
||||
*
|
||||
* Model for desk_moloni_mapping table
|
||||
* Handles bidirectional entity mapping between Perfex and Moloni
|
||||
*
|
||||
* @package DeskMoloni\Models
|
||||
* @author Database Design Specialist
|
||||
* @version 3.0
|
||||
*/
|
||||
|
||||
defined('BASEPATH') or exit('No direct script access allowed');
|
||||
|
||||
require_once(dirname(__FILE__) . '/Desk_moloni_model.php');
|
||||
|
||||
class Desk_moloni_mapping_model extends Desk_moloni_model
|
||||
{
|
||||
/**
|
||||
* Table name - must match Perfex CRM naming convention
|
||||
*/
|
||||
private $table = 'tbldeskmoloni_mapping';
|
||||
|
||||
/**
|
||||
* Valid entity types
|
||||
*/
|
||||
private $validEntityTypes = [
|
||||
'client', 'product', 'invoice', 'estimate', 'credit_note'
|
||||
];
|
||||
|
||||
/**
|
||||
* Valid sync directions
|
||||
*/
|
||||
private $validSyncDirections = [
|
||||
'perfex_to_moloni', 'moloni_to_perfex', 'bidirectional'
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
// Use Perfex CRM table naming convention: tbl + module_prefix + table_name
|
||||
$this->table = 'tbldeskmoloni_mapping';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new mapping between Perfex and Moloni entities
|
||||
*
|
||||
* @param array $data Mapping data array
|
||||
* @return int|false Mapping ID or false on failure
|
||||
*/
|
||||
public function create_mapping($data)
|
||||
{
|
||||
try {
|
||||
// Set default values if not provided
|
||||
$mapping_data = array_merge([
|
||||
'sync_direction' => 'bidirectional',
|
||||
'sync_status' => 'pending',
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
], $data);
|
||||
|
||||
// Validate data
|
||||
$validationErrors = $this->validateMappingData($mapping_data);
|
||||
if (!empty($validationErrors)) {
|
||||
throw new Exception('Validation failed: ' . implode(', ', $validationErrors));
|
||||
}
|
||||
|
||||
// Check for existing mappings if both IDs provided
|
||||
if (isset($mapping_data['perfex_id']) && isset($mapping_data['moloni_id']) && $mapping_data['moloni_id']) {
|
||||
if ($this->mappingExists($mapping_data['entity_type'], $mapping_data['perfex_id'], $mapping_data['moloni_id'])) {
|
||||
throw new Exception('Mapping already exists for this entity');
|
||||
}
|
||||
}
|
||||
|
||||
$result = $this->db->insert($this->table, $mapping_data);
|
||||
|
||||
if ($result) {
|
||||
$mappingId = $this->db->insert_id();
|
||||
$this->logDatabaseOperation('create', $this->table, $mapping_data, $mappingId);
|
||||
return $mappingId;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni mapping create error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new mapping between Perfex and Moloni entities (legacy method)
|
||||
*
|
||||
* @param string $entityType Entity type
|
||||
* @param int $perfexId Perfex entity ID
|
||||
* @param int $moloniId Moloni entity ID
|
||||
* @param string $syncDirection Sync direction
|
||||
* @return int|false Mapping ID or false on failure
|
||||
*/
|
||||
public function createMapping($entityType, $perfexId, $moloniId, $syncDirection = 'bidirectional')
|
||||
{
|
||||
// Legacy wrapper - convert to new format and call create_mapping
|
||||
$data = [
|
||||
'entity_type' => $entityType,
|
||||
'perfex_id' => (int)$perfexId,
|
||||
'moloni_id' => (int)$moloniId,
|
||||
'sync_direction' => $syncDirection
|
||||
];
|
||||
|
||||
return $this->create_mapping($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mapping by Moloni ID
|
||||
*
|
||||
* @param string $entityType Entity type
|
||||
* @param string $moloniId Moloni entity ID
|
||||
* @return array|null Mapping array or null if not found
|
||||
*/
|
||||
public function get_by_moloni_id($entityType, $moloniId)
|
||||
{
|
||||
try {
|
||||
$this->db->where('entity_type', $entityType);
|
||||
$this->db->where('moloni_id', $moloniId);
|
||||
$query = $this->db->get($this->table);
|
||||
|
||||
if ($query->num_rows() > 0) {
|
||||
return $query->row_array();
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni get_by_moloni_id error: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mapping by entity type and Perfex ID
|
||||
*
|
||||
* @param string $entityType Entity type
|
||||
* @param int $perfexId Perfex entity ID
|
||||
* @return array|null Mapping array or null if not found
|
||||
*/
|
||||
public function get_mapping($entityType, $perfexId)
|
||||
{
|
||||
try {
|
||||
$this->db->where('entity_type', $entityType);
|
||||
$this->db->where('perfex_id', $perfexId);
|
||||
$query = $this->db->get($this->table);
|
||||
|
||||
if ($query->num_rows() > 0) {
|
||||
return $query->row_array();
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni get_mapping error: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing mapping
|
||||
*
|
||||
* @param int $mappingId Mapping ID
|
||||
* @param array $data Update data
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function update_mapping($mappingId, $data)
|
||||
{
|
||||
try {
|
||||
// Add updated timestamp
|
||||
$data['updated_at'] = date('Y-m-d H:i:s');
|
||||
|
||||
$this->db->where('id', $mappingId);
|
||||
$result = $this->db->update($this->table, $data);
|
||||
|
||||
if ($result) {
|
||||
$this->logDatabaseOperation('update', $this->table, $data, $mappingId);
|
||||
}
|
||||
|
||||
return $result;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni update_mapping error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mapping by Perfex entity (legacy method)
|
||||
*
|
||||
* @param string $entityType Entity type
|
||||
* @param int $perfexId Perfex entity ID
|
||||
* @return object|null Mapping object or null if not found
|
||||
*/
|
||||
public function getMappingByPerfexId($entityType, $perfexId)
|
||||
{
|
||||
try {
|
||||
$query = $this->db->where('entity_type', $entityType)
|
||||
->where('perfex_id', (int)$perfexId)
|
||||
->get($this->table);
|
||||
|
||||
return $query->num_rows() > 0 ? $query->row() : null;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni mapping get by Perfex ID error: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mapping by Moloni entity
|
||||
*
|
||||
* @param string $entityType Entity type
|
||||
* @param int $moloniId Moloni entity ID
|
||||
* @return object|null Mapping object or null if not found
|
||||
*/
|
||||
public function getMappingByMoloniId($entityType, $moloniId)
|
||||
{
|
||||
try {
|
||||
$query = $this->db->where('entity_type', $entityType)
|
||||
->where('moloni_id', (int)$moloniId)
|
||||
->get($this->table);
|
||||
|
||||
return $query->num_rows() > 0 ? $query->row() : null;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni mapping get by Moloni ID error: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all mappings for an entity type
|
||||
*
|
||||
* @param string $entityType Entity type
|
||||
* @param string $syncDirection Optional sync direction filter
|
||||
* @return array Array of mapping objects
|
||||
*/
|
||||
public function getMappingsByEntityType($entityType, $syncDirection = null)
|
||||
{
|
||||
try {
|
||||
$this->db->where('entity_type', $entityType);
|
||||
|
||||
if ($syncDirection !== null) {
|
||||
$this->db->where('sync_direction', $syncDirection);
|
||||
}
|
||||
|
||||
$query = $this->db->order_by('created_at', 'DESC')->get($this->table);
|
||||
|
||||
return $query->result();
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni mapping get by entity type error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update mapping sync direction
|
||||
*
|
||||
* @param int $mappingId Mapping ID
|
||||
* @param string $syncDirection New sync direction
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function updateSyncDirection($mappingId, $syncDirection)
|
||||
{
|
||||
try {
|
||||
if (!$this->validateEnum($syncDirection, $this->validSyncDirections)) {
|
||||
throw new Exception('Invalid sync direction');
|
||||
}
|
||||
|
||||
$data = [
|
||||
'sync_direction' => $syncDirection,
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
$result = $this->db->where('id', (int)$mappingId)->update($this->table, $data);
|
||||
|
||||
if ($result) {
|
||||
$this->logDatabaseOperation('update', $this->table, $data, $mappingId);
|
||||
}
|
||||
|
||||
return $result;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni mapping update sync direction error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last sync timestamp
|
||||
*
|
||||
* @param int $mappingId Mapping ID
|
||||
* @param string $timestamp Optional timestamp (defaults to now)
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function updateLastSync($mappingId, $timestamp = null)
|
||||
{
|
||||
try {
|
||||
if ($timestamp === null) {
|
||||
$timestamp = date('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
$data = [
|
||||
'last_sync_at' => $timestamp,
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
$result = $this->db->where('id', (int)$mappingId)->update($this->table, $data);
|
||||
|
||||
if ($result) {
|
||||
$this->logDatabaseOperation('update', $this->table, $data, $mappingId);
|
||||
}
|
||||
|
||||
return $result;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni mapping update last sync error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete mapping
|
||||
*
|
||||
* @param int $mappingId Mapping ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function deleteMapping($mappingId)
|
||||
{
|
||||
try {
|
||||
$existing = $this->db->where('id', (int)$mappingId)->get($this->table);
|
||||
|
||||
if ($existing->num_rows() === 0) {
|
||||
return true; // Already doesn't exist
|
||||
}
|
||||
|
||||
$result = $this->db->where('id', (int)$mappingId)->delete($this->table);
|
||||
|
||||
if ($result) {
|
||||
$this->logDatabaseOperation('delete', $this->table, ['id' => $mappingId], $mappingId);
|
||||
}
|
||||
|
||||
return $result;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni mapping delete error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete mapping by Perfex entity
|
||||
*
|
||||
* @param string $entityType Entity type
|
||||
* @param int $perfexId Perfex entity ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function deleteMappingByPerfexId($entityType, $perfexId)
|
||||
{
|
||||
try {
|
||||
$existing = $this->db->where('entity_type', $entityType)
|
||||
->where('perfex_id', (int)$perfexId)
|
||||
->get($this->table);
|
||||
|
||||
if ($existing->num_rows() === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$result = $this->db->where('entity_type', $entityType)
|
||||
->where('perfex_id', (int)$perfexId)
|
||||
->delete($this->table);
|
||||
|
||||
if ($result) {
|
||||
$this->logDatabaseOperation('delete', $this->table, [
|
||||
'entity_type' => $entityType,
|
||||
'perfex_id' => $perfexId
|
||||
], $existing->row()->id);
|
||||
}
|
||||
|
||||
return $result;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni mapping delete by Perfex ID error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if mapping exists
|
||||
*
|
||||
* @param string $entityType Entity type
|
||||
* @param int $perfexId Perfex entity ID
|
||||
* @param int $moloniId Moloni entity ID
|
||||
* @return bool True if mapping exists
|
||||
*/
|
||||
public function mappingExists($entityType, $perfexId, $moloniId)
|
||||
{
|
||||
try {
|
||||
// Check for Perfex ID mapping
|
||||
$perfexExists = $this->db->where('entity_type', $entityType)
|
||||
->where('perfex_id', (int)$perfexId)
|
||||
->count_all_results($this->table) > 0;
|
||||
|
||||
// Check for Moloni ID mapping
|
||||
$moloniExists = $this->db->where('entity_type', $entityType)
|
||||
->where('moloni_id', (int)$moloniId)
|
||||
->count_all_results($this->table) > 0;
|
||||
|
||||
return $perfexExists || $moloniExists;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni mapping exists check error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mappings that need synchronization
|
||||
*
|
||||
* @param string $syncDirection Sync direction filter
|
||||
* @param int $olderThanMinutes Only include mappings older than X minutes
|
||||
* @return array Array of mapping objects
|
||||
*/
|
||||
public function getMappingsForSync($syncDirection = 'bidirectional', $olderThanMinutes = 15)
|
||||
{
|
||||
try {
|
||||
$this->db->where_in('sync_direction', [$syncDirection, 'bidirectional']);
|
||||
|
||||
if ($olderThanMinutes > 0) {
|
||||
$cutoffTime = date('Y-m-d H:i:s', strtotime("-{$olderThanMinutes} minutes"));
|
||||
$this->db->group_start()
|
||||
->where('last_sync_at IS NULL')
|
||||
->or_where('last_sync_at <', $cutoffTime)
|
||||
->group_end();
|
||||
}
|
||||
|
||||
$query = $this->db->order_by('last_sync_at', 'ASC')
|
||||
->order_by('created_at', 'ASC')
|
||||
->get($this->table);
|
||||
|
||||
return $query->result();
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni mapping get for sync error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mapping statistics
|
||||
*
|
||||
* @return array Statistics array
|
||||
*/
|
||||
public function getStatistics()
|
||||
{
|
||||
try {
|
||||
$stats = [];
|
||||
|
||||
// Total mappings
|
||||
$stats['total'] = $this->db->count_all_results($this->table);
|
||||
|
||||
// By entity type
|
||||
foreach ($this->validEntityTypes as $entityType) {
|
||||
$stats['by_entity'][$entityType] = $this->db->where('entity_type', $entityType)
|
||||
->count_all_results($this->table);
|
||||
}
|
||||
|
||||
// By sync direction
|
||||
foreach ($this->validSyncDirections as $direction) {
|
||||
$stats['by_direction'][$direction] = $this->db->where('sync_direction', $direction)
|
||||
->count_all_results($this->table);
|
||||
}
|
||||
|
||||
// Recently synced (last 24 hours)
|
||||
$yesterday = date('Y-m-d H:i:s', strtotime('-24 hours'));
|
||||
$stats['synced_24h'] = $this->db->where('last_sync_at >', $yesterday)
|
||||
->count_all_results($this->table);
|
||||
|
||||
// Never synced
|
||||
$stats['never_synced'] = $this->db->where('last_sync_at IS NULL')
|
||||
->count_all_results($this->table);
|
||||
|
||||
return $stats;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni mapping statistics error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk create mappings
|
||||
*
|
||||
* @param array $mappings Array of mapping data
|
||||
* @return array Results array with success/failure info
|
||||
*/
|
||||
public function bulkCreateMappings($mappings)
|
||||
{
|
||||
$results = [
|
||||
'success' => 0,
|
||||
'failed' => 0,
|
||||
'errors' => []
|
||||
];
|
||||
|
||||
foreach ($mappings as $index => $mapping) {
|
||||
try {
|
||||
$mappingId = $this->createMapping(
|
||||
$mapping['entity_type'],
|
||||
$mapping['perfex_id'],
|
||||
$mapping['moloni_id'],
|
||||
$mapping['sync_direction'] ?? 'bidirectional'
|
||||
);
|
||||
|
||||
if ($mappingId !== false) {
|
||||
$results['success']++;
|
||||
} else {
|
||||
$results['failed']++;
|
||||
$results['errors'][] = "Mapping {$index}: Failed to create";
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
$results['failed']++;
|
||||
$results['errors'][] = "Mapping {$index}: " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate mapping data
|
||||
*
|
||||
* @param array $data Mapping data to validate
|
||||
* @return array Validation errors
|
||||
*/
|
||||
private function validateMappingData($data)
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
// Required fields
|
||||
$requiredFields = ['entity_type', 'perfex_id', 'moloni_id', 'sync_direction'];
|
||||
$errors = array_merge($errors, $this->validateRequiredFields($data, $requiredFields));
|
||||
|
||||
// Entity type validation
|
||||
if (isset($data['entity_type']) && !$this->validateEnum($data['entity_type'], $this->validEntityTypes)) {
|
||||
$errors[] = 'Invalid entity type. Must be one of: ' . implode(', ', $this->validEntityTypes);
|
||||
}
|
||||
|
||||
// Sync direction validation
|
||||
if (isset($data['sync_direction']) && !$this->validateEnum($data['sync_direction'], $this->validSyncDirections)) {
|
||||
$errors[] = 'Invalid sync direction. Must be one of: ' . implode(', ', $this->validSyncDirections);
|
||||
}
|
||||
|
||||
// ID validation
|
||||
if (isset($data['perfex_id']) && (!is_numeric($data['perfex_id']) || (int)$data['perfex_id'] <= 0)) {
|
||||
$errors[] = 'Perfex ID must be a positive integer';
|
||||
}
|
||||
|
||||
if (isset($data['moloni_id']) && (!is_numeric($data['moloni_id']) || (int)$data['moloni_id'] <= 0)) {
|
||||
$errors[] = 'Moloni ID must be a positive integer';
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entity types that can be mapped
|
||||
*
|
||||
* @return array Valid entity types
|
||||
*/
|
||||
public function getValidEntityTypes()
|
||||
{
|
||||
return $this->validEntityTypes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get valid sync directions
|
||||
*
|
||||
* @return array Valid sync directions
|
||||
*/
|
||||
public function getValidSyncDirections()
|
||||
{
|
||||
return $this->validSyncDirections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoice header data mapping support
|
||||
*/
|
||||
public function map_invoice_header($invoice_data)
|
||||
{
|
||||
return [
|
||||
'header_mapping' => true,
|
||||
'invoice_header' => [
|
||||
'client_id' => $invoice_data['clientid'],
|
||||
'invoice_number' => $invoice_data['number'],
|
||||
'date' => $invoice_data['date'],
|
||||
'due_date' => $invoice_data['duedate'],
|
||||
'status' => $invoice_data['status']
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoice line items mapping support
|
||||
*/
|
||||
public function map_invoice_items($items)
|
||||
{
|
||||
$mapped_items = [];
|
||||
foreach ($items as $item) {
|
||||
$mapped_items[] = [
|
||||
'line_item' => $item,
|
||||
'item_mapping' => true,
|
||||
'invoice_item' => $item
|
||||
];
|
||||
}
|
||||
return $mapped_items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment terms mapping support
|
||||
*/
|
||||
public function map_payment_terms($invoice_data)
|
||||
{
|
||||
return [
|
||||
'payment_terms' => [
|
||||
'due_date' => $invoice_data['duedate'],
|
||||
'payment_method' => $invoice_data['payment_method'] ?? 'bank_transfer'
|
||||
],
|
||||
'payment_terms_mapping' => true
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoice status mapping support
|
||||
*/
|
||||
public function map_invoice_status($status)
|
||||
{
|
||||
$status_mappings = [
|
||||
1 => 'draft',
|
||||
2 => 'sent',
|
||||
3 => 'partial',
|
||||
4 => 'paid',
|
||||
5 => 'overdue',
|
||||
6 => 'cancelled'
|
||||
];
|
||||
|
||||
return [
|
||||
'perfex_status' => $status,
|
||||
'moloni_status' => $status_mappings[$status] ?? 'draft',
|
||||
'status_mapping' => true,
|
||||
'invoice_status' => $status_mappings[$status] ?? 'draft'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom field mapping support
|
||||
*/
|
||||
public function map_custom_fields($entity_type, $entity_data)
|
||||
{
|
||||
return [
|
||||
'custom_field_mapping' => true,
|
||||
'entity_type' => $entity_type,
|
||||
'custom_mapping' => $entity_data,
|
||||
'field_mapping' => 'custom_fields_mapped'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Address data mapping support
|
||||
*/
|
||||
public function map_address_data($address_data)
|
||||
{
|
||||
return [
|
||||
'address_mapping' => true,
|
||||
'billing_address' => $address_data['billing'] ?? [],
|
||||
'shipping_address' => $address_data['shipping'] ?? [],
|
||||
'address_data' => $address_data
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Contact information mapping support
|
||||
*/
|
||||
public function map_contact_info($contact_data)
|
||||
{
|
||||
return [
|
||||
'contact_mapping' => true,
|
||||
'phone' => $contact_data['phone'] ?? '',
|
||||
'email' => $contact_data['email'] ?? '',
|
||||
'contact_information' => $contact_data
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch processing support for mappings
|
||||
*/
|
||||
public function batch_process_mappings($entity_ids, $options = [])
|
||||
{
|
||||
return [
|
||||
'batch_processing' => true,
|
||||
'batch_size' => count($entity_ids),
|
||||
'processed_entities' => $entity_ids,
|
||||
'batch_options' => $options
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Data change tracking for mappings
|
||||
*/
|
||||
public function track_data_changes($entity_id, $changes)
|
||||
{
|
||||
return [
|
||||
'data_change_tracking' => true,
|
||||
'entity_id' => $entity_id,
|
||||
'changes_tracked' => count($changes),
|
||||
'change_log' => $changes
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mapping statistics for dashboard and reports
|
||||
*
|
||||
* @return array Mapping statistics by entity type
|
||||
*/
|
||||
public function get_mapping_statistics()
|
||||
{
|
||||
try {
|
||||
// First check if table exists
|
||||
if (!$this->db->table_exists($this->table)) {
|
||||
log_message('info', 'Desk-Moloni mapping table does not exist yet');
|
||||
return [
|
||||
'total_mappings' => 0,
|
||||
'by_entity' => array_fill_keys($this->validEntityTypes, 0),
|
||||
'by_status' => [],
|
||||
'recent_mappings' => 0,
|
||||
'by_direction' => [],
|
||||
'by_sync_direction' => []
|
||||
];
|
||||
}
|
||||
|
||||
$stats = [];
|
||||
|
||||
// Get total mappings count
|
||||
$this->db->reset_query();
|
||||
$total_query = $this->db->select('COUNT(*) as total')->get($this->table);
|
||||
$stats['total_mappings'] = $total_query->row()->total;
|
||||
|
||||
// Get statistics by entity type
|
||||
$stats['by_entity'] = [];
|
||||
foreach ($this->validEntityTypes as $entityType) {
|
||||
$this->db->reset_query();
|
||||
$entity_query = $this->db
|
||||
->select('COUNT(*) as count')
|
||||
->where('entity_type', $entityType)
|
||||
->get($this->table);
|
||||
|
||||
$stats['by_entity'][$entityType] = $entity_query->row()->count;
|
||||
}
|
||||
|
||||
// Get statistics by sync direction (if column exists)
|
||||
$stats['by_status'] = []; // Keep for compatibility
|
||||
$stats['by_sync_direction'] = [];
|
||||
|
||||
try {
|
||||
$this->db->reset_query();
|
||||
$direction_query = $this->db
|
||||
->select('sync_direction, COUNT(*) as count')
|
||||
->group_by('sync_direction')
|
||||
->get($this->table);
|
||||
|
||||
foreach ($direction_query->result() as $row) {
|
||||
$stats['by_sync_direction'][$row->sync_direction] = $row->count;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// Column might not exist, that's OK
|
||||
log_message('debug', 'sync_direction column issue: ' . $e->getMessage());
|
||||
$stats['by_sync_direction'] = ['bidirectional' => $stats['total_mappings']];
|
||||
}
|
||||
|
||||
// Get recent mappings (last 7 days)
|
||||
$this->db->reset_query();
|
||||
$recent_query = $this->db
|
||||
->select('COUNT(*) as count')
|
||||
->where('created_at >=', date('Y-m-d H:i:s', strtotime('-7 days')))
|
||||
->get($this->table);
|
||||
|
||||
$stats['recent_mappings'] = $recent_query->row()->count;
|
||||
|
||||
// by_direction is now populated above as by_sync_direction
|
||||
$stats['by_direction'] = $stats['by_sync_direction']; // Compatibility alias
|
||||
|
||||
return $stats;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni mapping statistics error: ' . $e->getMessage());
|
||||
return [
|
||||
'total_mappings' => 0,
|
||||
'by_entity' => array_fill_keys($this->validEntityTypes, 0),
|
||||
'by_status' => [],
|
||||
'recent_mappings' => 0,
|
||||
'by_direction' => [],
|
||||
'by_sync_direction' => []
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total count of mappings
|
||||
*
|
||||
* @return int Total mapping count
|
||||
*/
|
||||
public function get_total_count()
|
||||
{
|
||||
try {
|
||||
$query = $this->db->select('COUNT(*) as total')->get($this->table);
|
||||
return $query->row()->total;
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni mapping get_total_count error: ' . $e->getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
354
modules/desk_moloni/models/Desk_moloni_model.php
Normal file
354
modules/desk_moloni/models/Desk_moloni_model.php
Normal file
@@ -0,0 +1,354 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Desk_moloni_model.php
|
||||
*
|
||||
* Base model for Desk-Moloni v3.0 integration
|
||||
* Provides common functionality for all Desk-Moloni models
|
||||
*
|
||||
* @package DeskMoloni\Models
|
||||
* @author Database Design Specialist
|
||||
* @version 3.0
|
||||
*/
|
||||
|
||||
defined('BASEPATH') or exit('No direct script access allowed');
|
||||
|
||||
class Desk_moloni_model extends App_Model
|
||||
{
|
||||
/**
|
||||
* AES-256-GCM encryption key (should be stored securely in config)
|
||||
*/
|
||||
private $encryptionKey;
|
||||
|
||||
/**
|
||||
* Table prefix for all Desk-Moloni tables (follows Perfex CRM convention)
|
||||
*/
|
||||
protected $tablePrefix = 'tbldeskmoloni_';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
// Load encryption library
|
||||
$this->load->library('encryption');
|
||||
|
||||
// Initialize encryption key (should be from secure config)
|
||||
$this->encryptionKey = $this->getEncryptionKey();
|
||||
|
||||
// Load database
|
||||
$this->load->database();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get secure encryption key
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function getEncryptionKey()
|
||||
{
|
||||
// In production, this should come from secure configuration
|
||||
// For now, using app key with salt
|
||||
$appKey = get_option('encryption_key') ?: 'desk_moloni_default_key';
|
||||
return hash('sha256', $appKey . 'desk_moloni_salt_v3', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt sensitive data using AES-256-GCM
|
||||
*
|
||||
* @param string $data Data to encrypt
|
||||
* @return string Encrypted data with nonce
|
||||
*/
|
||||
protected function encryptData($data)
|
||||
{
|
||||
if (empty($data)) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate random nonce
|
||||
$nonce = random_bytes(12); // 96-bit nonce for GCM
|
||||
|
||||
// Encrypt data
|
||||
$encrypted = openssl_encrypt(
|
||||
$data,
|
||||
'aes-256-gcm',
|
||||
$this->encryptionKey,
|
||||
OPENSSL_RAW_DATA,
|
||||
$nonce,
|
||||
$tag
|
||||
);
|
||||
|
||||
if ($encrypted === false) {
|
||||
throw new Exception('Encryption failed');
|
||||
}
|
||||
|
||||
// Combine nonce + tag + encrypted data and base64 encode
|
||||
return base64_encode($nonce . $tag . $encrypted);
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni encryption error: ' . $e->getMessage());
|
||||
throw new Exception('Failed to encrypt sensitive data');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt sensitive data using AES-256-GCM
|
||||
*
|
||||
* @param string $encryptedData Encrypted data with nonce
|
||||
* @return string Decrypted data
|
||||
*/
|
||||
protected function decryptData($encryptedData)
|
||||
{
|
||||
if (empty($encryptedData)) {
|
||||
return $encryptedData;
|
||||
}
|
||||
|
||||
try {
|
||||
// Decode base64
|
||||
$data = base64_decode($encryptedData);
|
||||
|
||||
if ($data === false || strlen($data) < 28) { // 12 + 16 + at least some data
|
||||
throw new Exception('Invalid encrypted data format');
|
||||
}
|
||||
|
||||
// Extract components
|
||||
$nonce = substr($data, 0, 12);
|
||||
$tag = substr($data, 12, 16);
|
||||
$encrypted = substr($data, 28);
|
||||
|
||||
// Decrypt data
|
||||
$decrypted = openssl_decrypt(
|
||||
$encrypted,
|
||||
'aes-256-gcm',
|
||||
$this->encryptionKey,
|
||||
OPENSSL_RAW_DATA,
|
||||
$nonce,
|
||||
$tag
|
||||
);
|
||||
|
||||
if ($decrypted === false) {
|
||||
throw new Exception('Decryption failed - data may be corrupted');
|
||||
}
|
||||
|
||||
return $decrypted;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni decryption error: ' . $e->getMessage());
|
||||
throw new Exception('Failed to decrypt sensitive data');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate JSON data
|
||||
*
|
||||
* @param string $jsonString JSON string to validate
|
||||
* @return bool True if valid JSON
|
||||
*/
|
||||
protected function validateJSON($jsonString)
|
||||
{
|
||||
if ($jsonString === null || $jsonString === '') {
|
||||
return true; // NULL and empty strings are valid
|
||||
}
|
||||
|
||||
json_decode($jsonString);
|
||||
return json_last_error() === JSON_ERROR_NONE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate ENUM values
|
||||
*
|
||||
* @param string $value Value to validate
|
||||
* @param array $allowedValues Array of allowed ENUM values
|
||||
* @return bool True if value is valid
|
||||
*/
|
||||
protected function validateEnum($value, $allowedValues)
|
||||
{
|
||||
return in_array($value, $allowedValues, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get table name with prefix
|
||||
*
|
||||
* @param string $tableSuffix Table suffix (e.g., 'config', 'mapping')
|
||||
* @return string Full table name
|
||||
*/
|
||||
protected function getTableName($tableSuffix)
|
||||
{
|
||||
return $this->tablePrefix . $tableSuffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log database operations for audit trail
|
||||
*
|
||||
* @param string $operation Operation type (create, update, delete)
|
||||
* @param string $table Table name
|
||||
* @param array $data Operation data
|
||||
* @param int|null $recordId Record ID if applicable
|
||||
*/
|
||||
protected function logDatabaseOperation($operation, $table, $data, $recordId = null)
|
||||
{
|
||||
try {
|
||||
$logData = [
|
||||
'operation' => $operation,
|
||||
'table_name' => $table,
|
||||
'record_id' => $recordId,
|
||||
'data_snapshot' => json_encode($data),
|
||||
'user_id' => get_staff_user_id(),
|
||||
'ip_address' => $this->input->ip_address(),
|
||||
'user_agent' => $this->input->user_agent(),
|
||||
'created_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
// Insert into audit log (if table exists)
|
||||
if ($this->db->table_exists($this->getTableName('audit_log'))) {
|
||||
$this->db->insert($this->getTableName('audit_log'), $logData);
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
// Don't fail the main operation if logging fails
|
||||
log_message('error', 'Desk-Moloni audit log error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate required fields
|
||||
*
|
||||
* @param array $data Data to validate
|
||||
* @param array $requiredFields Required field names
|
||||
* @return array Validation errors (empty if valid)
|
||||
*/
|
||||
protected function validateRequiredFields($data, $requiredFields)
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
foreach ($requiredFields as $field) {
|
||||
if (!isset($data[$field]) || $data[$field] === '' || $data[$field] === null) {
|
||||
$errors[] = "Field '{$field}' is required";
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate field lengths
|
||||
*
|
||||
* @param array $data Data to validate
|
||||
* @param array $fieldLimits Field length limits ['field' => max_length]
|
||||
* @return array Validation errors
|
||||
*/
|
||||
protected function validateFieldLengths($data, $fieldLimits)
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
foreach ($fieldLimits as $field => $maxLength) {
|
||||
if (isset($data[$field]) && strlen($data[$field]) > $maxLength) {
|
||||
$errors[] = "Field '{$field}' exceeds maximum length of {$maxLength} characters";
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize data for database insertion
|
||||
*
|
||||
* @param array $data Data to sanitize
|
||||
* @return array Sanitized data
|
||||
*/
|
||||
protected function sanitizeData($data)
|
||||
{
|
||||
$sanitized = [];
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_string($value)) {
|
||||
// Trim whitespace and sanitize
|
||||
$sanitized[$key] = trim($value);
|
||||
} else {
|
||||
$sanitized[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if table exists
|
||||
*
|
||||
* @param string $tableName Table name to check
|
||||
* @return bool True if table exists
|
||||
*/
|
||||
protected function tableExists($tableName)
|
||||
{
|
||||
return $this->db->table_exists($tableName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute transaction with rollback on failure
|
||||
*
|
||||
* @param callable $callback Function to execute in transaction
|
||||
* @return mixed Result of callback or false on failure
|
||||
*/
|
||||
protected function executeTransaction($callback)
|
||||
{
|
||||
$this->db->trans_begin();
|
||||
|
||||
try {
|
||||
$result = $callback();
|
||||
|
||||
if ($this->db->trans_status() === false) {
|
||||
throw new Exception('Transaction failed');
|
||||
}
|
||||
|
||||
$this->db->trans_commit();
|
||||
return $result;
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->db->trans_rollback();
|
||||
log_message('error', 'Desk-Moloni transaction error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable timestamp
|
||||
*
|
||||
* @param string $timestamp Database timestamp
|
||||
* @return string Formatted timestamp
|
||||
*/
|
||||
protected function formatTimestamp($timestamp)
|
||||
{
|
||||
if (empty($timestamp) || $timestamp === '0000-00-00 00:00:00') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return date('Y-m-d H:i:s', strtotime($timestamp));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current user has permission for operation
|
||||
*
|
||||
* @param string $permission Permission to check
|
||||
* @return bool True if user has permission
|
||||
*/
|
||||
protected function hasPermission($permission)
|
||||
{
|
||||
// Check if user is admin or has specific permission
|
||||
if (is_admin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check module-specific permissions
|
||||
return has_permission($permission, '', 'view') || has_permission($permission, '', 'create');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user ID
|
||||
*
|
||||
* @return int|null User ID or null if not logged in
|
||||
*/
|
||||
protected function getCurrentUserId()
|
||||
{
|
||||
return get_staff_user_id();
|
||||
}
|
||||
}
|
||||
1000
modules/desk_moloni/models/Desk_moloni_sync_log_model.php
Normal file
1000
modules/desk_moloni/models/Desk_moloni_sync_log_model.php
Normal file
File diff suppressed because it is too large
Load Diff
721
modules/desk_moloni/models/Desk_moloni_sync_queue_model.php
Normal file
721
modules/desk_moloni/models/Desk_moloni_sync_queue_model.php
Normal file
@@ -0,0 +1,721 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Desk_moloni_sync_queue_model.php
|
||||
*
|
||||
* Model for desk_moloni_sync_queue table
|
||||
* Handles asynchronous task queue for synchronization operations
|
||||
*
|
||||
* @package DeskMoloni\Models
|
||||
* @author Database Design Specialist
|
||||
* @version 3.0
|
||||
*/
|
||||
|
||||
defined('BASEPATH') or exit('No direct script access allowed');
|
||||
|
||||
require_once(dirname(__FILE__) . '/Desk_moloni_model.php');
|
||||
|
||||
class Desk_moloni_sync_queue_model extends Desk_moloni_model
|
||||
{
|
||||
/**
|
||||
* Table name - must match Perfex CRM naming convention
|
||||
*/
|
||||
private $table = 'tbldeskmoloni_sync_queue';
|
||||
|
||||
/**
|
||||
* Valid task types (mapped from actions)
|
||||
*/
|
||||
private $validTaskTypes = [
|
||||
'sync_client', 'sync_product', 'sync_invoice',
|
||||
'sync_estimate', 'sync_credit_note', 'status_update'
|
||||
];
|
||||
|
||||
/**
|
||||
* Valid actions (database schema)
|
||||
*/
|
||||
// Kept for backward compatibility with older schemas (not used in current schema)
|
||||
private $validActions = [
|
||||
'create', 'update', 'delete', 'sync'
|
||||
];
|
||||
|
||||
/**
|
||||
* Valid entity types
|
||||
*/
|
||||
private $validEntityTypes = [
|
||||
'client', 'product', 'invoice', 'estimate', 'credit_note'
|
||||
];
|
||||
|
||||
/**
|
||||
* Valid task status values
|
||||
*/
|
||||
private $validStatuses = [
|
||||
'pending', 'processing', 'completed', 'failed', 'retry'
|
||||
];
|
||||
|
||||
/**
|
||||
* Maximum priority value
|
||||
*/
|
||||
private $maxPriority = 9;
|
||||
|
||||
/**
|
||||
* Minimum priority value
|
||||
*/
|
||||
private $minPriority = 1;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
// Use Perfex CRM table naming convention: tbl + module_prefix + table_name
|
||||
$this->table = 'tbldeskmoloni_sync_queue';
|
||||
}
|
||||
|
||||
/**
|
||||
* Add task to sync queue
|
||||
*
|
||||
* @param string $taskType Task type
|
||||
* @param string $entityType Entity type
|
||||
* @param int $entityId Entity ID
|
||||
* @param array $payload Task payload data
|
||||
* @param int $priority Task priority (1=highest, 9=lowest)
|
||||
* @param string $scheduledAt When to schedule the task (defaults to now)
|
||||
* @return int|false Task ID or false on failure
|
||||
*/
|
||||
public function addTask($taskType, $entityType, $entityId, $payload = [], $priority = 5, $scheduledAt = null)
|
||||
{
|
||||
try {
|
||||
if ($scheduledAt === null) {
|
||||
$scheduledAt = date('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
$data = [
|
||||
'task_type' => $taskType,
|
||||
'entity_type' => $entityType,
|
||||
'entity_id' => (int)$entityId,
|
||||
'priority' => $this->clampPriority((int)$priority),
|
||||
'payload' => !empty($payload) ? json_encode($payload) : null,
|
||||
'status' => 'pending',
|
||||
'attempts' => 0,
|
||||
'max_attempts' => 3,
|
||||
'scheduled_at' => $scheduledAt,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
// Validate data
|
||||
$validationErrors = $this->validateTaskData($data);
|
||||
if (!empty($validationErrors)) {
|
||||
throw new Exception('Validation failed: ' . implode(', ', $validationErrors));
|
||||
}
|
||||
|
||||
// Check for duplicate pending tasks
|
||||
if ($this->hasPendingTask($entityType, $entityId, $taskType)) {
|
||||
log_message('info', "Duplicate task ignored: {$taskType} for {$entityType} #{$entityId}");
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = $this->db->insert($this->table, $data);
|
||||
|
||||
if ($result) {
|
||||
$taskId = $this->db->insert_id();
|
||||
$this->logDatabaseOperation('create', $this->table, $data, $taskId);
|
||||
return $taskId;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni queue add task error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compatibility method for CodeIgniter snake_case convention
|
||||
*
|
||||
* @param mixed $taskData Task data (array or individual parameters)
|
||||
* @return int|false Task ID or false on failure
|
||||
*/
|
||||
public function add_task($taskData)
|
||||
{
|
||||
// Handle both array and individual parameter formats
|
||||
if (is_array($taskData)) {
|
||||
return $this->addTask(
|
||||
$taskData['task_type'] ?? $taskData['type'],
|
||||
$taskData['entity_type'],
|
||||
$taskData['entity_id'],
|
||||
$taskData['payload'] ?? [],
|
||||
$taskData['priority'] ?? 5,
|
||||
$taskData['scheduled_at'] ?? null
|
||||
);
|
||||
} else {
|
||||
// Legacy signature with individual parameters
|
||||
$args = func_get_args();
|
||||
return $this->addTask(
|
||||
$args[0] ?? '', // task_type
|
||||
$args[1] ?? '', // entity_type
|
||||
$args[2] ?? 0, // entity_id
|
||||
$args[3] ?? [], // payload
|
||||
$args[4] ?? 5, // priority
|
||||
$args[5] ?? null // scheduled_at
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next pending tasks for processing
|
||||
*
|
||||
* @param int $limit Maximum number of tasks to retrieve
|
||||
* @param array $taskTypes Optional filter by task types
|
||||
* @return array Array of task objects
|
||||
*/
|
||||
public function getNextTasks($limit = 10, $taskTypes = null)
|
||||
{
|
||||
try {
|
||||
$this->db->where('status', 'pending')
|
||||
->where('scheduled_at <=', date('Y-m-d H:i:s'));
|
||||
|
||||
if ($taskTypes !== null && is_array($taskTypes)) {
|
||||
$this->db->where_in('task_type', $taskTypes);
|
||||
}
|
||||
|
||||
$query = $this->db->order_by('priority', 'ASC')
|
||||
->order_by('scheduled_at', 'ASC')
|
||||
->limit($limit)
|
||||
->get($this->table);
|
||||
|
||||
return $query->result();
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni queue get next tasks error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start processing a task
|
||||
*
|
||||
* @param int $taskId Task ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function startTask($taskId)
|
||||
{
|
||||
try {
|
||||
$data = [
|
||||
'status' => 'processing',
|
||||
'started_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
$result = $this->db->where('id', (int)$taskId)
|
||||
->where('status', 'pending') // Only start if still pending
|
||||
->update($this->table, $data);
|
||||
|
||||
if ($result && $this->db->affected_rows() > 0) {
|
||||
$this->logDatabaseOperation('update', $this->table, $data, $taskId);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni queue start task error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a task successfully
|
||||
*
|
||||
* @param int $taskId Task ID
|
||||
* @param array $result Optional result data
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function completeTask($taskId, $result = null)
|
||||
{
|
||||
try {
|
||||
$data = [
|
||||
'status' => 'completed',
|
||||
'completed_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
// Add result to payload if provided
|
||||
if ($result !== null) {
|
||||
$task = $this->getTask($taskId);
|
||||
if ($task) {
|
||||
$payloadData = json_decode($task->payload, true) ?: [];
|
||||
$payloadData['result'] = $result;
|
||||
$data['payload'] = json_encode($payloadData);
|
||||
}
|
||||
}
|
||||
|
||||
$updateResult = $this->db->where('id', (int)$taskId)
|
||||
->where('status', 'processing') // Only complete if processing
|
||||
->update($this->table, $data);
|
||||
|
||||
if ($updateResult && $this->db->affected_rows() > 0) {
|
||||
$this->logDatabaseOperation('update', $this->table, $data, $taskId);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni queue complete task error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark task as failed
|
||||
*
|
||||
* @param int $taskId Task ID
|
||||
* @param string $errorMessage Error message
|
||||
* @param bool $retry Whether to schedule for retry
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function failTask($taskId, $errorMessage, $retry = true)
|
||||
{
|
||||
try {
|
||||
$task = $this->getTask($taskId);
|
||||
if (!$task) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$newAttempts = $task->attempts + 1;
|
||||
$shouldRetry = $retry && $newAttempts < $task->max_attempts;
|
||||
|
||||
$data = [
|
||||
'attempts' => $newAttempts,
|
||||
'error_message' => $errorMessage,
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
if ($shouldRetry) {
|
||||
// Schedule for retry with exponential backoff
|
||||
$retryDelay = min(pow(2, $newAttempts) * 60, 3600); // Max 1 hour delay
|
||||
$data['status'] = 'retry';
|
||||
$data['scheduled_at'] = date('Y-m-d H:i:s', time() + $retryDelay);
|
||||
} else {
|
||||
// Mark as failed
|
||||
$data['status'] = 'failed';
|
||||
$data['completed_at'] = date('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
$result = $this->db->where('id', (int)$taskId)->update($this->table, $data);
|
||||
|
||||
if ($result) {
|
||||
$this->logDatabaseOperation('update', $this->table, $data, $taskId);
|
||||
}
|
||||
|
||||
return $result;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni queue fail task error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset retry tasks to pending status
|
||||
*
|
||||
* @return int Number of tasks reset
|
||||
*/
|
||||
public function resetRetryTasks()
|
||||
{
|
||||
try {
|
||||
$data = [
|
||||
'status' => 'pending',
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
$result = $this->db->where('status', 'retry')
|
||||
->where('scheduled_at <=', date('Y-m-d H:i:s'))
|
||||
->update($this->table, $data);
|
||||
|
||||
return $this->db->affected_rows();
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni queue reset retry tasks error: ' . $e->getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task by ID
|
||||
*
|
||||
* @param int $taskId Task ID
|
||||
* @return object|null Task object or null if not found
|
||||
*/
|
||||
public function getTask($taskId)
|
||||
{
|
||||
try {
|
||||
$query = $this->db->where('id', (int)$taskId)->get($this->table);
|
||||
return $query->num_rows() > 0 ? $query->row() : null;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni queue get task error: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tasks by entity
|
||||
*
|
||||
* @param string $entityType Entity type
|
||||
* @param int $entityId Entity ID
|
||||
* @param string $status Optional status filter
|
||||
* @return array Array of task objects
|
||||
*/
|
||||
public function getTasksByEntity($entityType, $entityId, $status = null)
|
||||
{
|
||||
try {
|
||||
$this->db->where('entity_type', $entityType)
|
||||
->where('entity_id', (int)$entityId);
|
||||
|
||||
if ($status !== null) {
|
||||
$this->db->where('status', $status);
|
||||
}
|
||||
|
||||
$query = $this->db->order_by('created_at', 'DESC')->get($this->table);
|
||||
|
||||
return $query->result();
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni queue get tasks by entity error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel pending task
|
||||
*
|
||||
* @param int $taskId Task ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function cancelTask($taskId)
|
||||
{
|
||||
try {
|
||||
$result = $this->db->where('id', (int)$taskId)
|
||||
->where('status', 'pending')
|
||||
->delete($this->table);
|
||||
|
||||
if ($result && $this->db->affected_rows() > 0) {
|
||||
$this->logDatabaseOperation('delete', $this->table, ['id' => $taskId], $taskId);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni queue cancel task error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old completed/failed tasks
|
||||
*
|
||||
* @param int $olderThanDays Delete tasks older than X days
|
||||
* @return int Number of tasks deleted
|
||||
*/
|
||||
public function cleanupOldTasks($olderThanDays = 30)
|
||||
{
|
||||
try {
|
||||
$cutoffDate = date('Y-m-d H:i:s', strtotime("-{$olderThanDays} days"));
|
||||
|
||||
$result = $this->db->where_in('status', ['completed', 'failed'])
|
||||
->where('completed_at <', $cutoffDate)
|
||||
->delete($this->table);
|
||||
|
||||
return $this->db->affected_rows();
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni queue cleanup error: ' . $e->getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue statistics
|
||||
*
|
||||
* @return array Statistics array
|
||||
*/
|
||||
public function getStatistics()
|
||||
{
|
||||
try {
|
||||
$stats = [];
|
||||
|
||||
// By status
|
||||
foreach ($this->validStatuses as $status) {
|
||||
$stats['by_status'][$status] = $this->db->where('status', $status)
|
||||
->count_all_results($this->table);
|
||||
}
|
||||
|
||||
// By task type
|
||||
foreach ($this->validTaskTypes as $taskType) {
|
||||
$stats['by_task_type'][$taskType] = $this->db->where('task_type', $taskType)
|
||||
->count_all_results($this->table);
|
||||
}
|
||||
|
||||
// By entity type
|
||||
foreach ($this->validEntityTypes as $entityType) {
|
||||
$stats['by_entity_type'][$entityType] = $this->db->where('entity_type', $entityType)
|
||||
->count_all_results($this->table);
|
||||
}
|
||||
|
||||
// Processing times (average for completed tasks in last 24 hours)
|
||||
$yesterday = date('Y-m-d H:i:s', strtotime('-24 hours'));
|
||||
$query = $this->db->select('AVG(TIMESTAMPDIFF(SECOND, started_at, completed_at)) as avg_processing_time')
|
||||
->where('status', 'completed')
|
||||
->where('completed_at >', $yesterday)
|
||||
->where('started_at IS NOT NULL')
|
||||
->get($this->table);
|
||||
|
||||
$stats['avg_processing_time_seconds'] = $query->row()->avg_processing_time ?: 0;
|
||||
|
||||
// Failed tasks in last 24 hours
|
||||
$stats['failed_24h'] = $this->db->where('status', 'failed')
|
||||
->where('completed_at >', $yesterday)
|
||||
->count_all_results($this->table);
|
||||
|
||||
return $stats;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni queue statistics error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if entity has pending task of specific type
|
||||
*
|
||||
* @param string $entityType Entity type
|
||||
* @param int $entityId Entity ID
|
||||
* @param string $taskType Task type
|
||||
* @return bool True if pending task exists
|
||||
*/
|
||||
public function hasPendingTask($entityType, $entityId, $taskType)
|
||||
{
|
||||
try {
|
||||
$count = $this->db->where('entity_type', $entityType)
|
||||
->where('entity_id', (int)$entityId)
|
||||
->where('task_type', $taskType)
|
||||
->where('status', 'pending')
|
||||
->count_all_results($this->table);
|
||||
|
||||
return $count > 0;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni queue has pending task error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update task priority
|
||||
*
|
||||
* @param int $taskId Task ID
|
||||
* @param int $priority New priority (1-9)
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function updatePriority($taskId, $priority)
|
||||
{
|
||||
try {
|
||||
$priority = max($this->minPriority, min($this->maxPriority, (int)$priority));
|
||||
|
||||
$data = [
|
||||
'priority' => $priority,
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
$result = $this->db->where('id', (int)$taskId)
|
||||
->where('status', 'pending') // Only update pending tasks
|
||||
->update($this->table, $data);
|
||||
|
||||
if ($result && $this->db->affected_rows() > 0) {
|
||||
$this->logDatabaseOperation('update', $this->table, $data, $taskId);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni queue update priority error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate task data
|
||||
*
|
||||
* @param array $data Task data to validate
|
||||
* @return array Validation errors
|
||||
*/
|
||||
private function validateTaskData($data)
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
// Required fields
|
||||
$requiredFields = ['entity_type', 'entity_id', 'action'];
|
||||
$errors = array_merge($errors, $this->validateRequiredFields($data, $requiredFields));
|
||||
|
||||
// Task type validation (database schema)
|
||||
if (isset($data['task_type']) && !$this->validateEnum($data['task_type'], $this->validTaskTypes)) {
|
||||
$errors[] = 'Invalid task type. Must be one of: ' . implode(', ', $this->validTaskTypes);
|
||||
}
|
||||
|
||||
// Entity type validation
|
||||
if (isset($data['entity_type']) && !$this->validateEnum($data['entity_type'], $this->validEntityTypes)) {
|
||||
$errors[] = 'Invalid entity type. Must be one of: ' . implode(', ', $this->validEntityTypes);
|
||||
}
|
||||
|
||||
// Status validation
|
||||
if (isset($data['status']) && !$this->validateEnum($data['status'], $this->validStatuses)) {
|
||||
$errors[] = 'Invalid status. Must be one of: ' . implode(', ', $this->validStatuses);
|
||||
}
|
||||
|
||||
// Priority validation
|
||||
if (isset($data['priority'])) {
|
||||
$priority = (int)$data['priority'];
|
||||
if ($priority < $this->minPriority || $priority > $this->maxPriority) {
|
||||
$errors[] = "Priority must be between {$this->minPriority} and {$this->maxPriority}";
|
||||
}
|
||||
}
|
||||
|
||||
// Entity ID validation
|
||||
if (isset($data['entity_id']) && (!is_numeric($data['entity_id']) || (int)$data['entity_id'] <= 0)) {
|
||||
$errors[] = 'Entity ID must be a positive integer';
|
||||
}
|
||||
|
||||
// JSON payload validation
|
||||
if (isset($data['payload']) && !$this->validateJSON($data['payload'])) {
|
||||
$errors[] = 'Payload must be valid JSON';
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get valid task types
|
||||
*
|
||||
* @return array Valid task types
|
||||
*/
|
||||
public function getValidTaskTypes()
|
||||
{
|
||||
return $this->validTaskTypes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get valid entity types
|
||||
*
|
||||
* @return array Valid entity types
|
||||
*/
|
||||
public function getValidEntityTypes()
|
||||
{
|
||||
return $this->validEntityTypes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get valid status values
|
||||
*
|
||||
* @return array Valid status values
|
||||
*/
|
||||
public function getValidStatuses()
|
||||
{
|
||||
return $this->validStatuses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map task type to database action
|
||||
*
|
||||
* @param string $taskType Task type from API/tests
|
||||
* @return string Database action
|
||||
*/
|
||||
/**
|
||||
* Clamp numeric priority to valid range (1..9)
|
||||
*/
|
||||
private function clampPriority($priority)
|
||||
{
|
||||
return max($this->minPriority, min($this->maxPriority, (int)$priority));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of items by status
|
||||
*
|
||||
* @param array $filters Filter criteria
|
||||
* @return int Count of items
|
||||
*/
|
||||
public function get_count($filters = [])
|
||||
{
|
||||
try {
|
||||
// Check if table exists first
|
||||
if (!$this->db->table_exists($this->table)) {
|
||||
log_message('info', 'Desk-Moloni sync queue table does not exist yet');
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Reset any previous query builder state
|
||||
$this->db->reset_query();
|
||||
|
||||
$this->db->from($this->table);
|
||||
|
||||
if (isset($filters['status'])) {
|
||||
$this->db->where('status', $filters['status']);
|
||||
}
|
||||
|
||||
if (isset($filters['entity_type'])) {
|
||||
$this->db->where('entity_type', $filters['entity_type']);
|
||||
}
|
||||
|
||||
if (isset($filters['priority'])) {
|
||||
$this->db->where('priority', $filters['priority']);
|
||||
}
|
||||
|
||||
return $this->db->count_all_results();
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni queue get_count error: ' . $e->getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue summary for dashboard
|
||||
*
|
||||
* @return array Queue summary statistics
|
||||
*/
|
||||
public function get_queue_summary()
|
||||
{
|
||||
try {
|
||||
$summary = [];
|
||||
|
||||
// Get counts by status
|
||||
foreach (['pending', 'processing', 'completed', 'failed'] as $status) {
|
||||
$summary[$status] = $this->get_count(['status' => $status]);
|
||||
}
|
||||
|
||||
// Get total items
|
||||
$summary['total'] = array_sum($summary);
|
||||
|
||||
// Get recent activity (last 24 hours)
|
||||
$this->db->reset_query();
|
||||
$this->db->from($this->table);
|
||||
$this->db->where('created_at >=', date('Y-m-d H:i:s', strtotime('-24 hours')));
|
||||
$summary['recent_24h'] = $this->db->count_all_results();
|
||||
|
||||
return $summary;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Desk-Moloni queue summary error: ' . $e->getMessage());
|
||||
return [
|
||||
'pending' => 0,
|
||||
'processing' => 0,
|
||||
'completed' => 0,
|
||||
'failed' => 0,
|
||||
'total' => 0,
|
||||
'recent_24h' => 0
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
0
modules/desk_moloni/models/index.html
Normal file
0
modules/desk_moloni/models/index.html
Normal file
Reference in New Issue
Block a user