'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); } }