- Mehrfach-Upload von PDF-Kontoauszügen mit automatischer Metadaten-Erkennung - Dashboard mit Übersichts-Widgets (letzte Buchungen und Kontoauszüge) - Menü-Integration unter "Banken und Kasse" statt eigenem Top-Menü - Erinnerungsfunktion bei veralteten Kontoauszügen (konfigurierbar) - Verknüpfung von Buchungen mit PDF-Kontoauszügen - Auszugsnummer wird automatisch aus dem Zeitraum abgeleitet (Monat/Jahr) - Jahrfilter zeigt nur Jahre mit vorhandenen Kontoauszügen - Modul-Icon auf fa-money-check-alt gesetzt - README und ChangeLog aktualisiert - .gitignore für Kontoauszüge und Build-Artefakte hinzugefügt Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1068 lines
29 KiB
PHP
Executable file
1068 lines
29 KiB
PHP
Executable file
<?php
|
|
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*/
|
|
|
|
/**
|
|
* \file bankimport/class/banktransaction.class.php
|
|
* \ingroup bankimport
|
|
* \brief Class for bank transactions from FinTS import
|
|
*/
|
|
|
|
require_once DOL_DOCUMENT_ROOT.'/core/class/commonobject.class.php';
|
|
|
|
/**
|
|
* Class BankImportTransaction
|
|
* Represents a bank transaction imported via FinTS
|
|
*/
|
|
class BankImportTransaction extends CommonObject
|
|
{
|
|
/**
|
|
* @var string ID to identify managed object
|
|
*/
|
|
public $element = 'banktransaction';
|
|
|
|
/**
|
|
* @var string Name of table without prefix where object is stored
|
|
*/
|
|
public $table_element = 'bankimport_transaction';
|
|
|
|
/**
|
|
* @var string Primary key name
|
|
*/
|
|
public $pk = 'rowid';
|
|
|
|
/**
|
|
* @var int Entity
|
|
*/
|
|
public $entity;
|
|
|
|
/**
|
|
* @var string Unique reference (hash)
|
|
*/
|
|
public $ref;
|
|
|
|
/**
|
|
* @var string IBAN
|
|
*/
|
|
public $iban;
|
|
|
|
/**
|
|
* @var string BIC
|
|
*/
|
|
public $bic;
|
|
|
|
/**
|
|
* @var int Creation timestamp
|
|
*/
|
|
public $datec;
|
|
|
|
/**
|
|
* @var int Transaction date
|
|
*/
|
|
public $date_trans;
|
|
|
|
/**
|
|
* @var int Value date
|
|
*/
|
|
public $date_value;
|
|
|
|
/**
|
|
* @var string Counterparty name
|
|
*/
|
|
public $name;
|
|
|
|
/**
|
|
* @var string Counterparty IBAN
|
|
*/
|
|
public $counterparty_iban;
|
|
|
|
/**
|
|
* @var string Counterparty BIC
|
|
*/
|
|
public $counterparty_bic;
|
|
|
|
/**
|
|
* @var float Amount
|
|
*/
|
|
public $amount;
|
|
|
|
/**
|
|
* @var string Currency
|
|
*/
|
|
public $currency = 'EUR';
|
|
|
|
/**
|
|
* @var string Short label
|
|
*/
|
|
public $label;
|
|
|
|
/**
|
|
* @var string Full description
|
|
*/
|
|
public $description;
|
|
|
|
/**
|
|
* @var string End-to-end ID
|
|
*/
|
|
public $end_to_end_id;
|
|
|
|
/**
|
|
* @var string Mandate ID
|
|
*/
|
|
public $mandate_id;
|
|
|
|
/**
|
|
* @var int Link to llx_bank
|
|
*/
|
|
public $fk_bank;
|
|
|
|
/**
|
|
* @var int Link to llx_facture
|
|
*/
|
|
public $fk_facture;
|
|
|
|
/**
|
|
* @var int Link to llx_facture_fourn
|
|
*/
|
|
public $fk_facture_fourn;
|
|
|
|
/**
|
|
* @var int Link to llx_paiement
|
|
*/
|
|
public $fk_paiement;
|
|
|
|
/**
|
|
* @var int Link to llx_paiementfourn
|
|
*/
|
|
public $fk_paiementfourn;
|
|
|
|
/**
|
|
* @var int Link to llx_salary
|
|
*/
|
|
public $fk_salary;
|
|
|
|
/**
|
|
* @var int Link to llx_don
|
|
*/
|
|
public $fk_don;
|
|
|
|
/**
|
|
* @var int Link to llx_loan
|
|
*/
|
|
public $fk_loan;
|
|
|
|
/**
|
|
* @var int Link to llx_societe
|
|
*/
|
|
public $fk_societe;
|
|
|
|
/**
|
|
* @var int Link to llx_bankimport_statement
|
|
*/
|
|
public $fk_statement;
|
|
|
|
/**
|
|
* @var int Status (0=new, 1=matched, 2=reconciled, 9=ignored)
|
|
*/
|
|
public $status = 0;
|
|
|
|
/**
|
|
* @var string Import batch key
|
|
*/
|
|
public $import_key;
|
|
|
|
/**
|
|
* @var int User who created
|
|
*/
|
|
public $fk_user_creat;
|
|
|
|
/**
|
|
* @var int User who modified
|
|
*/
|
|
public $fk_user_modif;
|
|
|
|
/**
|
|
* @var int User who matched
|
|
*/
|
|
public $fk_user_match;
|
|
|
|
/**
|
|
* @var int Match date
|
|
*/
|
|
public $date_match;
|
|
|
|
/**
|
|
* @var string Private note
|
|
*/
|
|
public $note_private;
|
|
|
|
/**
|
|
* @var string Public note
|
|
*/
|
|
public $note_public;
|
|
|
|
// Status constants
|
|
const STATUS_NEW = 0;
|
|
const STATUS_MATCHED = 1;
|
|
const STATUS_RECONCILED = 2;
|
|
const STATUS_IGNORED = 9;
|
|
|
|
/**
|
|
* Constructor
|
|
*
|
|
* @param DoliDB $db Database handler
|
|
*/
|
|
public function __construct($db)
|
|
{
|
|
global $conf;
|
|
|
|
$this->db = $db;
|
|
$this->entity = $conf->entity;
|
|
}
|
|
|
|
/**
|
|
* Create transaction in database
|
|
*
|
|
* @param User $user User that creates
|
|
* @param bool $notrigger Disable triggers
|
|
* @return int <0 if KO, Id of created object if OK
|
|
*/
|
|
public function create($user, $notrigger = false)
|
|
{
|
|
global $conf;
|
|
|
|
$error = 0;
|
|
$now = dol_now();
|
|
|
|
// Generate reference hash if not set
|
|
if (empty($this->ref)) {
|
|
$this->ref = $this->generateRef();
|
|
}
|
|
|
|
$this->db->begin();
|
|
|
|
$sql = "INSERT INTO ".MAIN_DB_PREFIX."bankimport_transaction (";
|
|
$sql .= "ref, entity, iban, bic, datec, date_trans, date_value,";
|
|
$sql .= "name, counterparty_iban, counterparty_bic,";
|
|
$sql .= "amount, currency, label, description, end_to_end_id, mandate_id,";
|
|
$sql .= "status, import_key, fk_user_creat";
|
|
$sql .= ") VALUES (";
|
|
$sql .= "'".$this->db->escape($this->ref)."',";
|
|
$sql .= ((int) $this->entity).",";
|
|
$sql .= ($this->iban ? "'".$this->db->escape($this->iban)."'" : "NULL").",";
|
|
$sql .= ($this->bic ? "'".$this->db->escape($this->bic)."'" : "NULL").",";
|
|
$sql .= "'".$this->db->idate($now)."',";
|
|
$sql .= "'".$this->db->idate($this->date_trans)."',";
|
|
$sql .= ($this->date_value ? "'".$this->db->idate($this->date_value)."'" : "NULL").",";
|
|
$sql .= ($this->name ? "'".$this->db->escape($this->name)."'" : "NULL").",";
|
|
$sql .= ($this->counterparty_iban ? "'".$this->db->escape($this->counterparty_iban)."'" : "NULL").",";
|
|
$sql .= ($this->counterparty_bic ? "'".$this->db->escape($this->counterparty_bic)."'" : "NULL").",";
|
|
$sql .= ((float) $this->amount).",";
|
|
$sql .= "'".$this->db->escape($this->currency)."',";
|
|
$sql .= ($this->label ? "'".$this->db->escape($this->label)."'" : "NULL").",";
|
|
$sql .= ($this->description ? "'".$this->db->escape($this->description)."'" : "NULL").",";
|
|
$sql .= ($this->end_to_end_id ? "'".$this->db->escape($this->end_to_end_id)."'" : "NULL").",";
|
|
$sql .= ($this->mandate_id ? "'".$this->db->escape($this->mandate_id)."'" : "NULL").",";
|
|
$sql .= ((int) $this->status).",";
|
|
$sql .= ($this->import_key ? "'".$this->db->escape($this->import_key)."'" : "NULL").",";
|
|
$sql .= ((int) $user->id);
|
|
$sql .= ")";
|
|
|
|
dol_syslog(get_class($this)."::create", LOG_DEBUG);
|
|
$resql = $this->db->query($sql);
|
|
|
|
if ($resql) {
|
|
$this->id = $this->db->last_insert_id(MAIN_DB_PREFIX."bankimport_transaction");
|
|
$this->datec = $now;
|
|
$this->fk_user_creat = $user->id;
|
|
|
|
$this->db->commit();
|
|
return $this->id;
|
|
} else {
|
|
$this->error = $this->db->lasterror();
|
|
$this->db->rollback();
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load transaction from database
|
|
*
|
|
* @param int $id Id of transaction to load
|
|
* @param string $ref Reference
|
|
* @return int <0 if KO, 0 if not found, >0 if OK
|
|
*/
|
|
public function fetch($id, $ref = '')
|
|
{
|
|
$sql = "SELECT t.*";
|
|
$sql .= " FROM ".MAIN_DB_PREFIX."bankimport_transaction as t";
|
|
if ($id > 0) {
|
|
$sql .= " WHERE t.rowid = ".((int) $id);
|
|
} else {
|
|
$sql .= " WHERE t.ref = '".$this->db->escape($ref)."' AND t.entity = ".((int) $this->entity);
|
|
}
|
|
|
|
dol_syslog(get_class($this)."::fetch", LOG_DEBUG);
|
|
$resql = $this->db->query($sql);
|
|
|
|
if ($resql) {
|
|
if ($this->db->num_rows($resql)) {
|
|
$obj = $this->db->fetch_object($resql);
|
|
|
|
$this->id = $obj->rowid;
|
|
$this->ref = $obj->ref;
|
|
$this->entity = $obj->entity;
|
|
$this->iban = $obj->iban;
|
|
$this->bic = $obj->bic;
|
|
$this->datec = $this->db->jdate($obj->datec);
|
|
$this->date_trans = $this->db->jdate($obj->date_trans);
|
|
$this->date_value = $this->db->jdate($obj->date_value);
|
|
$this->name = $obj->name;
|
|
$this->counterparty_iban = $obj->counterparty_iban;
|
|
$this->counterparty_bic = $obj->counterparty_bic;
|
|
$this->amount = $obj->amount;
|
|
$this->currency = $obj->currency;
|
|
$this->label = $obj->label;
|
|
$this->description = $obj->description;
|
|
$this->end_to_end_id = $obj->end_to_end_id;
|
|
$this->mandate_id = $obj->mandate_id;
|
|
$this->fk_bank = $obj->fk_bank;
|
|
$this->fk_facture = $obj->fk_facture;
|
|
$this->fk_facture_fourn = $obj->fk_facture_fourn;
|
|
$this->fk_paiement = $obj->fk_paiement;
|
|
$this->fk_paiementfourn = $obj->fk_paiementfourn;
|
|
$this->fk_salary = $obj->fk_salary;
|
|
$this->fk_don = $obj->fk_don;
|
|
$this->fk_loan = $obj->fk_loan;
|
|
$this->fk_societe = $obj->fk_societe;
|
|
$this->fk_statement = $obj->fk_statement;
|
|
$this->status = $obj->status;
|
|
$this->import_key = $obj->import_key;
|
|
$this->fk_user_creat = $obj->fk_user_creat;
|
|
$this->fk_user_modif = $obj->fk_user_modif;
|
|
$this->fk_user_match = $obj->fk_user_match;
|
|
$this->date_match = $this->db->jdate($obj->date_match);
|
|
$this->note_private = $obj->note_private;
|
|
$this->note_public = $obj->note_public;
|
|
|
|
$this->db->free($resql);
|
|
return 1;
|
|
} else {
|
|
$this->db->free($resql);
|
|
return 0;
|
|
}
|
|
} else {
|
|
$this->error = $this->db->lasterror();
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update transaction in database
|
|
*
|
|
* @param User $user User that modifies
|
|
* @param bool $notrigger Disable triggers
|
|
* @return int <0 if KO, >0 if OK
|
|
*/
|
|
public function update($user, $notrigger = false)
|
|
{
|
|
$error = 0;
|
|
|
|
$this->db->begin();
|
|
|
|
$sql = "UPDATE ".MAIN_DB_PREFIX."bankimport_transaction SET";
|
|
$sql .= " iban = ".($this->iban ? "'".$this->db->escape($this->iban)."'" : "NULL").",";
|
|
$sql .= " bic = ".($this->bic ? "'".$this->db->escape($this->bic)."'" : "NULL").",";
|
|
$sql .= " date_trans = '".$this->db->idate($this->date_trans)."',";
|
|
$sql .= " date_value = ".($this->date_value ? "'".$this->db->idate($this->date_value)."'" : "NULL").",";
|
|
$sql .= " name = ".($this->name ? "'".$this->db->escape($this->name)."'" : "NULL").",";
|
|
$sql .= " counterparty_iban = ".($this->counterparty_iban ? "'".$this->db->escape($this->counterparty_iban)."'" : "NULL").",";
|
|
$sql .= " counterparty_bic = ".($this->counterparty_bic ? "'".$this->db->escape($this->counterparty_bic)."'" : "NULL").",";
|
|
$sql .= " amount = ".((float) $this->amount).",";
|
|
$sql .= " currency = '".$this->db->escape($this->currency)."',";
|
|
$sql .= " label = ".($this->label ? "'".$this->db->escape($this->label)."'" : "NULL").",";
|
|
$sql .= " description = ".($this->description ? "'".$this->db->escape($this->description)."'" : "NULL").",";
|
|
$sql .= " end_to_end_id = ".($this->end_to_end_id ? "'".$this->db->escape($this->end_to_end_id)."'" : "NULL").",";
|
|
$sql .= " mandate_id = ".($this->mandate_id ? "'".$this->db->escape($this->mandate_id)."'" : "NULL").",";
|
|
$sql .= " fk_bank = ".($this->fk_bank > 0 ? ((int) $this->fk_bank) : "NULL").",";
|
|
$sql .= " fk_facture = ".($this->fk_facture > 0 ? ((int) $this->fk_facture) : "NULL").",";
|
|
$sql .= " fk_facture_fourn = ".($this->fk_facture_fourn > 0 ? ((int) $this->fk_facture_fourn) : "NULL").",";
|
|
$sql .= " fk_paiement = ".($this->fk_paiement > 0 ? ((int) $this->fk_paiement) : "NULL").",";
|
|
$sql .= " fk_paiementfourn = ".($this->fk_paiementfourn > 0 ? ((int) $this->fk_paiementfourn) : "NULL").",";
|
|
$sql .= " fk_salary = ".($this->fk_salary > 0 ? ((int) $this->fk_salary) : "NULL").",";
|
|
$sql .= " fk_don = ".($this->fk_don > 0 ? ((int) $this->fk_don) : "NULL").",";
|
|
$sql .= " fk_loan = ".($this->fk_loan > 0 ? ((int) $this->fk_loan) : "NULL").",";
|
|
$sql .= " fk_societe = ".($this->fk_societe > 0 ? ((int) $this->fk_societe) : "NULL").",";
|
|
$sql .= " fk_statement = ".($this->fk_statement > 0 ? ((int) $this->fk_statement) : "NULL").",";
|
|
$sql .= " status = ".((int) $this->status).",";
|
|
$sql .= " fk_user_modif = ".((int) $user->id).",";
|
|
$sql .= " note_private = ".($this->note_private ? "'".$this->db->escape($this->note_private)."'" : "NULL").",";
|
|
$sql .= " note_public = ".($this->note_public ? "'".$this->db->escape($this->note_public)."'" : "NULL");
|
|
$sql .= " WHERE rowid = ".((int) $this->id);
|
|
|
|
dol_syslog(get_class($this)."::update", LOG_DEBUG);
|
|
$resql = $this->db->query($sql);
|
|
|
|
if ($resql) {
|
|
$this->fk_user_modif = $user->id;
|
|
$this->db->commit();
|
|
return 1;
|
|
} else {
|
|
$this->error = $this->db->lasterror();
|
|
$this->db->rollback();
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete transaction from database
|
|
*
|
|
* @param User $user User that deletes
|
|
* @param bool $notrigger Disable triggers
|
|
* @return int <0 if KO, >0 if OK
|
|
*/
|
|
public function delete($user, $notrigger = false)
|
|
{
|
|
$this->db->begin();
|
|
|
|
$sql = "DELETE FROM ".MAIN_DB_PREFIX."bankimport_transaction";
|
|
$sql .= " WHERE rowid = ".((int) $this->id);
|
|
|
|
dol_syslog(get_class($this)."::delete", LOG_DEBUG);
|
|
$resql = $this->db->query($sql);
|
|
|
|
if ($resql) {
|
|
$this->db->commit();
|
|
return 1;
|
|
} else {
|
|
$this->error = $this->db->lasterror();
|
|
$this->db->rollback();
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate unique reference hash
|
|
*
|
|
* @return string Reference hash
|
|
*/
|
|
public function generateRef()
|
|
{
|
|
// Create hash from: date + amount + name + description
|
|
$data = $this->date_trans . '|' . $this->amount . '|' . $this->name . '|' . $this->description;
|
|
return substr(md5($data), 0, 32);
|
|
}
|
|
|
|
/**
|
|
* Check if transaction already exists (duplicate detection)
|
|
*
|
|
* @return int 0 if not exists, rowid if exists
|
|
*/
|
|
public function exists()
|
|
{
|
|
$ref = $this->generateRef();
|
|
|
|
$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."bankimport_transaction";
|
|
$sql .= " WHERE ref = '".$this->db->escape($ref)."'";
|
|
$sql .= " AND entity = ".((int) $this->entity);
|
|
|
|
$resql = $this->db->query($sql);
|
|
if ($resql) {
|
|
if ($this->db->num_rows($resql) > 0) {
|
|
$obj = $this->db->fetch_object($resql);
|
|
return $obj->rowid;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Import transactions from FinTS fetch result
|
|
*
|
|
* @param array $transactions Array of transaction data from FinTS
|
|
* @param string $iban IBAN of the account
|
|
* @param User $user User doing the import
|
|
* @return array Array with 'imported' and 'skipped' counts
|
|
*/
|
|
public function importFromFinTS($transactions, $iban, $user)
|
|
{
|
|
$imported = 0;
|
|
$skipped = 0;
|
|
$errors = array();
|
|
|
|
$importKey = date('YmdHis') . '_' . $user->id;
|
|
|
|
foreach ($transactions as $tx) {
|
|
$trans = new BankImportTransaction($this->db);
|
|
$trans->iban = $iban;
|
|
$trans->date_trans = $tx['date'];
|
|
$trans->date_value = $tx['bookingDate'] ?? $tx['date'];
|
|
$trans->name = $tx['name'] ?? '';
|
|
$trans->counterparty_iban = $tx['iban'] ?? '';
|
|
$trans->counterparty_bic = $tx['bic'] ?? '';
|
|
$trans->amount = $tx['amount'];
|
|
$trans->currency = $tx['currency'] ?? 'EUR';
|
|
$trans->label = $tx['bookingText'] ?? '';
|
|
$trans->description = $tx['reference'] ?? '';
|
|
$trans->end_to_end_id = $tx['endToEndId'] ?? '';
|
|
$trans->import_key = $importKey;
|
|
$trans->status = self::STATUS_NEW;
|
|
|
|
// Check for duplicate
|
|
if ($trans->exists()) {
|
|
$skipped++;
|
|
continue;
|
|
}
|
|
|
|
// Create transaction
|
|
$result = $trans->create($user);
|
|
if ($result > 0) {
|
|
$imported++;
|
|
} else {
|
|
$errors[] = $trans->error;
|
|
}
|
|
}
|
|
|
|
return array(
|
|
'imported' => $imported,
|
|
'skipped' => $skipped,
|
|
'errors' => $errors,
|
|
'import_key' => $importKey
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Fetch list of transactions with filters
|
|
*
|
|
* @param string $sortfield Sort field
|
|
* @param string $sortorder Sort order (ASC/DESC)
|
|
* @param int $limit Limit
|
|
* @param int $offset Offset
|
|
* @param array $filter Filters array
|
|
* @param string $mode 'list' returns array of objects, 'count' returns total count
|
|
* @return array|int Array of transactions, count or -1 on error
|
|
*/
|
|
public function fetchAll($sortfield = 'date_trans', $sortorder = 'DESC', $limit = 0, $offset = 0, $filter = array(), $mode = 'list')
|
|
{
|
|
$sql = "SELECT t.rowid";
|
|
$sql .= " FROM ".MAIN_DB_PREFIX."bankimport_transaction as t";
|
|
$sql .= " WHERE t.entity = ".((int) $this->entity);
|
|
|
|
// Apply filters
|
|
if (!empty($filter['ref'])) {
|
|
$sql .= " AND t.ref LIKE '%".$this->db->escape($filter['ref'])."%'";
|
|
}
|
|
if (!empty($filter['name'])) {
|
|
$sql .= " AND t.name LIKE '%".$this->db->escape($filter['name'])."%'";
|
|
}
|
|
if (!empty($filter['description'])) {
|
|
$sql .= " AND (t.description LIKE '%".$this->db->escape($filter['description'])."%'";
|
|
$sql .= " OR t.label LIKE '%".$this->db->escape($filter['description'])."%')";
|
|
}
|
|
if (!empty($filter['amount_min'])) {
|
|
$sql .= " AND t.amount >= ".((float) $filter['amount_min']);
|
|
}
|
|
if (!empty($filter['amount_max'])) {
|
|
$sql .= " AND t.amount <= ".((float) $filter['amount_max']);
|
|
}
|
|
if (!empty($filter['date_from'])) {
|
|
$sql .= " AND t.date_trans >= '".$this->db->idate($filter['date_from'])."'";
|
|
}
|
|
if (!empty($filter['date_to'])) {
|
|
$sql .= " AND t.date_trans <= '".$this->db->idate($filter['date_to'])."'";
|
|
}
|
|
if (isset($filter['status']) && $filter['status'] !== '' && $filter['status'] >= 0) {
|
|
$sql .= " AND t.status = ".((int) $filter['status']);
|
|
}
|
|
if (!empty($filter['iban'])) {
|
|
$sql .= " AND t.iban LIKE '%".$this->db->escape($filter['iban'])."%'";
|
|
}
|
|
|
|
// Count mode - just return total count
|
|
if ($mode == 'count') {
|
|
$sqlcount = preg_replace('/SELECT t\.rowid/', 'SELECT COUNT(*) as total', $sql);
|
|
$resqlcount = $this->db->query($sqlcount);
|
|
if ($resqlcount) {
|
|
$objcount = $this->db->fetch_object($resqlcount);
|
|
return (int) $objcount->total;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
// Sort and limit
|
|
$sql .= $this->db->order($sortfield, $sortorder);
|
|
if ($limit > 0) {
|
|
$sql .= $this->db->plimit($limit, $offset);
|
|
}
|
|
|
|
dol_syslog(get_class($this)."::fetchAll", LOG_DEBUG);
|
|
$resql = $this->db->query($sql);
|
|
|
|
if ($resql) {
|
|
$result = array();
|
|
|
|
while ($obj = $this->db->fetch_object($resql)) {
|
|
$trans = new BankImportTransaction($this->db);
|
|
$trans->fetch($obj->rowid);
|
|
$result[] = $trans;
|
|
}
|
|
|
|
$this->db->free($resql);
|
|
return $result;
|
|
} else {
|
|
$this->error = $this->db->lasterror();
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get status label
|
|
*
|
|
* @param int $mode 0=long label, 1=short label, 2=Picto + short label, 3=Picto, 4=Picto + long label, 5=Short label + Picto, 6=Long label + Picto
|
|
* @return string Label
|
|
*/
|
|
public function getLibStatut($mode = 0)
|
|
{
|
|
return $this->LibStatut($this->status, $mode);
|
|
}
|
|
|
|
/**
|
|
* Get status label for a given status
|
|
*
|
|
* @param int $status Status value
|
|
* @param int $mode Mode
|
|
* @return string Label
|
|
*/
|
|
public function LibStatut($status, $mode = 0)
|
|
{
|
|
global $langs;
|
|
|
|
$statusLabels = array(
|
|
self::STATUS_NEW => array('short' => 'New', 'long' => 'NewTransaction', 'picto' => 'status0'),
|
|
self::STATUS_MATCHED => array('short' => 'Matched', 'long' => 'TransactionMatched', 'picto' => 'status4'),
|
|
self::STATUS_RECONCILED => array('short' => 'Reconciled', 'long' => 'TransactionReconciled', 'picto' => 'status6'),
|
|
self::STATUS_IGNORED => array('short' => 'Ignored', 'long' => 'TransactionIgnored', 'picto' => 'status5'),
|
|
);
|
|
|
|
$statusInfo = $statusLabels[$status] ?? array('short' => 'Unknown', 'long' => 'Unknown', 'picto' => 'status0');
|
|
|
|
if ($mode == 0) {
|
|
return $langs->trans($statusInfo['long']);
|
|
} elseif ($mode == 1) {
|
|
return $langs->trans($statusInfo['short']);
|
|
} elseif ($mode == 2) {
|
|
return img_picto($langs->trans($statusInfo['long']), $statusInfo['picto']).' '.$langs->trans($statusInfo['short']);
|
|
} elseif ($mode == 3) {
|
|
return img_picto($langs->trans($statusInfo['long']), $statusInfo['picto']);
|
|
} elseif ($mode == 4) {
|
|
return img_picto($langs->trans($statusInfo['long']), $statusInfo['picto']).' '.$langs->trans($statusInfo['long']);
|
|
} elseif ($mode == 5) {
|
|
return $langs->trans($statusInfo['short']).' '.img_picto($langs->trans($statusInfo['long']), $statusInfo['picto']);
|
|
} elseif ($mode == 6) {
|
|
return $langs->trans($statusInfo['long']).' '.img_picto($langs->trans($statusInfo['long']), $statusInfo['picto']);
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Set status of transaction
|
|
*
|
|
* @param int $status New status
|
|
* @param User $user User making the change
|
|
* @return int <0 if KO, >0 if OK
|
|
*/
|
|
public function setStatus($status, $user)
|
|
{
|
|
$this->status = $status;
|
|
if ($status == self::STATUS_MATCHED || $status == self::STATUS_RECONCILED) {
|
|
$this->fk_user_match = $user->id;
|
|
$this->date_match = dol_now();
|
|
}
|
|
return $this->update($user);
|
|
}
|
|
|
|
/**
|
|
* Try to auto-match transaction with Dolibarr invoices
|
|
* Uses multiple criteria: amount, name, invoice reference, IBAN
|
|
*
|
|
* @return array Array of potential matches with scores
|
|
*/
|
|
public function findMatches()
|
|
{
|
|
$matches = array();
|
|
$seenIds = array();
|
|
|
|
// Skip if already matched
|
|
if ($this->status == self::STATUS_MATCHED || $this->status == self::STATUS_RECONCILED) {
|
|
return $matches;
|
|
}
|
|
|
|
// Build search text from description and end-to-end-id
|
|
$searchText = strtoupper($this->description . ' ' . $this->end_to_end_id . ' ' . $this->label);
|
|
|
|
// For incoming payments (positive amount), search customer invoices
|
|
if ($this->amount > 0) {
|
|
$matches = array_merge($matches, $this->findCustomerInvoiceMatches($searchText, $seenIds));
|
|
}
|
|
|
|
// For outgoing payments (negative amount), search supplier invoices
|
|
if ($this->amount < 0) {
|
|
$matches = array_merge($matches, $this->findSupplierInvoiceMatches($searchText, $seenIds));
|
|
}
|
|
|
|
// Sort by score descending
|
|
usort($matches, function($a, $b) {
|
|
return $b['match_score'] - $a['match_score'];
|
|
});
|
|
|
|
return $matches;
|
|
}
|
|
|
|
/**
|
|
* Find matching customer invoices
|
|
*
|
|
* @param string $searchText Text to search in
|
|
* @param array &$seenIds Already seen invoice IDs
|
|
* @return array Matches
|
|
*/
|
|
protected function findCustomerInvoiceMatches($searchText, &$seenIds)
|
|
{
|
|
$matches = array();
|
|
|
|
// 1. Search by invoice reference in description (highest priority)
|
|
$sql = "SELECT f.rowid, f.ref, f.ref_client, f.total_ttc, f.date_lim_reglement,";
|
|
$sql .= " s.nom as socname, s.rowid as socid";
|
|
$sql .= " FROM ".MAIN_DB_PREFIX."facture as f";
|
|
$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."societe as s ON f.fk_soc = s.rowid";
|
|
$sql .= " WHERE f.entity = ".((int) $this->entity);
|
|
$sql .= " AND f.fk_statut = 1"; // Unpaid
|
|
|
|
$resql = $this->db->query($sql);
|
|
if ($resql) {
|
|
while ($obj = $this->db->fetch_object($resql)) {
|
|
$score = 0;
|
|
$matchReasons = array();
|
|
|
|
// Check if invoice reference appears in description
|
|
if (stripos($searchText, $obj->ref) !== false) {
|
|
$score += 80;
|
|
$matchReasons[] = 'ref';
|
|
}
|
|
|
|
// Check client reference
|
|
if (!empty($obj->ref_client) && stripos($searchText, $obj->ref_client) !== false) {
|
|
$score += 70;
|
|
$matchReasons[] = 'ref_client';
|
|
}
|
|
|
|
// Check exact amount match
|
|
if (abs($obj->total_ttc - $this->amount) < 0.01) {
|
|
$score += 50;
|
|
$matchReasons[] = 'amount';
|
|
} elseif (abs($obj->total_ttc - $this->amount) < 1.00) {
|
|
// Close amount (within 1 EUR)
|
|
$score += 20;
|
|
$matchReasons[] = 'amount_close';
|
|
}
|
|
|
|
// Check name similarity
|
|
if (!empty($this->name) && !empty($obj->socname)) {
|
|
$nameSimilarity = $this->calculateNameSimilarity($this->name, $obj->socname);
|
|
if ($nameSimilarity > 80) {
|
|
$score += 40;
|
|
$matchReasons[] = 'name_exact';
|
|
} elseif ($nameSimilarity > 50) {
|
|
$score += 20;
|
|
$matchReasons[] = 'name_similar';
|
|
}
|
|
}
|
|
|
|
// Check IBAN match
|
|
if (!empty($this->counterparty_iban)) {
|
|
$ibanMatch = $this->checkSocieteIban($obj->socid, $this->counterparty_iban);
|
|
if ($ibanMatch) {
|
|
$score += 60;
|
|
$matchReasons[] = 'iban';
|
|
}
|
|
}
|
|
|
|
// Only include if score is significant
|
|
if ($score >= 50 && !isset($seenIds['facture_'.$obj->rowid])) {
|
|
$seenIds['facture_'.$obj->rowid] = true;
|
|
$matches[] = array(
|
|
'type' => 'facture',
|
|
'id' => $obj->rowid,
|
|
'ref' => $obj->ref,
|
|
'amount' => $obj->total_ttc,
|
|
'socname' => $obj->socname,
|
|
'socid' => $obj->socid,
|
|
'match_score' => min($score, 100),
|
|
'match_reasons' => $matchReasons,
|
|
'date_due' => $obj->date_lim_reglement
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return $matches;
|
|
}
|
|
|
|
/**
|
|
* Find matching supplier invoices
|
|
*
|
|
* @param string $searchText Text to search in
|
|
* @param array &$seenIds Already seen invoice IDs
|
|
* @return array Matches
|
|
*/
|
|
protected function findSupplierInvoiceMatches($searchText, &$seenIds)
|
|
{
|
|
$matches = array();
|
|
$absAmount = abs($this->amount);
|
|
|
|
$sql = "SELECT f.rowid, f.ref, f.ref_supplier, f.total_ttc, f.date_lim_reglement,";
|
|
$sql .= " s.nom as socname, s.rowid as socid";
|
|
$sql .= " FROM ".MAIN_DB_PREFIX."facture_fourn as f";
|
|
$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."societe as s ON f.fk_soc = s.rowid";
|
|
$sql .= " WHERE f.entity = ".((int) $this->entity);
|
|
$sql .= " AND f.fk_statut = 1"; // Unpaid
|
|
|
|
$resql = $this->db->query($sql);
|
|
if ($resql) {
|
|
while ($obj = $this->db->fetch_object($resql)) {
|
|
$score = 0;
|
|
$matchReasons = array();
|
|
|
|
// Check if invoice reference appears in description
|
|
if (stripos($searchText, $obj->ref) !== false) {
|
|
$score += 80;
|
|
$matchReasons[] = 'ref';
|
|
}
|
|
|
|
// Check supplier reference
|
|
if (!empty($obj->ref_supplier) && stripos($searchText, $obj->ref_supplier) !== false) {
|
|
$score += 70;
|
|
$matchReasons[] = 'ref_supplier';
|
|
}
|
|
|
|
// Check exact amount match
|
|
if (abs($obj->total_ttc - $absAmount) < 0.01) {
|
|
$score += 50;
|
|
$matchReasons[] = 'amount';
|
|
} elseif (abs($obj->total_ttc - $absAmount) < 1.00) {
|
|
$score += 20;
|
|
$matchReasons[] = 'amount_close';
|
|
}
|
|
|
|
// Check name similarity
|
|
if (!empty($this->name) && !empty($obj->socname)) {
|
|
$nameSimilarity = $this->calculateNameSimilarity($this->name, $obj->socname);
|
|
if ($nameSimilarity > 80) {
|
|
$score += 40;
|
|
$matchReasons[] = 'name_exact';
|
|
} elseif ($nameSimilarity > 50) {
|
|
$score += 20;
|
|
$matchReasons[] = 'name_similar';
|
|
}
|
|
}
|
|
|
|
// Check IBAN match
|
|
if (!empty($this->counterparty_iban)) {
|
|
$ibanMatch = $this->checkSocieteIban($obj->socid, $this->counterparty_iban);
|
|
if ($ibanMatch) {
|
|
$score += 60;
|
|
$matchReasons[] = 'iban';
|
|
}
|
|
}
|
|
|
|
// Only include if score is significant
|
|
if ($score >= 50 && !isset($seenIds['facture_fourn_'.$obj->rowid])) {
|
|
$seenIds['facture_fourn_'.$obj->rowid] = true;
|
|
$matches[] = array(
|
|
'type' => 'facture_fourn',
|
|
'id' => $obj->rowid,
|
|
'ref' => $obj->ref,
|
|
'amount' => $obj->total_ttc,
|
|
'socname' => $obj->socname,
|
|
'socid' => $obj->socid,
|
|
'match_score' => min($score, 100),
|
|
'match_reasons' => $matchReasons,
|
|
'date_due' => $obj->date_lim_reglement
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return $matches;
|
|
}
|
|
|
|
/**
|
|
* Calculate name similarity percentage
|
|
*
|
|
* @param string $name1 First name
|
|
* @param string $name2 Second name
|
|
* @return int Similarity percentage (0-100)
|
|
*/
|
|
protected function calculateNameSimilarity($name1, $name2)
|
|
{
|
|
// Normalize names
|
|
$name1 = strtolower(trim($name1));
|
|
$name2 = strtolower(trim($name2));
|
|
|
|
// Remove common suffixes
|
|
$suffixes = array(' gmbh', ' ag', ' kg', ' ohg', ' e.k.', ' ug', ' gbr', ' se', ' co. kg', ' gmbh & co. kg');
|
|
foreach ($suffixes as $suffix) {
|
|
$name1 = str_replace($suffix, '', $name1);
|
|
$name2 = str_replace($suffix, '', $name2);
|
|
}
|
|
|
|
$name1 = trim($name1);
|
|
$name2 = trim($name2);
|
|
|
|
// Check if one contains the other
|
|
if (strpos($name1, $name2) !== false || strpos($name2, $name1) !== false) {
|
|
return 90;
|
|
}
|
|
|
|
// Use similar_text for fuzzy matching
|
|
$similarity = 0;
|
|
similar_text($name1, $name2, $similarity);
|
|
|
|
return (int) $similarity;
|
|
}
|
|
|
|
/**
|
|
* Check if IBAN matches any bank account of a societe
|
|
*
|
|
* @param int $socid Societe ID
|
|
* @param string $iban IBAN to check
|
|
* @return bool True if match found
|
|
*/
|
|
protected function checkSocieteIban($socid, $iban)
|
|
{
|
|
if (empty($socid) || empty($iban)) {
|
|
return false;
|
|
}
|
|
|
|
// Normalize IBAN (remove spaces)
|
|
$iban = str_replace(' ', '', strtoupper($iban));
|
|
|
|
$sql = "SELECT iban_prefix FROM ".MAIN_DB_PREFIX."societe_rib";
|
|
$sql .= " WHERE fk_soc = ".((int) $socid);
|
|
$sql .= " AND status = 1"; // Active
|
|
|
|
$resql = $this->db->query($sql);
|
|
if ($resql) {
|
|
while ($obj = $this->db->fetch_object($resql)) {
|
|
$storedIban = str_replace(' ', '', strtoupper($obj->iban_prefix));
|
|
if ($storedIban === $iban) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Find third party by IBAN
|
|
*
|
|
* @param string $iban IBAN to search
|
|
* @return int|null Societe ID or null
|
|
*/
|
|
public function findSocieteByIban($iban)
|
|
{
|
|
if (empty($iban)) {
|
|
return null;
|
|
}
|
|
|
|
$iban = str_replace(' ', '', strtoupper($iban));
|
|
|
|
$sql = "SELECT fk_soc FROM ".MAIN_DB_PREFIX."societe_rib";
|
|
$sql .= " WHERE REPLACE(UPPER(iban_prefix), ' ', '') = '".$this->db->escape($iban)."'";
|
|
$sql .= " AND status = 1";
|
|
$sql .= " LIMIT 1";
|
|
|
|
$resql = $this->db->query($sql);
|
|
if ($resql && $this->db->num_rows($resql) > 0) {
|
|
$obj = $this->db->fetch_object($resql);
|
|
return (int) $obj->fk_soc;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Auto-detect and link third party by IBAN
|
|
*
|
|
* @param User $user User doing the action
|
|
* @return int Societe ID if found and linked, 0 if not found
|
|
*/
|
|
public function autoLinkSociete($user)
|
|
{
|
|
if (!empty($this->fk_societe)) {
|
|
return $this->fk_societe; // Already linked
|
|
}
|
|
|
|
$socid = $this->findSocieteByIban($this->counterparty_iban);
|
|
if ($socid > 0) {
|
|
$this->fk_societe = $socid;
|
|
$this->update($user);
|
|
return $socid;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Link transaction to a Dolibarr object
|
|
*
|
|
* @param string $type Object type (facture, facture_fourn, societe, etc.)
|
|
* @param int $id Object ID
|
|
* @param User $user User making the link
|
|
* @return int <0 if KO, >0 if OK
|
|
*/
|
|
public function linkTo($type, $id, $user)
|
|
{
|
|
switch ($type) {
|
|
case 'facture':
|
|
$this->fk_facture = $id;
|
|
break;
|
|
case 'facture_fourn':
|
|
$this->fk_facture_fourn = $id;
|
|
break;
|
|
case 'societe':
|
|
$this->fk_societe = $id;
|
|
break;
|
|
case 'bank':
|
|
$this->fk_bank = $id;
|
|
break;
|
|
case 'paiement':
|
|
$this->fk_paiement = $id;
|
|
break;
|
|
case 'paiementfourn':
|
|
$this->fk_paiementfourn = $id;
|
|
break;
|
|
case 'salary':
|
|
$this->fk_salary = $id;
|
|
break;
|
|
case 'don':
|
|
$this->fk_don = $id;
|
|
break;
|
|
case 'loan':
|
|
$this->fk_loan = $id;
|
|
break;
|
|
default:
|
|
$this->error = 'Unknown link type: '.$type;
|
|
return -1;
|
|
}
|
|
|
|
$this->status = self::STATUS_MATCHED;
|
|
return $this->update($user);
|
|
}
|
|
}
|