- Added GitHub spec-kit for development workflow - Standardized file signatures to Descomplicar® format - Updated development configuration 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
498 lines
17 KiB
PHP
498 lines
17 KiB
PHP
/**
|
|
* Descomplicar® Crescimento Digital
|
|
* https://descomplicar.pt
|
|
*/
|
|
|
|
<?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();
|
|
}
|
|
} |