Jean-Christophe Engel
f6eef8897e
This reverts commit16daac2e24
, reversing changes made to46d0c838ae
.
563 lines
15 KiB
PHP
563 lines
15 KiB
PHP
<?php
|
|
|
|
namespace Paheko\Plugin\Facturation;
|
|
|
|
use DateTime;
|
|
use Paheko\Config;
|
|
use Paheko\DB;
|
|
use Paheko\DynamicList;
|
|
use Paheko\UserException;
|
|
use Paheko\Utils;
|
|
use Paheko\Services\Services_User;
|
|
|
|
class Facture
|
|
{
|
|
const TYPES_NAMES = [
|
|
DEVIS => 'Devis',
|
|
FACT => 'Facture',
|
|
CERFA => 'Reçu fiscal',
|
|
COTIS => 'Reçu de cotisation',
|
|
];
|
|
|
|
private $keys = [
|
|
'type_facture', // 0 : devis, 1 : facture, 2 : reçu cerfa, 3 : reçu cotis
|
|
'numero',
|
|
'receveur_membre',
|
|
'receveur_id',
|
|
'date_emission', // Reçus : date du don
|
|
'date_echeance', // Reçus : date d'édition du reçu
|
|
'reglee',
|
|
'archivee',
|
|
'moyen_paiement',
|
|
'contenu',
|
|
'total'
|
|
];
|
|
|
|
public $types = [
|
|
DEVIS => [
|
|
'id' => DEVIS,
|
|
'accounts' => [],
|
|
'label' => 'Devis',
|
|
'help' => ''],
|
|
FACT => [
|
|
'id' => FACT,
|
|
'accounts' => [],
|
|
'label' => 'Facture',
|
|
'help' => ''],
|
|
CERFA => [
|
|
'id' => CERFA,
|
|
'accounts' => [],
|
|
'label' => 'Reçu fiscal',
|
|
'help' => 'Reçu fiscal pour un don (membre ou client)'],
|
|
COTIS => [
|
|
'id' => COTIS,
|
|
'accounts' => [],
|
|
'label' => 'Reçu de cotisation',
|
|
'help' => 'Reçu pour une cotisation payée par un·e membre'],
|
|
];
|
|
|
|
public function __construct()
|
|
{
|
|
|
|
}
|
|
|
|
// Fix : est dépendant de l'ordre des données dans l'array
|
|
// et implique que toutes les données soient présentes (pas possible de faire un update partiel)
|
|
public function _checkFields(&$datas)
|
|
{
|
|
foreach($datas as $k=>$data)
|
|
{
|
|
if (!in_array($k, $this->keys))
|
|
{
|
|
throw new UserException("Clé inattendue : $k.");
|
|
}
|
|
|
|
if(!is_array($data) && null !== $data){
|
|
$datas[$k] = trim($data);
|
|
}
|
|
if ($datas[$k] === '' && $k != 'numero')
|
|
{
|
|
throw new UserException("La valeur de $k est vide");
|
|
}
|
|
|
|
switch($k)
|
|
{
|
|
case 'type_facture':
|
|
if (!array_key_exists($datas[$k], $this->types)) {
|
|
throw new UserException("$k est de type non-attendue ($data).");
|
|
}
|
|
if ($datas[$k] < 2) {
|
|
$fac = true;
|
|
$cerfa = false;
|
|
$recu = false;
|
|
}
|
|
elseif ($datas[$k] == 2) {
|
|
$fac = false;
|
|
$cerfa = true;
|
|
$recu = false;
|
|
}
|
|
elseif ($datas[$k] == 3) {
|
|
$fac = false;
|
|
$cerfa = false;
|
|
$recu = true;
|
|
}
|
|
break;
|
|
case 'receveur_membre':
|
|
case 'reglee':
|
|
case 'archivee':
|
|
if ($datas[$k] != 1 && $datas[$k] != 0) {
|
|
throw new UserException("$k est de valeur non-attendue ($data).");
|
|
}
|
|
break;
|
|
case 'receveur_id':
|
|
if (!is_numeric($datas[$k]) || $datas[$k] < 0) {
|
|
throw new UserException("L'id du receveur est non-attendu ($data).");
|
|
}
|
|
break;
|
|
case 'date_emission':
|
|
$datas[$k] = \DateTime::createFromFormat('!d/m/Y', $data)->format('Y-m-d');
|
|
break;
|
|
case 'date_echeance':
|
|
$datas[$k] = \DateTime::createFromFormat('!d/m/Y', $data)->format('Y-m-d');
|
|
if (DateTime::createFromFormat('!Y-m-d', $datas[$k])->format('U') < DateTime::createFromFormat('!Y-m-d', $datas['date_emission'])->format('U'))
|
|
{
|
|
throw new UserException("La date d'échéance est antérieure à la date d'émission ($data).");
|
|
}
|
|
break;
|
|
case 'moyen_paiement':
|
|
if (!array_key_exists($datas[$k], $this->listMoyensPaiement())) {
|
|
throw new UserException("Le moyen de paiement ne correspond pas à la liste interne ($data).");
|
|
}
|
|
break;
|
|
case 'contenu':
|
|
if ($fac)
|
|
{
|
|
if (!is_array($datas[$k]) || empty($datas[$k])) {
|
|
throw new UserException("Le contenu du document est vide ($data).");
|
|
}
|
|
$total = 0;
|
|
foreach($datas[$k] as $g => $r)
|
|
{
|
|
if (empty($r['designation']) && empty($r['prix']))
|
|
{
|
|
unset($datas[$k][$g]);
|
|
unset($datas[$k]['prix']);
|
|
continue;
|
|
}
|
|
elseif (empty($r['prix']))
|
|
{
|
|
$datas[$k]['prix'] = 0;
|
|
}
|
|
|
|
if (!is_int($r['prix']))
|
|
{
|
|
throw new UserException('Un (ou plus) des prix n\'est pas un entier.');
|
|
}
|
|
|
|
$total += $r['prix'];
|
|
}
|
|
|
|
if($fac && !$total)
|
|
{
|
|
throw new UserException("Toutes les désignations/prix sont vides.");
|
|
}
|
|
}
|
|
elseif ($cerfa)
|
|
{
|
|
|
|
}
|
|
elseif ($recu)
|
|
{
|
|
// $fields = ['id', 'intitule', 'date', 'expiration'];
|
|
// foreach ($datas[$k]as $)
|
|
}
|
|
$datas[$k] = json_encode($datas[$k]);
|
|
break;
|
|
case 'total':
|
|
if ($cerfa && $datas[$k] < 1) {
|
|
throw new UserException('Le total ne peut être inférieur à 1€ pour les reçus (bug encore non résolu).');
|
|
}
|
|
if ($fac && !isset($datas['contenu'])) {
|
|
throw new UserException("Pas de contenu fourni pour vérifier le total.");
|
|
}
|
|
if ($fac && $total != $datas[$k])
|
|
{
|
|
throw new UserException("Les totaux sont différents ($total != $datas[$k].");
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
public function add($data, ?string $number_pattern = null)
|
|
{
|
|
$db = DB::getInstance();
|
|
|
|
$this->_checkFields($data);
|
|
$generate_number = false;
|
|
|
|
if (empty($data['numero']) && $number_pattern) {
|
|
$generate_number = true;
|
|
$data['numero'] = sha1(random_bytes(10));
|
|
}
|
|
|
|
if ($db->test('plugin_facturation_factures', 'numero = ? COLLATE NOCASE', $data['numero']))
|
|
{
|
|
throw new UserException('Le numéro de document doit être unique, or il existe déjà un document avec le numéro ' . $data['numero']);
|
|
}
|
|
|
|
$db->insert('plugin_facturation_factures', $data);
|
|
$id = $db->lastInsertRowId();
|
|
|
|
if ($generate_number) {
|
|
$numero = $this->getNewNumber($number_pattern, $data['type_facture'], \DateTime::createFromFormat('!Y-m-d', $data['date_emission']), $id);
|
|
$db->update('plugin_facturation_factures', compact('numero'), 'id = ' . (int) $id);
|
|
}
|
|
|
|
return $id;
|
|
}
|
|
|
|
/**
|
|
* Renvoie un nouveau numéro de facture selon un motif défini et le type de document
|
|
*/
|
|
public function getNewNumber(string $pattern, int $type, \DateTimeInterface $date, int $id)
|
|
{
|
|
if ($type == DEVIS) {
|
|
$type = 'DEVIS';
|
|
$t = 'D';
|
|
}
|
|
elseif ($type == FACT) {
|
|
$type = 'FACT';
|
|
$t = 'F';
|
|
}
|
|
elseif ($type == CERFA) {
|
|
$type = 'CERFA';
|
|
$t = 'RF';
|
|
}
|
|
else {
|
|
$type = 'COTIS';
|
|
$t = 'RC';
|
|
}
|
|
|
|
$year = $date->format('Y');
|
|
$y = $date->format('y');
|
|
|
|
// Garantir l'unicité du numéro
|
|
$db = DB::getInstance();
|
|
$sql = sprintf('SELECT numero FROM plugin_facturation_factures');
|
|
$numeros = array_column($db->get($sql), 'numero');
|
|
|
|
//sélectionner les numéros qui correspondent au pattern
|
|
$selpattern = preg_replace('/%(\d+)?\{(ynumber|id)\}/', '', $pattern);
|
|
$data = compact('type', 't', 'year', 'y');
|
|
$prefixe = preg_replace_callback('/%(\d+)?\{([a-z]+)\}/', function ($match) use ($data) {
|
|
$v = (string) $data[$match[2]];
|
|
$type = ctype_digit($v) ? 'd' : 's';
|
|
return sprintf('%' . $match[1] . $type, $v);
|
|
}, $selpattern);
|
|
$modele = '/^' . $prefixe . '\d+$/';
|
|
$numeros_filtres = array_filter($numeros, function($elem) use ($modele) {
|
|
return preg_match($modele, $elem);
|
|
}, 0);
|
|
|
|
// extraire le numéro d'ordre
|
|
$rangs = array_map(function($elem) use($prefixe) {
|
|
return (int) substr($elem, strlen($prefixe));
|
|
}, array_values($numeros_filtres));
|
|
sort($rangs);
|
|
if (empty($rangs)) {
|
|
$ynumber = 1;
|
|
} else {
|
|
$ynumber = end($rangs) + 1;
|
|
}
|
|
|
|
// fabriquer le numéro selon le pattern
|
|
$data = compact('type', 't', 'year', 'y', 'ynumber', 'id');
|
|
return preg_replace_callback('/%(\d+)?\{([a-z]+)\}/', function ($match) use ($data) {
|
|
$v = (string) $data[$match[2]];
|
|
$type = ctype_digit($v) ? 'd' : 's';
|
|
return sprintf('%' . $match[1] . $type, $v);
|
|
}, $pattern);
|
|
}
|
|
|
|
public function get($id)
|
|
{
|
|
$db = DB::getInstance();
|
|
|
|
$r = $db->first('SELECT * FROM plugin_facturation_factures WHERE id = ? LIMIT 1;', (int)$id);
|
|
|
|
if(!$r)
|
|
{
|
|
throw new UserException("Pas de document retournée avec cet id.");
|
|
}
|
|
|
|
if ($r->contenu)
|
|
{
|
|
$r->contenu = json_decode($r->contenu, true);
|
|
}
|
|
|
|
$r->date_emission = \DateTime::createFromFormat('!Y-m-d', $r->date_emission);
|
|
if ($r->date_echeance)
|
|
{
|
|
$r->date_echeance= \DateTime::createFromFormat('!Y-m-d', $r->date_echeance);
|
|
}
|
|
|
|
return $r;
|
|
}
|
|
|
|
public function listAll()
|
|
{
|
|
$r = (array)DB::getInstance()->get('SELECT *, strftime(\'%s\', date_emission) AS date_emission,
|
|
strftime(\'%s\', date_echeance) AS date_echeance
|
|
FROM plugin_facturation_factures');
|
|
|
|
foreach ($r as $e)
|
|
{
|
|
if($e->contenu)
|
|
{
|
|
$e->contenu = json_decode((string)$e->contenu, true);
|
|
}
|
|
}
|
|
|
|
return $r;
|
|
}
|
|
|
|
public function list(): DynamicList
|
|
{
|
|
$id_field = \Paheko\Users\DynamicFields::getNameFieldsSQL('u');
|
|
$plugin_name = preg_replace('/^.*\/(\w+)\/$/', '${1}', \Paheko\PLUGIN_ADMIN_URL);
|
|
$plugin = \Paheko\Plugins::get($plugin_name);
|
|
|
|
// adresse et ville peuvent être redéfinies dans la configuration du plugin
|
|
$adresse_client = $plugin->getConfig('adresse_client');
|
|
if ($adresse_client == null) { $adresse = 'u.adresse'; } else { $adresse = 'u.' . $adresse_client; }
|
|
$ville_client = $plugin->getConfig('ville_client');
|
|
if ($ville_client == null) { $ville = 'u.ville'; } else { $ville = 'u.' . $ville_client; }
|
|
|
|
$columns = [
|
|
// Sélectionner cette colonne, mais ne pas la mettre dans la liste des colonnes
|
|
// (absence de label)
|
|
'id' => [
|
|
'select' => 'f.id',
|
|
],
|
|
'type_facture' => [
|
|
],
|
|
'receveur_membre' => [
|
|
],
|
|
'receveur_id' => [
|
|
],
|
|
// Créer une colonne virtuelle
|
|
'type' => [
|
|
'label' => 'Type',
|
|
'select' => null,
|
|
],
|
|
'numero' => [
|
|
'label' => 'Numéro',
|
|
'select' => 'f.numero',
|
|
],
|
|
'receveur' => [
|
|
'label' => 'Receveur',
|
|
// l'identité du membre peut être redéfinie dans la configuration des membres
|
|
'select' => sprintf('CASE WHEN receveur_membre THEN CASE %s WHEN "" THEN "** ABSENT **" ELSE %s END ELSE c.nom END', $id_field, $id_field),
|
|
],
|
|
'receveur_adresse' => [
|
|
// l'adresse peut être redéfinie dans la configuration du plugin
|
|
'label' => 'Adresse',
|
|
'select' => sprintf('CASE WHEN receveur_membre THEN %s ELSE c.adresse END', $adresse),
|
|
],
|
|
'receveur_ville' => [
|
|
// la ville peut être redéfinie dans la configuration du plugin
|
|
'label' => 'Ville',
|
|
'select' => sprintf('CASE WHEN receveur_membre THEN %s ELSE c.ville END', $ville),
|
|
],
|
|
'date_emission' => [
|
|
'label' => 'Émission',
|
|
'order' => 'date_emission %s, id %1$s',
|
|
],
|
|
'date_echeance' => [
|
|
'label' => 'Échéance',
|
|
],
|
|
'reglee' => [
|
|
'label' => 'Réglée',
|
|
],
|
|
'archivee' => [
|
|
'label' => 'Archivée',
|
|
],
|
|
'moyen_paiement' => [
|
|
'label' => 'Moyen de paiement',
|
|
'select' => 'mp.nom',
|
|
],
|
|
'contenu' => [
|
|
'label' => 'Contenu',
|
|
],
|
|
'total' => [
|
|
'label' => 'Total',
|
|
],
|
|
];
|
|
|
|
$tables = 'plugin_facturation_factures AS f
|
|
INNER JOIN plugin_facturation_paiement AS mp ON mp.code = f.moyen_paiement
|
|
LEFT JOIN users AS u ON f.receveur_membre = 1 AND u.id = f.receveur_id
|
|
LEFT JOIN plugin_facturation_clients AS c ON f.receveur_membre = 0 AND c.id = f.receveur_id';
|
|
|
|
$list = new DynamicList($columns, $tables);
|
|
$list->orderBy('date_emission', true);
|
|
$list->setCount('COUNT(f.id)');
|
|
|
|
$currency = Config::getInstance()->monnaie;
|
|
|
|
$list->setModifier(function ($row) use ($currency) {
|
|
// Remplir la colonne virtuelle
|
|
$row->type = self::TYPES_NAMES[$row->type_facture] ?? null;
|
|
$row->reglee = $row->reglee ? 'Réglée' : 'Non';
|
|
$row->archivee = $row->archivee ? 'Archivée' : 'Non';
|
|
|
|
// Remplir le contenu
|
|
$content = json_decode((string)$row->contenu);
|
|
|
|
if ($row->type_facture == COTIS && isset($content->intitule, $content->souscription)) {
|
|
$row->contenu = sprintf("Cotisation %s\nSouscrite le %s",
|
|
$content->intitule,
|
|
Utils::date_fr($content->souscription, 'd/m/Y')
|
|
);
|
|
}
|
|
elseif ($row->type_facture != CERFA) {
|
|
$row->contenu = implode("\n", array_map(function ($row) use ($currency) {
|
|
return sprintf('%s : %s %s', $row->designation, Utils::money_format($row->prix), $currency);
|
|
}, (array)$content));
|
|
}
|
|
else
|
|
{
|
|
$row->contenu = '';
|
|
}
|
|
});
|
|
|
|
return $list;
|
|
}
|
|
|
|
public function edit($id, $data = [])
|
|
{
|
|
$db = DB::getInstance();
|
|
|
|
$this->_checkFields($data);
|
|
|
|
if(isset($data['numero']) && $db->test('plugin_facturation_factures', 'numero = ? COLLATE NOCASE AND id != ?', $data['numero'], (int)$id))
|
|
{
|
|
throw new UserException('Un document avec ce numéro existe déjà, or le numéro doit être unique.');
|
|
}
|
|
return $db->update('plugin_facturation_factures', $data, $db->where('id', (int)$id));
|
|
}
|
|
|
|
public function listUserDoc($base, $id)
|
|
{
|
|
$client = new Client;
|
|
|
|
if ($base == 0) // Si c'est un client
|
|
{
|
|
if(!$client->get($id))
|
|
{
|
|
throw new UserException("Ce client n'existe pas.");
|
|
}
|
|
}
|
|
else // Si c'est un membre de l'asso
|
|
{
|
|
throw new UserException("Woopsie, g pô encore implémenté l'usage des users de l'asso comme clients");
|
|
}
|
|
|
|
$r = (array)DB::getInstance()->get('SELECT *, strftime(\'%s\', date_emission) AS date_emission,
|
|
strftime(\'%s\', date_echeance) AS date_echeance
|
|
FROM plugin_facturation_factures
|
|
WHERE receveur_membre = ? AND receveur_id = ?', (int)$base, (int)$id);
|
|
|
|
foreach ($r as $e)
|
|
{
|
|
if ($e->contenu)
|
|
{
|
|
$e->contenu = json_decode((string)$e->contenu, true);
|
|
}
|
|
}
|
|
|
|
return empty($r)?false:$r;
|
|
}
|
|
|
|
public function hasDocs($base, $id)
|
|
{
|
|
$client = new Client;
|
|
|
|
if ($base == 0) // Si c'est un client
|
|
{
|
|
if(!$client->get($id))
|
|
{
|
|
throw new UserException("Ce client n'existe pas.");
|
|
}
|
|
}
|
|
else // Si c'est un membre de l'asso
|
|
{
|
|
throw new UserException("Woopsie, g pô encore implémenté l'usage des users de l'asso comme clients");
|
|
}
|
|
|
|
return DB::getInstance()->test('plugin_facturation_factures', 'receveur_membre = ? AND receveur_id = ?', $base, $id);
|
|
}
|
|
|
|
// ** Pour type reçu **
|
|
|
|
public $recu_fields = ['id', 'label', 'amount', 'date', 'expiry', 'paid', 'paid_amount'];
|
|
|
|
public function getCotis(int $user_id, int $su_id = null)
|
|
{
|
|
$where = 'WHERE su.id_user = ?';
|
|
if (null !== $su_id)
|
|
{
|
|
$where .= ' AND su.id = '.$su_id;
|
|
}
|
|
|
|
$sql = 'SELECT su.id, s.label, su.date, MAX(su.expiry_date) as expiry, sf.label as fee, sf.amount as amount, su.paid, SUM(tl.debit) as paid_amount
|
|
FROM services_users su
|
|
INNER JOIN services s ON s.id = su.id_service
|
|
LEFT JOIN services_fees sf ON sf.id = su.id_fee
|
|
LEFT JOIN acc_transactions_users tu ON tu.id_service_user = su.id
|
|
LEFT JOIN acc_transactions_lines tl ON tl.id_transaction = tu.id_transaction
|
|
'.$where.'
|
|
GROUP BY su.id
|
|
ORDER BY su.date;';
|
|
|
|
return DB::getInstance()->get($sql, $user_id);
|
|
}
|
|
|
|
public function listMoyensPaiement($assoc = false)
|
|
{
|
|
$db = DB::getInstance();
|
|
|
|
$query = 'SELECT code, nom FROM plugin_facturation_paiement ORDER BY nom COLLATE NOCASE;';
|
|
|
|
if ($assoc) {
|
|
return $db->getAssoc($query);
|
|
}
|
|
else {
|
|
return $db->getGrouped($query);
|
|
}
|
|
}
|
|
|
|
/* modif DD -- lecture et retour des textes de CERFA -- */
|
|
public function listTextesCerfa($menu = true)
|
|
{
|
|
$db = DB::getInstance();
|
|
|
|
$sel = ($menu) ? 'id, menu' : 'id, texte';
|
|
$query = 'SELECT '.$sel.' FROM "plugin_facturation_txt_cerfa" WHERE 1 ORDER BY id ;';
|
|
|
|
return $db->getAssoc($query);
|
|
}
|
|
|
|
public function getMoyenPaiement($code)
|
|
{
|
|
$db = DB::getInstance();
|
|
return $db->firstColumn('SELECT nom FROM plugin_facturation_paiement WHERE code = ?;', $code);
|
|
}
|
|
|
|
public function delete($id)
|
|
{
|
|
return DB::getInstance()->delete('plugin_facturation_factures', 'id = '. (int)$id);
|
|
}
|
|
}
|