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

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

464 lines
14 KiB
PHP

<?php
defined('BASEPATH') or exit('No direct script access allowed');
/**
* Entity Mapping Service
* Handles mapping and relationship management between Perfex CRM and Moloni ERP entities
*
* @package DeskMoloni
* @subpackage Libraries
* @category EntityMapping
* @author Descomplicar® - PHP Fullstack Engineer
* @version 1.0.0
*/
namespace DeskMoloni\Libraries;
class EntityMappingService
{
protected $CI;
protected $model;
// Entity types supported
const ENTITY_CUSTOMER = 'customer';
const ENTITY_PRODUCT = 'product';
const ENTITY_INVOICE = 'invoice';
const ENTITY_ESTIMATE = 'estimate';
const ENTITY_CREDIT_NOTE = 'credit_note';
// Mapping status constants
const STATUS_PENDING = 'pending';
const STATUS_SYNCED = 'synced';
const STATUS_ERROR = 'error';
const STATUS_CONFLICT = 'conflict';
// Sync directions
const DIRECTION_PERFEX_TO_MOLONI = 'perfex_to_moloni';
const DIRECTION_MOLONI_TO_PERFEX = 'moloni_to_perfex';
const DIRECTION_BIDIRECTIONAL = 'bidirectional';
public function __construct()
{
$this->CI = &get_instance();
$this->CI->load->model('desk_moloni_model');
$this->model = $this->CI->desk_moloni_model;
log_activity('EntityMappingService initialized');
}
/**
* Create entity mapping
*
* @param string $entity_type
* @param int $perfex_id
* @param int $moloni_id
* @param string $sync_direction
* @param array $metadata
* @return int|false
*/
public function create_mapping($entity_type, $perfex_id, $moloni_id, $sync_direction = self::DIRECTION_BIDIRECTIONAL, $metadata = [])
{
if (!$this->is_valid_entity_type($entity_type)) {
throw new \InvalidArgumentException("Invalid entity type: {$entity_type}");
}
// Check for existing mapping
$existing = $this->get_mapping($entity_type, $perfex_id, $moloni_id);
if ($existing) {
throw new \Exception("Mapping already exists with ID: {$existing->id}");
}
$mapping_data = [
'entity_type' => $entity_type,
'perfex_id' => $perfex_id,
'moloni_id' => $moloni_id,
'sync_direction' => $sync_direction,
'sync_status' => self::STATUS_PENDING,
'metadata' => json_encode($metadata),
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
];
$mapping_id = $this->model->create_entity_mapping($mapping_data);
if ($mapping_id) {
log_activity("Created {$entity_type} mapping: Perfex #{$perfex_id} <-> Moloni #{$moloni_id}");
}
return $mapping_id;
}
/**
* Update entity mapping
*
* @param int $mapping_id
* @param array $data
* @return bool
*/
public function update_mapping($mapping_id, $data)
{
$data['updated_at'] = date('Y-m-d H:i:s');
$result = $this->model->update_entity_mapping($mapping_id, $data);
if ($result) {
log_activity("Updated entity mapping #{$mapping_id}");
}
return $result;
}
/**
* Get entity mapping by IDs
*
* @param string $entity_type
* @param int $perfex_id
* @param int $moloni_id
* @return object|null
*/
public function get_mapping($entity_type, $perfex_id = null, $moloni_id = null)
{
if (!$perfex_id && !$moloni_id) {
throw new \InvalidArgumentException("Either perfex_id or moloni_id must be provided");
}
return $this->model->get_entity_mapping($entity_type, $perfex_id, $moloni_id);
}
/**
* Get mapping by Perfex ID
*
* @param string $entity_type
* @param int $perfex_id
* @return object|null
*/
public function get_mapping_by_perfex_id($entity_type, $perfex_id)
{
return $this->model->get_entity_mapping_by_perfex_id($entity_type, $perfex_id);
}
/**
* Get mapping by Moloni ID
*
* @param string $entity_type
* @param int $moloni_id
* @return object|null
*/
public function get_mapping_by_moloni_id($entity_type, $moloni_id)
{
return $this->model->get_entity_mapping_by_moloni_id($entity_type, $moloni_id);
}
/**
* Delete entity mapping
*
* @param int $mapping_id
* @return bool
*/
public function delete_mapping($mapping_id)
{
$mapping = $this->model->get_entity_mapping_by_id($mapping_id);
if (!$mapping) {
return false;
}
$result = $this->model->delete_entity_mapping($mapping_id);
if ($result) {
log_activity("Deleted {$mapping->entity_type} mapping #{$mapping_id}");
}
return $result;
}
/**
* Get all mappings for entity type
*
* @param string $entity_type
* @param array $filters
* @return array
*/
public function get_mappings_by_type($entity_type, $filters = [])
{
if (!$this->is_valid_entity_type($entity_type)) {
throw new \InvalidArgumentException("Invalid entity type: {$entity_type}");
}
return $this->model->get_entity_mappings_by_type($entity_type, $filters);
}
/**
* Update mapping status
*
* @param int $mapping_id
* @param string $status
* @param string $error_message
* @return bool
*/
public function update_mapping_status($mapping_id, $status, $error_message = null)
{
if (!in_array($status, [self::STATUS_PENDING, self::STATUS_SYNCED, self::STATUS_ERROR, self::STATUS_CONFLICT])) {
throw new \InvalidArgumentException("Invalid status: {$status}");
}
$data = [
'sync_status' => $status,
'error_message' => $error_message,
'last_sync_at' => date('Y-m-d H:i:s')
];
return $this->update_mapping($mapping_id, $data);
}
/**
* Update sync timestamps
*
* @param int $mapping_id
* @param string $direction
* @return bool
*/
public function update_sync_timestamp($mapping_id, $direction)
{
$field = $direction === self::DIRECTION_PERFEX_TO_MOLONI ? 'last_sync_perfex' : 'last_sync_moloni';
return $this->update_mapping($mapping_id, [
$field => date('Y-m-d H:i:s'),
'sync_status' => self::STATUS_SYNCED
]);
}
/**
* Check if entity is already mapped
*
* @param string $entity_type
* @param int $perfex_id
* @param int $moloni_id
* @return bool
*/
public function is_mapped($entity_type, $perfex_id = null, $moloni_id = null)
{
return $this->get_mapping($entity_type, $perfex_id, $moloni_id) !== null;
}
/**
* Get unmapped entities
*
* @param string $entity_type
* @param string $source_system ('perfex' or 'moloni')
* @param int $limit
* @return array
*/
public function get_unmapped_entities($entity_type, $source_system, $limit = 100)
{
if (!$this->is_valid_entity_type($entity_type)) {
throw new \InvalidArgumentException("Invalid entity type: {$entity_type}");
}
if (!in_array($source_system, ['perfex', 'moloni'])) {
throw new \InvalidArgumentException("Invalid source system: {$source_system}");
}
return $this->model->get_unmapped_entities($entity_type, $source_system, $limit);
}
/**
* Get mapping statistics
*
* @param string $entity_type
* @return array
*/
public function get_mapping_statistics($entity_type = null)
{
return $this->model->get_mapping_statistics($entity_type);
}
/**
* Find potential matches between systems
*
* @param string $entity_type
* @param array $search_criteria
* @param string $target_system
* @return array
*/
public function find_potential_matches($entity_type, $search_criteria, $target_system)
{
if (!$this->is_valid_entity_type($entity_type)) {
throw new \InvalidArgumentException("Invalid entity type: {$entity_type}");
}
// This will be implemented by specific sync services
// Return format: [['id' => X, 'match_score' => Y, 'match_criteria' => []], ...]
return [];
}
/**
* Resolve mapping conflicts
*
* @param int $mapping_id
* @param string $resolution ('keep_perfex', 'keep_moloni', 'merge')
* @param array $merge_data
* @return bool
*/
public function resolve_conflict($mapping_id, $resolution, $merge_data = [])
{
$mapping = $this->model->get_entity_mapping_by_id($mapping_id);
if (!$mapping || $mapping->sync_status !== self::STATUS_CONFLICT) {
throw new \Exception("Mapping not found or not in conflict state");
}
switch ($resolution) {
case 'keep_perfex':
return $this->update_mapping_status($mapping_id, self::STATUS_SYNCED);
case 'keep_moloni':
return $this->update_mapping_status($mapping_id, self::STATUS_SYNCED);
case 'merge':
// Store merge data for processing by sync services
$metadata = json_decode($mapping->metadata, true) ?: [];
$metadata['merge_data'] = $merge_data;
$metadata['resolution'] = 'merge';
return $this->update_mapping($mapping_id, [
'sync_status' => self::STATUS_PENDING,
'metadata' => json_encode($metadata)
]);
default:
throw new \InvalidArgumentException("Invalid resolution: {$resolution}");
}
}
/**
* Bulk create mappings
*
* @param array $mappings
* @return array
*/
public function bulk_create_mappings($mappings)
{
$results = [
'total' => count($mappings),
'success' => 0,
'errors' => 0,
'details' => []
];
foreach ($mappings as $mapping) {
try {
$mapping_id = $this->create_mapping(
$mapping['entity_type'],
$mapping['perfex_id'],
$mapping['moloni_id'],
$mapping['sync_direction'] ?? self::DIRECTION_BIDIRECTIONAL,
$mapping['metadata'] ?? []
);
$results['success']++;
$results['details'][] = [
'mapping_id' => $mapping_id,
'success' => true
];
} catch (\Exception $e) {
$results['errors']++;
$results['details'][] = [
'error' => $e->getMessage(),
'success' => false,
'data' => $mapping
];
}
}
return $results;
}
/**
* Clean up old mappings
*
* @param string $entity_type
* @param int $retention_days
* @return int
*/
public function cleanup_old_mappings($entity_type, $retention_days = 90)
{
$cutoff_date = date('Y-m-d H:i:s', strtotime("-{$retention_days} days"));
$deleted = $this->model->cleanup_old_mappings($entity_type, $cutoff_date);
if ($deleted > 0) {
log_activity("Cleaned up {$deleted} old {$entity_type} mappings older than {$retention_days} days");
}
return $deleted;
}
/**
* Validate entity type
*
* @param string $entity_type
* @return bool
*/
protected function is_valid_entity_type($entity_type)
{
return in_array($entity_type, [
self::ENTITY_CUSTOMER,
self::ENTITY_PRODUCT,
self::ENTITY_INVOICE,
self::ENTITY_ESTIMATE,
self::ENTITY_CREDIT_NOTE
]);
}
/**
* Export mappings to CSV
*
* @param string $entity_type
* @param array $filters
* @return string
*/
public function export_mappings_csv($entity_type, $filters = [])
{
$mappings = $this->get_mappings_by_type($entity_type, $filters);
$output = fopen('php://temp', 'r+');
// CSV Header
fputcsv($output, [
'ID',
'Entity Type',
'Perfex ID',
'Moloni ID',
'Sync Direction',
'Sync Status',
'Last Sync Perfex',
'Last Sync Moloni',
'Created At',
'Updated At'
]);
foreach ($mappings as $mapping) {
fputcsv($output, [
$mapping->id,
$mapping->entity_type,
$mapping->perfex_id,
$mapping->moloni_id,
$mapping->sync_direction,
$mapping->sync_status,
$mapping->last_sync_perfex,
$mapping->last_sync_moloni,
$mapping->created_at,
$mapping->updated_at
]);
}
rewind($output);
$csv_content = stream_get_contents($output);
fclose($output);
return $csv_content;
}
}