- 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.
721 lines
23 KiB
PHP
721 lines
23 KiB
PHP
<?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
|
|
];
|
|
}
|
|
}
|
|
} |