2 Commits

Author SHA1 Message Date
pierre
f6c5e96534 Merge branch 'main' of https://gitea.d6soft.fr/D6/Cleo 2025-11-05 15:47:25 +01:00
pierre
a4d1c22a93 feat(v2.0.3): Marchés hybrides et améliorations multiples
Fonctionnalités principales :

1. Marchés hybrides - Onglet Mercurial
   - Ajout onglet Mercurial avec style distinct (vert, gras, blanc)
   - Affichage des produits mercuriaux pour marchés hybrides
   - Filtrage automatique des produits "Hors Marché 999"
   - Documentation Phase 2 avec CAS 1 et CAS 2 de marchés hybrides
   - Règles métier pour validation différenciée (devis 100% mercurial vs mixte)

2. Corrections bugs
   - Fix flag chkChange sur onglet "Sélection Produits" (callback asynchrone)
   - Plus d'alerte intempestive après sauvegarde des produits

3. Outils de déploiement
   - Nouveau script deploy-file.sh pour déploiement unitaire (DEV/PROD)
   - Amélioration deploy-cleo.sh

4. Gestion multi-contacts (v2.0.3)
   - Contrôleur AJAX cjxcontacts.php
   - Script migration clients_contacts
   - Documentation complète

5. Documentation
   - Mise à jour TODO.md avec Phase 2 marchés hybrides
   - Mise à jour README.md v2.0.3
   - Ajout RULES.md
   - Ajout migration_clients_contacts.sql

6. Nettoyage
   - Suppression fichiers obsolètes (conf_new.php, conf_old.php, uof_linet_20250911.sql)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 15:40:06 +01:00
21 changed files with 11544 additions and 904 deletions

View File

@@ -2,8 +2,7 @@
require_once dirname(__FILE__) . '/Database.php';
class Conf
{
class Conf {
const admin = 1;
const intra = 1;
const erp = 1;
@@ -11,7 +10,7 @@ class Conf
public $_appname = "cleo";
public $_appscript = "login";
public $_appversion = "2.0.2";
public $_appversion = "2.0.3";
public $_appenv;
public $_apptitle = "CLEO - Gestion de devis";
@@ -48,8 +47,7 @@ class Conf
public $_entite = '';
public $_new_version = false;
public function __construct()
{
public function __construct() {
$this->loadEnvironment();
$this->loadConfiguration();
$this->setupDebug();
@@ -115,7 +113,6 @@ class Conf
$this->_brandemail = $entite["email"] ?? "";
$this->_brandlogo = $entite["appname"] ?? "cleo";
}
} catch (Exception $e) {
error_log("Erreur de configuration: " . $e->getMessage());
$this->setDefaultConfiguration();

View File

@@ -1,184 +0,0 @@
<?php
require_once dirname(__FILE__) . '/Database.php';
class Conf
{
const admin = 1;
const intra = 1;
const erp = 1;
const magazine = 0;
public $_appname = "cleo";
public $_appscript = "login";
public $_appversion = "2.0.1";
public $_appenv;
public $_apptitle = "CLEO - Gestion de devis";
public $_brandname;
public $_brandadresse1;
public $_brandadresse2;
public $_brandcp;
public $_brandville;
public $_brandtel;
public $_brandemail;
public $_brandlogo;
public $_brandgroupe;
public $_brandmulti;
public $_piwikid;
public $_googlid;
public $_excludeIp;
public $_clientIp;
public $_devIp = false;
public $_debug_level = 0;
public $_log_sql = false;
public $_log_performance = false;
public $_log_file_path = '';
public $_pathupload;
public $_dbhost;
public $_dbname;
public $_dbuser;
public $_dbpass;
public $_entite = '';
public $_new_version = false;
public function __construct()
{
$this->loadEnvironment();
$this->loadConfiguration();
$this->setupDebug();
}
private function loadEnvironment() {
$envFile = dirname(__DIR__) . '/.env';
if (file_exists($envFile)) {
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (strpos(trim($line), '#') === 0) continue;
list($name, $value) = explode('=', $line, 2);
$name = trim($name);
$value = trim($value);
if (!isset($_ENV[$name])) {
putenv(sprintf('%s=%s', $name, $value));
$_ENV[$name] = $value;
$_SERVER[$name] = $value;
}
}
}
$this->_dbhost = $_ENV['DB_HOST'] ?? 'localhost';
$this->_dbname = $_ENV['DB_DATABASE'] ?? 'cleo';
$this->_dbuser = $_ENV['DB_USERNAME'] ?? 'cleo_user';
$this->_dbpass = $_ENV['DB_PASSWORD'] ?? '';
$this->_excludeIp = $_ENV['EXCLUDE_IP'] ?? '';
$this->_pathupload = $_ENV['UPLOAD_PATH'] ?? '/pub/files/upload/';
$this->_appenv = $_ENV['APP_ENV'] ?? 'production';
$this->_debug_level = $_ENV['LOG_LEVEL'] === 'debug' ? 4 : 0;
$this->_log_sql = filter_var($_ENV['LOG_SQL'] ?? false, FILTER_VALIDATE_BOOLEAN);
$this->_log_performance = filter_var($_ENV['LOG_PERFORMANCE'] ?? false, FILTER_VALIDATE_BOOLEAN);
}
private function loadConfiguration() {
$http_host = $_SERVER['HTTP_HOST'];
try {
$db = Database::getInstance();
$sql = "SELECT * FROM users_entites WHERE http_host LIKE :host AND active = 1 LIMIT 1";
$entite = $db->fetchOne($sql, ['host' => "%$http_host%"]);
if (empty($entite)) {
$sql = "SELECT * FROM users_entites WHERE rowid = 1";
$entite = $db->fetchOne($sql);
}
if ($entite) {
$this->_entite = $entite;
$this->_appname = $entite["appname"] ?? "cleo";
$this->_apptitle = $entite["libelle"] ?? "CLEO";
$this->_brandname = $entite["libelle"] ?? "";
$this->_brandadresse1 = $entite["adresse1"] ?? "";
$this->_brandadresse2 = $entite["adresse2"] ?? "";
$this->_brandcp = $entite["cp"] ?? "";
$this->_brandville = $entite["ville"] ?? "";
$this->_brandtel = $entite["tel1"] ?? "";
$this->_brandemail = $entite["email"] ?? "";
$this->_brandlogo = $entite["appname"] ?? "cleo";
}
} catch (Exception $e) {
error_log("Erreur de configuration: " . $e->getMessage());
$this->setDefaultConfiguration();
}
}
private function setDefaultConfiguration() {
$this->_appname = "cleo";
$this->_apptitle = "CLEO - Gestion de devis";
$this->_brandname = "CLEO";
$this->_brandemail = $_ENV['MAIL_FROM_ADDRESS'] ?? "noreply@example.com";
}
private function setupDebug() {
if (!empty($_SERVER["HTTP_CLIENT_IP"])) {
$this->_clientIp = $_SERVER["HTTP_CLIENT_IP"];
} elseif (!empty($_SERVER["HTTP_X_FORWARDED_FOR"])) {
$this->_clientIp = $_SERVER["HTTP_X_FORWARDED_FOR"];
} else {
$this->_clientIp = $_SERVER["REMOTE_ADDR"];
}
$http_host = $_SERVER['HTTP_HOST'] ?? '';
$isDev = strpos($http_host, 'dcleo.unikoffice.com') !== false;
$isRecette = strpos($http_host, 'rcleo.unikoffice.com') !== false;
$isDebugEnv = $_ENV['APP_DEBUG'] === 'true' || $_ENV['APP_ENV'] === 'development';
if ($isDev || $isRecette || $isDebugEnv) {
ini_set('error_reporting', -1);
ini_set('display_errors', '1');
$this->_devIp = true;
$this->_debug_level = 4;
$this->_log_sql = true;
$this->_log_performance = true;
$this->_log_file_path = dirname(__DIR__) . '/log/' . $this->_appname . '_debug_' . date('Y-m-d') . '.log';
ini_set('log_errors', '1');
ini_set('error_log', $this->_log_file_path);
ini_set('display_startup_errors', '1');
} else {
ini_set('error_reporting', 0);
ini_set('display_errors', '0');
ini_set('log_errors', '0');
$this->_debug_level = 0;
$this->_log_sql = false;
$this->_log_performance = false;
}
}
public function debug($data, $type = 'DEBUG', $level = 3) {
if ($this->_debug_level < $level) return;
$levels = ['ERROR', 'WARNING', 'INFO', 'DEBUG'];
$timestamp = date('Y-m-d H:i:s');
$message = "[$timestamp] [$type] " . (is_array($data) ? json_encode($data) : $data) . PHP_EOL;
if ($this->_log_file_path) {
error_log($message, 3, $this->_log_file_path);
}
if ($this->_devIp && ini_get('display_errors')) {
echo "<!-- $message -->\n";
}
}
}

View File

@@ -1,148 +0,0 @@
<?php
class Conf
{
const admin = 1; // TRUE ou FALSE pour indiquer si l'application est admin ou non
const intra = 1; // Est-ce un intranet privé TRUE 1, ou un site public FALSE 0
const erp = 1; //! Est-ce un ERP ? Utile pour la gestion documentaire avec les paths spéciaux pour l'ERP
const magazine = 0; //! Est-ce qu'on veut transformer les PDF en JPG pour la lecture Magazine dans le d6tools.upload ?
public $_appname;
public $_appscript;
public $_appversion;
public $_appenv;
public $_apptitle;
public $_brandname;
public $_brandadresse1;
public $_brandadresse2;
public $_brandcp;
public $_brandville;
public $_brandtel;
public $_brandemail;
public $_brandlogo;
public $_brandgroupe;
public $_brandmulti;
public $_piwikid;
public $_googlid;
public $_excludeIp = "82.67.142.214"; //! IP à exclure pour le comptage des visites et pour le debug
public $_clientIp;
public $_devIp = false;
//! Configuration du debug
public $_debug_level = 0; //! 0=off, 1=errors, 2=warnings, 3=info, 4=debug
public $_log_sql = false; //! Logger les requêtes SQL
public $_log_performance = false; //! Logger les temps d'exécution
public $_log_file_path = ''; //! Chemin du fichier de log
public $_pathupload = "/pub/files/upload/"; //! le path de base pour les uploads
//! les infos de connexion de la base de données
public $_dbhost = 'localhost';
public $_dbname = 'uof_frontal';
public $_dbuser = 'uof_front_user';
public $_dbpass = 'd66,UnikOffice.User';
public $_dbghost = 'localhost';
public $_dbgname = '';
public $_dbguser = 'uof_linet_user';
public $_dbgpass = 'd66,UOF-LinetRH.User';
public $_dbuhost = 'localhost';
public $_dbuname = '';
public $_dbuuser = 'uof_linet_user';
public $_dbupass = 'd66,UOF-LinetRH.User';
public $_tbusers = ""; // Spécifie la table des users de cette application, par défaut uof_frontal.users, mais sur Linet c'est dans uof_linet.commerciaux
//! les infos de l'entité de l'utilisateur
public $_entite = '';
//! indique si c'est une nouvelle version pour les tests de nouveaux modules et librairies
public $_new_version = false;
public function __construct()
{
//! on va chercher la configuration de l'application dans la table ce_frontal.y_conf
$mysqli = new mysqli($this->_dbhost, $this->_dbuser, $this->_dbpass, $this->_dbname);
$sql = 'SELECT * FROM y_conf WHERE admin=' . self::admin . ' AND active=1 LIMIT 1;';
$mysqli->set_charset("utf8");
$res = $mysqli->query($sql);
$resconf = $res->fetch_assoc();
$this->_appenv = $resconf["appenv"];
$this->_appversion = "2.0.1";
$this->_appscript = $resconf["appscript"]; //! le script à appeler par défaut si l'utilisateur n'est pas reconnu
$this->_brandgroupe = $resconf["brandgroupe"];
$this->_brandmulti = $resconf["brandmulti"];
//! On va chercher les infos de base de cette appname dans ce_frontal.users_entites en fonction du http_host
$http_host = $_SERVER['HTTP_HOST'];
error_log("http_host : ".$http_host);
$sql = 'SELECT * FROM users_entites WHERE http_host LIKE "%' . $http_host . '%" AND active=1 LIMIT 1;';
$res = $mysqli->query($sql);
$mysqli->close();
$resentite = $res->fetch_assoc();
if (empty($resentite)) {
//! on ne trouve pas ce http_host, on part sur la demo
$this->_appname = "udo_demo";
$mysqli = new mysqli($this->_dbhost, $this->_dbuser, $this->_dbpass, $this->_dbname);
$sql = 'SELECT * FROM users_entites WHERE rowid=1;'; // appname="' . $this->_appname . '" AND active=1 LIMIT 1;';
$res = $mysqli->query($sql);
$mysqli->close();
$resentite = $res->fetch_assoc();
}
$this->_entite = $resentite;
$this->_appname = $resentite["appname"];
$this->_apptitle = $resentite["libelle"];
$this->_brandname = $resentite["libelle"];
$this->_brandadresse1 = $resentite["adresse1"];
$this->_brandadresse2 = $resentite["adresse2"];
$this->_brandcp = $resentite["cp"];
$this->_brandville = $resentite["ville"];
$this->_brandtel = $resentite["tel1"];
$this->_brandemail = $resentite["email"];
$this->_brandlogo = $resentite["appname"];
$this->_dbgname = $resentite["groupebase"];
$this->_dbuname = $resentite["genbase"];
$this->_tbusers = $resentite["table_users_gen"]; //! Spécifie la table des users de cette application, par défaut dans uof_frontal.users
if (!empty($_SERVER["HTTP_CLIENT_IP"])) {
$this->_clientIp = $_SERVER["HTTP_CLIENT_IP"];
} elseif (!empty($_SERVER["HTTP_X_FORWARDED_FOR"])) {
$this->_clientIp = $_SERVER["HTTP_X_FORWARDED_FOR"];
} else {
$this->_clientIp = $_SERVER["REMOTE_ADDR"];
}
// Active le debug uniquement pour dev et recette
if (strpos($http_host, 'dcleo.unikoffice.com') !== false || strpos($http_host, 'rcleo.unikoffice.com') !== false) {
ini_set('error_reporting', -1);
ini_set('display_errors', '1');
$this->_devIp = true;
// Configuration avancée du debug pour dev/recette
$this->_debug_level = 4; // Niveau debug complet
$this->_log_sql = true; // Logger les requêtes SQL
$this->_log_performance = true; // Mesurer les performances
$this->_log_file_path = dirname(__DIR__) . '/log/' . $this->_appname . '_debug_' . date('Y-m-d') . '.log';
// Options PHP supplémentaires pour le debug
ini_set('log_errors', '1');
ini_set('error_log', $this->_log_file_path);
ini_set('display_startup_errors', '1');
ini_set('track_errors', '1');
ini_set('html_errors', '1');
ini_set('xmlrpc_errors', '0');
} else {
ini_set('error_reporting', 0);
ini_set('display_errors', '0');
ini_set('log_errors', '0');
$this->_debug_level = 0;
$this->_log_sql = false;
$this->_log_performance = false;
}
}
}

245
controllers/cjxcontacts.php Normal file
View File

@@ -0,0 +1,245 @@
<?php
//! Page des requêtes AJAX pour la gestion des contacts clients
global $Session;
global $Route;
$fk_user = $Session->_user["rowid"];
switch ($Route->_action) {
case "load_contacts":
// Charge tous les contacts d'un client
$data = json_decode(file_get_contents("php://input"));
if (isset($data->fk_client)) {
$fk_client = nettoie_input($data->fk_client);
$fkClientSafe = intval($fk_client);
try {
$db = Database::getInstance();
$sql = 'SELECT rowid, nom, prenom, fonction, telephone, mobile, email, principal, active
FROM clients_contacts
WHERE fk_client = :fk_client AND active = 1
ORDER BY principal DESC, nom, prenom';
$contacts = $db->fetchAll($sql, [':fk_client' => $fkClientSafe]);
echo json_encode($contacts);
} catch (Exception $e) {
error_log("Erreur load_contacts : " . $e->getMessage());
echo json_encode(array('ret' => 'ko', 'msg' => 'Erreur lors du chargement des contacts'));
}
} else {
echo json_encode(array('ret' => 'ko', 'msg' => 'Client non spécifié'));
}
break;
case "load_contact":
// Charge un contact spécifique
$data = json_decode(file_get_contents("php://input"));
if (isset($data->rowid)) {
$rowid = nettoie_input($data->rowid);
$rowidSafe = intval($rowid);
try {
$db = Database::getInstance();
$sql = 'SELECT rowid, fk_client, nom, prenom, fonction, telephone, mobile, email, principal, active
FROM clients_contacts
WHERE rowid = :rowid';
$contact = $db->fetchAll($sql, [':rowid' => $rowidSafe]);
if (count($contact) == 1) {
echo json_encode($contact[0]);
} else {
echo json_encode(array('ret' => 'ko', 'msg' => 'Contact introuvable'));
}
} catch (Exception $e) {
error_log("Erreur load_contact : " . $e->getMessage());
echo json_encode(array('ret' => 'ko', 'msg' => 'Erreur lors du chargement du contact'));
}
} else {
echo json_encode(array('ret' => 'ko', 'msg' => 'Contact non spécifié'));
}
break;
case "save_contact":
// Crée ou met à jour un contact
if ($_POST) {
$rowid = nettoie_input($_POST["rowid"]);
$fk_client = nettoie_input($_POST["fk_client"]);
$nom = nettoie_input($_POST["nom"]);
$prenom = nettoie_input($_POST["prenom"]);
$fonction = nettoie_input($_POST["fonction"]);
$telephone = formattel(nettoie_input($_POST["telephone"]));
$mobile = formattel(nettoie_input($_POST["mobile"]));
$email = nettoie_input($_POST["email"]);
$principal = 0;
if (isset($_POST["principal"]) && $_POST["principal"] == "1") {
$principal = 1;
}
$fkClientSafe = intval($fk_client);
$fkUserSafe = intval($fk_user);
try {
$db = Database::getInstance();
if ($rowid == 0) {
// Création d'un nouveau contact
// Si ce contact est marqué comme principal, on retire le flag principal des autres contacts du client
if ($principal == 1) {
$sqlUpdate = 'UPDATE clients_contacts SET principal = 0 WHERE fk_client = :fk_client';
$db->query($sqlUpdate, [':fk_client' => $fkClientSafe]);
}
$data = [
'fk_client' => $fkClientSafe,
'nom' => $nom,
'prenom' => $prenom,
'fonction' => $fonction,
'telephone' => $telephone,
'mobile' => $mobile,
'email' => $email,
'principal' => $principal,
'active' => 1,
'date_creat' => date("Y-m-d H:i:s"),
'fk_user_creat' => $fkUserSafe
];
$newId = $db->insert('clients_contacts', $data);
eLog("Contact créé avec l'ID : " . $newId);
echo json_encode(array('ret' => 'ok', 'msg' => 'Contact créé avec succès', 'rowid' => $newId));
} else {
// Mise à jour d'un contact existant
$rowidSafe = intval($rowid);
// Si ce contact est marqué comme principal, on retire le flag principal des autres contacts du client
if ($principal == 1) {
$sqlUpdate = 'UPDATE clients_contacts SET principal = 0 WHERE fk_client = :fk_client AND rowid != :rowid';
$db->query($sqlUpdate, [':fk_client' => $fkClientSafe, ':rowid' => $rowidSafe]);
}
$sql = 'UPDATE clients_contacts
SET nom = :nom, prenom = :prenom, fonction = :fonction, telephone = :telephone,
mobile = :mobile, email = :email, principal = :principal,
date_modif = :date_modif, fk_user_modif = :fk_user_modif
WHERE rowid = :rowid';
$params = [
':nom' => $nom,
':prenom' => $prenom,
':fonction' => $fonction,
':telephone' => $telephone,
':mobile' => $mobile,
':email' => $email,
':principal' => $principal,
':date_modif' => date("Y-m-d H:i:s"),
':fk_user_modif' => $fkUserSafe,
':rowid' => $rowidSafe
];
$db->query($sql, $params);
eLog("Contact mis à jour : " . $rowidSafe);
echo json_encode(array('ret' => 'ok', 'msg' => 'Contact mis à jour avec succès', 'rowid' => $rowidSafe));
}
} catch (Exception $e) {
error_log("Erreur save_contact : " . $e->getMessage());
echo json_encode(array('ret' => 'ko', 'msg' => 'Erreur lors de l\'enregistrement du contact'));
}
}
break;
case "delete_contact":
// Supprime (désactive) un contact
$data = json_decode(file_get_contents("php://input"));
if (isset($data->rowid)) {
$rowid = nettoie_input($data->rowid);
$rowidSafe = intval($rowid);
try {
$db = Database::getInstance();
// Vérifier qu'il reste au moins un autre contact actif pour ce client
$sqlCheck = 'SELECT cc.fk_client, COUNT(*) as nb_contacts
FROM clients_contacts cc
WHERE cc.rowid = :rowid';
$result = $db->fetchAll($sqlCheck, [':rowid' => $rowidSafe]);
if (count($result) == 1) {
$fkClient = $result[0]['fk_client'];
// Compter les contacts actifs restants
$sqlCount = 'SELECT COUNT(*) as nb FROM clients_contacts WHERE fk_client = :fk_client AND active = 1 AND rowid != :rowid';
$countResult = $db->fetchAll($sqlCount, [':fk_client' => $fkClient, ':rowid' => $rowidSafe]);
if ($countResult[0]['nb'] == 0) {
echo json_encode(array('ret' => 'ko', 'msg' => 'Impossible de supprimer le dernier contact actif du client'));
} else {
// Désactiver le contact
$fkUserSafe = intval($fk_user);
$sql = 'UPDATE clients_contacts
SET active = 0, date_modif = :date_modif, fk_user_modif = :fk_user_modif
WHERE rowid = :rowid';
$params = [
':date_modif' => date("Y-m-d H:i:s"),
':fk_user_modif' => $fkUserSafe,
':rowid' => $rowidSafe
];
$db->query($sql, $params);
eLog("Contact désactivé : " . $rowidSafe);
echo json_encode(array('ret' => 'ok', 'msg' => 'Contact supprimé avec succès'));
}
} else {
echo json_encode(array('ret' => 'ko', 'msg' => 'Contact introuvable'));
}
} catch (Exception $e) {
error_log("Erreur delete_contact : " . $e->getMessage());
echo json_encode(array('ret' => 'ko', 'msg' => 'Erreur lors de la suppression du contact'));
}
} else {
echo json_encode(array('ret' => 'ko', 'msg' => 'Contact non spécifié'));
}
break;
case "set_principal":
// Définit un contact comme principal
$data = json_decode(file_get_contents("php://input"));
if (isset($data->rowid) && isset($data->fk_client)) {
$rowid = nettoie_input($data->rowid);
$fk_client = nettoie_input($data->fk_client);
$rowidSafe = intval($rowid);
$fkClientSafe = intval($fk_client);
try {
$db = Database::getInstance();
$fkUserSafe = intval($fk_user);
// Retirer le flag principal de tous les contacts du client
$sqlReset = 'UPDATE clients_contacts SET principal = 0 WHERE fk_client = :fk_client';
$db->query($sqlReset, [':fk_client' => $fkClientSafe]);
// Définir le contact comme principal
$sql = 'UPDATE clients_contacts
SET principal = 1, date_modif = :date_modif, fk_user_modif = :fk_user_modif
WHERE rowid = :rowid';
$params = [
':date_modif' => date("Y-m-d H:i:s"),
':fk_user_modif' => $fkUserSafe,
':rowid' => $rowidSafe
];
$db->query($sql, $params);
eLog("Contact principal défini : " . $rowidSafe);
echo json_encode(array('ret' => 'ok', 'msg' => 'Contact principal défini avec succès'));
} catch (Exception $e) {
error_log("Erreur set_principal : " . $e->getMessage());
echo json_encode(array('ret' => 'ko', 'msg' => 'Erreur lors de la définition du contact principal'));
}
} else {
echo json_encode(array('ret' => 'ko', 'msg' => 'Données manquantes'));
}
break;
default:
echo json_encode(array('ret' => 'ko', 'msg' => 'Action inconnue'));
break;
}
exit();

View File

@@ -54,7 +54,7 @@ switch ($Route->_action) {
//! 4. On met à jour la date_demande, date_remise, num_opportunite et fk_statut_devis du nouveau devis
$newRowidSafe = intval($newRowid);
$sql = 'UPDATE devis SET date_demande = "' . date("Y-m-d H:i:s") . '", date_remise = "", num_opportunite = "", fk_statut_devis = 1 WHERE rowid = ' . $newRowidSafe . ';';
$sql = 'UPDATE devis SET date_demande = "' . date("Y-m-d H:i:s") . '", date_remise = NULL, num_opportunite = "", fk_statut_devis = 1 WHERE rowid = ' . $newRowidSafe . ';';
eLog($sql);
qSQL($sql, "gen");
@@ -124,14 +124,16 @@ switch ($Route->_action) {
$sql = 'UPDATE devis SET chk_maj = 0 WHERE rowid = ' . $cidSafe . ';';
qSQL($sql, "gen");
}
$sql = 'SELECT d.rowid, d.fk_user, d.fk_client, d.fk_marche, m.libelle AS lib_marche, d.fk_statut_devis, d.dossier, d.num_opportunite, d.montant_total_ht, ';
$sql = 'SELECT d.rowid, d.fk_user, d.fk_client, d.fk_contact, d.fk_marche, m.libelle AS lib_marche, d.fk_statut_devis, d.dossier, d.num_opportunite, d.montant_total_ht, ';
$sql .= 'd.date_demande, d.date_remise, d.montant_total_ht_remise, d.marge_totale, d.commentaire, d.comment_devis, d.chk_clients_secteur, d.chk_speciaux, ';
$sql .= 'd.lib_new_client, d.type_new_client, d.adresse1_new_client, d.adresse2_new_client, d.adresse3_new_client, d.cp_new_client, d.ville_new_client,';
$sql .= 'd.contact_new_nom, d.contact_new_prenom, d.contact_new_fonction, d.new_telephone, d.new_mobile, d.new_email, ';
$sql .= 'd.comment_geste_comm, d.comment_validat, d.chk_validat, d.fk_user_validat, d.date_validat, ';
$sql .= 'xs.libelle as lib_statut_devis, c.code, c.libelle, c.adresse1, c.adresse2, c.adresse3, c.cp, c.ville, c.type_client, ';
$sql .= 'c.contact_nom, c.contact_prenom, c.contact_fonction, c.telephone, c.mobile, c.email, d.chk_devis_photos ';
$sql .= 'FROM devis d LEFT JOIN clients c ON d.fk_client = c.rowid LEFT JOIN x_statuts_devis xs ON d.fk_statut_devis = xs.rowid ';
$sql .= 'cc.rowid as contact_rowid, cc.nom as contact_nom, cc.prenom as contact_prenom, cc.fonction as contact_fonction, cc.telephone, cc.mobile, cc.email, d.chk_devis_photos ';
$sql .= 'FROM devis d LEFT JOIN clients c ON d.fk_client = c.rowid ';
$sql .= 'LEFT JOIN clients_contacts cc ON d.fk_contact = cc.rowid ';
$sql .= 'LEFT JOIN x_statuts_devis xs ON d.fk_statut_devis = xs.rowid ';
$sql .= 'LEFT JOIN marches m ON d.fk_marche = m.rowid ';
$sql .= 'WHERE d.rowid = ' . $cidSafe . ';';
echo getinfos($sql, "gen", "json");
@@ -162,7 +164,7 @@ switch ($Route->_action) {
if (isset($data->secteur)) {
$chkSecteur = nettoie_input($data->secteur);
$fkUser = nettoie_input($data->user);
$sql = 'SELECT rowid, libelle, CONCAT(libelle, ", ", adresse1, ", ", cp, " ", ville) AS rech, adresse1, adresse2, adresse3, cp, ville, contact_nom, contact_prenom, contact_fonction, telephone, mobile, email, type_client FROM clients WHERE active=1 ';
$sql = 'SELECT rowid, code, libelle, CONCAT(libelle, ", ", adresse1, ", ", cp, " ", ville) AS rech, adresse1, adresse2, adresse3, cp, ville, contact_nom, contact_prenom, contact_fonction, telephone, mobile, email, type_client FROM clients WHERE active=1 ';
if ($chkSecteur == "1") {
//! on ne prend que les clients du secteur de l'utilisateur
$fkUserSafe = intval($fkUser);
@@ -197,6 +199,32 @@ switch ($Route->_action) {
}
break;
case "load_produits_mercurial":
//! Charge les produits du marché hybride pour l'onglet Mercurial
$data = json_decode(file_get_contents("php://input"));
if (isset($data->fk_marche)) {
$fkMarche = nettoie_input($data->fk_marche);
$fkMarcheSafe = intval($fkMarche);
// Vérifier que le marché est bien hybride
$sql = 'SELECT chk_marche_hybride FROM marches WHERE rowid = ' . $fkMarcheSafe . ';';
$retSql = getinfos($sql, "gen");
if (count($retSql) == 1 && $retSql[0]["chk_marche_hybride"] == 1) {
// Le marché est hybride, on charge tous les produits de ce marché (hors 999)
$sql = 'SELECT p.*, CONCAT(p.code, " - ", p.libelle) AS rech, pf.fk_famille, xf.libelle AS lib_famille ';
$sql .= 'FROM produits p LEFT JOIN produits_familles pf ON p.groupe=pf.groupe LEFT JOIN x_familles xf on pf.fk_famille = xf.rowid ';
$sql .= 'WHERE p.fk_marche = ' . $fkMarcheSafe . ' AND p.fk_marche != 999 AND p.active=1 ORDER BY xf.ordre, pf.ordre;';
echo getinfos($sql, "gen", "json");
} else {
// Le marché n'est pas hybride, on retourne un tableau vide
echo json_encode(array());
}
} else {
echo json_encode(array());
}
break;
case "load_devis_marche_produits":
//! Charge les produits enregistrés pour un marché
$data = json_decode(file_get_contents("php://input"));
@@ -234,7 +262,7 @@ switch ($Route->_action) {
$sql = 'SELECT p.*, CONCAT(p.code, " - ", p.libelle) AS rech, pf.fk_famille, xf.libelle AS lib_famille, "0" AS chk_prix_net ';
$sql .= 'FROM produits p LEFT JOIN produits_familles pf ON p.groupe=pf.groupe LEFT JOIN x_familles xf on pf.fk_famille = xf.rowid ';
$sql .= 'WHERE p.fk_marche = ' . $cidSafe . ' AND p.active=1 ORDER BY xf.ordre, pf.ordre;';
$sql .= 'WHERE p.fk_marche = ' . intval($cid) . ' AND p.active=1 ORDER BY xf.ordre, pf.ordre;';
$upls = getinfos($sql, "gen");
if ($cid != "999") {
@@ -390,31 +418,13 @@ switch ($Route->_action) {
$commentaire = nettoie_input($_POST["commentaire"]);
$newCommentaire = 0;
$contact_nom = nettoie_input($_POST["contact_nom"]);
$contact_prenom = nettoie_input($_POST["contact_prenom"]);
$contact_fonction = nettoie_input($_POST["contact_fonction"]);
$email = nettoie_input($_POST["email"]);
$telephone = formattel(nettoie_input($_POST["telephone"]));
$mobile = formattel(nettoie_input($_POST["mobile"]));
// Récupération du contact sélectionné
$fk_contact = isset($_POST["fk_contact"]) ? intval($_POST["fk_contact"]) : 0;
if ($fk_contact == 0) $fk_contact = NULL;
$set = 'fk_client=' . $fk_client . ', num_opportunite="' . $num_opportunite . '", date_demande="' . $date_demande . '", date_remise="' . $date_remise . '", ';
$set = 'fk_client=' . $fk_client . ', fk_contact=' . ($fk_contact === NULL ? 'NULL' : $fk_contact) . ', num_opportunite="' . $num_opportunite . '", date_demande="' . $date_demande . '", date_remise="' . $date_remise . '", ';
$set .= 'fk_user=' . $fk_user . ', fk_marche=' . $fk_marche . ', commentaire="' . $commentaire . '", chk_devis_photos=' . $chk_devis_photos . ', ';
if ($fk_client == 0) {
//! C'est un nouveau client : on enregistre ces infos et celle du contact dans le devis et non au niveau de la table clients
$libNewClient = nettoie_input($_POST["lib_client"]);
$typNewClient = nettoie_input($_POST["type_client"]);
$adr1NewClient = nettoie_input($_POST["adresse1"]);
$adr2NewClient = nettoie_input($_POST["adresse2"]);
$adr3NewClient = nettoie_input($_POST["adresse3"]);
$cpNewClient = nettoie_input($_POST["cp"]);
// Si le CP a une longueur de 4, on rajoute un 0 devant
if (strlen($cpNewClient) == 4) $cpNewClient = "0" . $cpNewClient;
$villeNewClient = nettoie_input($_POST["ville"]);
$set .= 'lib_new_client="' . $libNewClient . '", type_new_client="' . $typNewClient . '", adresse1_new_client="' . $adr1NewClient . '", adresse2_new_client="' . $adr2NewClient . '", adresse3_new_client="' . $adr3NewClient . '", cp_new_client="' . $cpNewClient . '", ville_new_client="' . $villeNewClient . '", ';
$set .= 'contact_new_nom="' . $contact_nom . '", contact_new_prenom="' . $contact_prenom . '", contact_new_fonction="' . $contact_fonction . '", new_email="' . $email . '", new_telephone="' . $telephone . '", new_mobile="' . $mobile . '", ';
}
if ($_POST["rowid"] == 0) {
//! C'est un nouveau devis
//! On le range dans un dossier
@@ -465,14 +475,8 @@ switch ($Route->_action) {
}
eLog('Entete Devis Save : ' . $sql);
if ($fk_client != "0") {
//! On sauvegarde aussi les infos complémentaires du client qui peuvent ête mises à jour
$sql = 'UPDATE clients SET contact_nom="' . $contact_nom . '", contact_prenom="' . $contact_prenom . '", contact_fonction="' . $contact_fonction . '", ';
$fkClientSafe = intval($fk_client);
$sql .= 'email="' . $email . '", telephone="' . $telephone . '", mobile="' . $mobile . '" WHERE rowid=' . $fkClientSafe . ';';
eLog('Entete Devis Save infos client : ' . $sql);
qSQL($sql, "gen");
}
// NOTE: Les contacts sont maintenant gérés via la table clients_contacts
// et non plus directement dans la table clients
// On inscrit l'enregistrement dans le journal si il y a eu un changement de commentaire ou bien si c'est une création avec commentaire
if ($newCommentaire > 0) {
@@ -651,11 +655,8 @@ switch ($Route->_action) {
try {
$db = Database::getInstance();
$sql = 'SELECT rowid, code, libelle, prix_vente FROM produits WHERE active=1 AND (code LIKE :term OR libelle LIKE :term) ORDER BY code';
$stmt = $db->prepare($sql);
$termParam = '%' . $term . '%';
$stmt->bindParam(':term', $termParam, PDO::PARAM_STR);
$stmt->execute();
$upls = $stmt->fetchAll(PDO::FETCH_ASSOC);
$upls = $db->fetchAll($sql, [':term' => $termParam]);
} catch (Exception $e) {
error_log("Erreur recherche produits : " . $e->getMessage());
$upls = [];
@@ -1303,5 +1304,69 @@ switch ($Route->_action) {
echo json_encode(array("success" => "true", "message" => "Devis refusé avec succès"));
}
break;
case "save_new_client":
$data = json_decode(file_get_contents("php://input"));
if (isset($data->libelle)) {
try {
$db = Database::getInstance();
$libelle = nettoie_input($data->libelle);
$typeClient = nettoie_input($data->type_client);
$adresse1 = nettoie_input($data->adresse1);
$adresse2 = nettoie_input($data->adresse2);
$adresse3 = nettoie_input($data->adresse3);
$cp = nettoie_input($data->cp);
if (strlen($cp) == 4) $cp = "0" . $cp;
$ville = nettoie_input($data->ville);
$fkUserSafe = intval($fk_user);
$sqlMaxCode = 'SELECT MAX(code) as max_code FROM clients';
$resultCode = $db->fetchOne($sqlMaxCode);
$newCode = ($resultCode && $resultCode['max_code']) ? intval($resultCode['max_code']) + 1 : 1;
$sql = 'INSERT INTO clients SET code = :code, libelle = :libelle, type_client = :type_client, adresse1 = :adresse1, adresse2 = :adresse2, adresse3 = :adresse3, ';
$sql .= 'cp = :cp, ville = :ville, fk_user_creat = :fk_user_creat, date_creat = NOW(), active = 1';
$params = [
':code' => $newCode,
':libelle' => $libelle,
':type_client' => $typeClient,
':adresse1' => $adresse1,
':adresse2' => $adresse2,
':adresse3' => $adresse3,
':cp' => $cp,
':ville' => $ville,
':fk_user_creat' => $fkUserSafe
];
$db->query($sql, $params);
$newClientId = $db->lastInsertId();
if ($newClientId > 0) {
$sql = 'INSERT INTO clients_contacts SET fk_client = :fk_client, nom = :nom, prenom = :prenom, principal = 1, active = 1, date_creat = NOW()';
$params = [
':fk_client' => $newCode,
':nom' => 'À compléter',
':prenom' => ''
];
$db->query($sql, $params);
eLog("Nouveau client créé : ID=" . $newClientId . ", code=" . $newCode);
echo json_encode(array("success" => true, "rowid" => $newClientId, "code" => $newCode, "message" => "Client créé avec succès"));
} else {
eLog("save_new_client ERREUR: newClientId = 0");
echo json_encode(array("success" => false, "message" => "Erreur lors de la création du client - ID non récupéré"));
}
} catch (Exception $e) {
$errorMsg = $e->getMessage();
error_log("Erreur création client : " . $errorMsg);
eLog("save_new_client EXCEPTION: " . $errorMsg);
echo json_encode(array("success" => false, "message" => "Erreur : " . $errorMsg));
}
}
break;
}
exit();

View File

@@ -140,10 +140,7 @@ switch ($Route->_action) {
try {
$db = Database::getInstance();
$sql = 'SELECT c.* FROM clients c WHERE c.code = :code';
$stmt = $db->prepare($sql);
$stmt->bindParam(':code', $code, PDO::PARAM_STR);
$stmt->execute();
$record = $stmt->fetchAll(PDO::FETCH_ASSOC);
$record = $db->fetchAll($sql, [':code' => $code]);
} catch (Exception $e) {
error_log("Erreur recherche client : " . $e->getMessage());
$record = [];
@@ -155,8 +152,7 @@ switch ($Route->_action) {
$db = Database::getInstance();
$sql = 'INSERT INTO clients SET code = :code, libelle = :libelle, siret = :siret, adresse1 = :adresse1, adresse2 = :adresse2, adresse3 = :adresse3, cp = :cp, ville = :ville, ';
$sql .= 'type_client = :type_client, contact_nom = :contact_nom, contact_prenom = :contact_prenom, contact_fonction = :contact_fonction, telephone = :telephone, mobile = :mobile, email = :email, chk_import = 1';
$stmt = $db->prepare($sql);
$stmt->execute([
$stmt = $db->query($sql, [
':code' => $code,
':libelle' => $libelle,
':siret' => $siret,
@@ -198,8 +194,7 @@ switch ($Route->_action) {
$sql = 'UPDATE clients SET libelle = :libelle, siret = :siret, adresse1 = :adresse1, adresse2 = :adresse2, adresse3 = :adresse3, cp = :cp, ville = :ville, ';
$sql .= 'type_client = :type_client, contact_nom = :contact_nom, contact_prenom = :contact_prenom, contact_fonction = :contact_fonction, telephone = :telephone, mobile = :mobile, email = :email, chk_import = 1 ';
$sql .= 'WHERE code = :code';
$stmt = $db->prepare($sql);
$stmt->execute([
$stmt = $db->query($sql, [
':libelle' => $libelle,
':siret' => $siret,
':adresse1' => $adresse1,
@@ -378,12 +373,18 @@ switch ($Route->_action) {
$libelle = str_replace('"', '', trim($data[1])); // on remplace les doubles guillemets par rien
// on réencode en ISO 8859-1 pour éviter les problèmes d'accent
if ($codOrigin == "UTF-8") {
$libelle = utf8_decode($libelle);
}
if ($codOrigin != "ISO-8859-1") {
// Convertir en ISO 8859-1
$libelle = iconv($codOrigin, "ISO-8859-15//IGNORE", $libelle);
// Utiliser mb_convert_encoding au lieu de utf8_decode (déprécié en PHP 8.3)
$libelle = mb_convert_encoding($libelle, "ISO-8859-1", "UTF-8");
} elseif ($codOrigin == "Windows-1252" || $codOrigin == "CP1252") {
// Windows-1252 est très proche de ISO-8859-1
// On traite le texte tel quel car les caractères de base sont compatibles
// Seulement quelques caractères spéciaux diffèrent (€, œ, etc.)
$libelle = $libelle; // Pas de conversion, on garde tel quel
} elseif ($codOrigin == "ISO-8859-15") {
// ISO-8859-15 est compatible avec ISO-8859-1 sauf pour le symbole €
$libelle = str_replace('€', 'EUR', $libelle);
}
// Pour ISO-8859-1, on ne fait rien, c'est déjà le bon format
$groupe = str_replace(" ", " ", trim($data[2])); // on remplace les doubles espaces par un simple espace
$liste = trim($data[3]);
@@ -532,7 +533,10 @@ switch ($Route->_action) {
$ret = array('ret' => "ko", 'msg' => "Aucun fichier à importer");
} else {
if ($erreur == "") {
$ret = array('ret' => "ok", 'msg' => "L'importation est terminée et s'est bien déroulée");
$nbLignes = isset($row) ? $row - 1 : 0; // On enlève la ligne d'en-tête
$nbMarches = isset($idMarches) ? count($idMarches) : 0;
$msgMarches = $nbMarches > 1 ? " pour " . $nbMarches . " marchés" : ($nbMarches == 1 ? " pour 1 marché" : "");
$ret = array('ret' => "ok", 'msg' => "Import terminé : " . $nbLignes . " produits traités" . $msgMarches);
} else {
$ret = array('ret' => "ko", 'msg' => $erreur);
}

View File

@@ -211,9 +211,7 @@ switch ($Route->_action) {
$db = Database::getInstance();
// SÉCURITÉ : Utilisation de requête préparée pour l'ID
$sql = "SELECT `$chp` AS data FROM clients WHERE rowid = :id";
$stmt = $db->prepare($sql);
$stmt->execute([':id' => intval($fk_tiers)]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
$result = $db->fetchOne($sql, [':id' => intval($fk_tiers)]);
if ($result) {
$upls = $result;
}
@@ -290,12 +288,8 @@ switch ($Route->_action) {
OR c.email LIKE :search
ORDER BY c.libelle';
$stmt = $db->prepare($sql);
$searchParam = '%' . $search . '%';
$stmt->bindParam(':search', $searchParam, PDO::PARAM_STR);
$stmt->execute();
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
$results = $db->fetchAll($sql, [':search' => $searchParam]);
echo json_encode($results);
} catch (Exception $e) {
error_log("Erreur search_clients : " . $e->getMessage());
@@ -522,16 +516,13 @@ switch ($Route->_action) {
// Utilisation de requêtes préparées pour la suppression
$sql1 = 'DELETE FROM marches WHERE rowid = :id';
$stmt1 = $db->prepare($sql1);
$stmt1->execute(['id' => $cid]);
$db->query($sql1, ['id' => $cid]);
$sql2 = 'DELETE FROM marches_listes WHERE fk_marche = :id';
$stmt2 = $db->prepare($sql2);
$stmt2->execute(['id' => $cid]);
$db->query($sql2, ['id' => $cid]);
$sql3 = 'DELETE FROM produits WHERE fk_marche = :id';
$stmt3 = $db->prepare($sql3);
$stmt3->execute(['id' => $cid]);
$db->query($sql3, ['id' => $cid]);
eLog("Marché supprimé : ID=$cid");
$ret = array('ret' => "ok", 'msg' => 'Marché supprimé');
@@ -785,9 +776,7 @@ switch ($Route->_action) {
try {
$db = Database::getInstance();
$sql = 'SELECT c.code, c.libelle, c.adresse1, c.adresse2 FROM clients c WHERE c.rowid = :id';
$stmt = $db->prepare($sql);
$stmt->execute([':id' => intval($devis["fk_client"])]);
$client = $stmt->fetch(PDO::FETCH_ASSOC);
$client = $db->fetchOne($sql, [':id' => intval($devis["fk_client"])]);
if (!$client) {
$client = ['code' => '', 'libelle' => '', 'adresse1' => '', 'adresse2' => ''];
}
@@ -884,8 +873,8 @@ switch ($Route->_action) {
try {
$db = Database::getInstance();
$sql = 'DELETE FROM infos WHERE rowid = :id';
$stmt = $db->prepare($sql);
$result = $stmt->execute(['id' => $cid]);
$stmt = $db->query($sql, ['id' => $cid]);
$result = $stmt->rowCount() > 0;
if ($result) {
eLog("Info supprimée : ID=$cid");

View File

@@ -1,25 +1,53 @@
#!/bin/bash
# Script de déploiement optimisé de Cleo vers l'environnement de développement
# Version: 2.0 - Utilise tar.gz pour un transfert plus rapide
# Script de déploiement optimisé de Cleo
# Version: 2.1 - Supporte déploiement DEV et PROD
# Usage: ./deploy-cleo.sh [pra]
# Sans argument : Déploie depuis local vers IN3/dva-front (DEV)
# Avec 'pra' : Déploie depuis IN3/dva-front vers IN4/pra-front (PROD)
cd /home/pierre/dev/cleo
# Configuration du serveur hôte Debian 12
HOST_SSH_HOST=195.154.80.116 # Adresse IP du serveur hôte
HOST_SSH_USER=root # Utilisateur SSH sur le serveur hôte
HOST_SSH_PORT=22 # Port SSH du serveur hôte
HOST_SSH_KEY=/home/pierre/.ssh/id_rsa_mbpi # Clé SSH privée pour accéder au serveur hôte
# Détecter le mode de déploiement
DEPLOY_MODE=${1:-dev}
# Configuration du conteneur Incus hébergeant cette application
CT_PROJECT_NAME=default # Nom du projet Incus où se trouve le conteneur
CT_NAME=dva-front # Nom du conteneur Incus
CT_IP=13.23.33.42 # IP interne du conteneur Incus
DEPLOY_DIR=/var/www # Répertoire de déploiement sur le conteneur
APP_NAME=cleo # Nom de l'application
if [ "$DEPLOY_MODE" = "pra" ]; then
# Configuration PROD : IN3/dva-front → IN4/pra-front
SOURCE_HOST=11.1.2.1
SOURCE_SSH_USER=root
SOURCE_SSH_PORT=22
SOURCE_SSH_KEY=/home/pierre/.ssh/id_rsa_mbpi
SOURCE_CT_PROJECT=default
SOURCE_CT_NAME=dva-front
SOURCE_DEPLOY_DIR=/var/www/cleo
TARGET_HOST=11.1.2.14
TARGET_SSH_USER=root
TARGET_SSH_PORT=22
TARGET_SSH_KEY=/home/pierre/.ssh/id_rsa_mbpi
TARGET_CT_PROJECT=default
TARGET_CT_NAME=pra-front
TARGET_DEPLOY_DIR=/var/www/cleo
APP_NAME=cleo
SKIP_ENV=true
else
# Configuration DEV : local → IN3/dva-front
HOST_SSH_HOST=195.154.80.116
HOST_SSH_USER=root
HOST_SSH_PORT=22
HOST_SSH_KEY=/home/pierre/.ssh/id_rsa_mbpi
CT_PROJECT_NAME=default
CT_NAME=dva-front
CT_IP=13.23.33.42
DEPLOY_DIR=/var/www
APP_NAME=cleo
SKIP_ENV=false
fi
# Propriétaire et groupe pour les fichiers et dossiers de destination
OWNER=nginx
OWNER=nobody
GROUP=nginx
# Couleurs pour l'affichage
@@ -36,53 +64,181 @@ fi
# Afficher les paramètres
echo -e "${GREEN}=== Déploiement optimisé CLEO ===${NC}"
echo "Serveur hôte: $HOST_SSH_USER@$HOST_SSH_HOST:$HOST_SSH_PORT"
echo "Conteneur: $CT_NAME"
echo "Déploiement: $DEPLOY_DIR/$APP_NAME"
if [ "$DEPLOY_MODE" = "pra" ]; then
echo "Mode: PRODUCTION (DEV → PROD)"
echo "Source: $SOURCE_CT_NAME ($SOURCE_DEPLOY_DIR)"
echo "Destination: $TARGET_CT_NAME ($TARGET_DEPLOY_DIR)"
echo "Note: Le fichier .env ne sera PAS écrasé"
else
echo "Mode: DÉVELOPPEMENT (Local → DEV)"
echo "Serveur hôte: $HOST_SSH_USER@$HOST_SSH_HOST:$HOST_SSH_PORT"
echo "Conteneur: $CT_NAME"
echo "Déploiement: $DEPLOY_DIR/$APP_NAME"
fi
echo "=================================="
# 1. Créer l'archive tar.gz localement
echo -e "${YELLOW}1. Création de l'archive...${NC}"
tar -czf /tmp/cleo.tar.gz \
if [ "$DEPLOY_MODE" = "pra" ]; then
# ===== MODE PROD: Déploiement IN3/dva-front → IN4/pra-front =====
echo -e "${YELLOW}1. Création de l'archive depuis IN3/dva-front...${NC}"
# Définir les options SSH pour source et target
SOURCE_SSH_OPTS="-p $SOURCE_SSH_PORT -i $SOURCE_SSH_KEY"
TARGET_SSH_OPTS="-p $TARGET_SSH_PORT -i $TARGET_SSH_KEY"
# Définir les options SCP (port avec -P majuscule)
SOURCE_SCP_OPTS="-P $SOURCE_SSH_PORT -i $SOURCE_SSH_KEY"
TARGET_SCP_OPTS="-P $TARGET_SSH_PORT -i $TARGET_SSH_KEY"
# Script sur IN3 pour créer l'archive
SOURCE_SCRIPT="
set -e
incus project switch $SOURCE_CT_PROJECT
echo 'Création de l archive depuis dva-front...'
incus exec $SOURCE_CT_NAME -- sh -c '
cd $SOURCE_DEPLOY_DIR && \
tar -czf /tmp/cleo-prod.tar.gz \
--exclude=\".git\" \
--exclude=\"log/*.log\" \
--exclude=\"pub/files/upload/*\" \
--exclude=\"docs\" \
--exclude=\"migration\" \
--exclude=\".claude\" \
--exclude=\".vscode\" \
--exclude=\"vendor\" \
--exclude=\"backup_*\" \
--exclude=\"*.tar.gz\" \
--exclude=\".env\" \
--exclude=\".env.swp\" \
.
'
incus file pull $SOURCE_CT_NAME/tmp/cleo-prod.tar.gz /tmp/
incus exec $SOURCE_CT_NAME -- rm -f /tmp/cleo-prod.tar.gz
"
ssh $SOURCE_SSH_OPTS $SOURCE_SSH_USER@$SOURCE_HOST "$SOURCE_SCRIPT"
if [ $? -ne 0 ]; then
echo -e "${RED}Erreur lors de la création de l'archive${NC}"
exit 1
fi
echo -e "${GREEN}✓ Archive créée sur IN3${NC}"
# Transférer l'archive de IN3 vers IN4
echo -e "${YELLOW}2. Transfert de l'archive IN3 → IN4...${NC}"
scp $SOURCE_SCP_OPTS $SOURCE_SSH_USER@$SOURCE_HOST:/tmp/cleo-prod.tar.gz /tmp/
scp $TARGET_SCP_OPTS /tmp/cleo-prod.tar.gz $TARGET_SSH_USER@$TARGET_HOST:/tmp/
if [ $? -ne 0 ]; then
echo -e "${RED}Erreur lors du transfert de l'archive${NC}"
rm -f /tmp/cleo-prod.tar.gz
exit 1
fi
echo -e "${GREEN}✓ Archive transférée vers IN4${NC}"
# Déployer sur IN4/pra-front
echo -e "${YELLOW}3. Déploiement dans IN4/pra-front...${NC}"
TARGET_SCRIPT="
set -e
incus project switch $TARGET_CT_PROJECT
echo 'Transfert de l archive dans pra-front...'
incus file push /tmp/cleo-prod.tar.gz $TARGET_CT_NAME/tmp/
echo 'Déploiement dans pra-front (backup .env)...'
incus exec $TARGET_CT_NAME -- sh -c '
cp $TARGET_DEPLOY_DIR/.env /tmp/.env.backup && \
cd $TARGET_DEPLOY_DIR && \
tar -xzf /tmp/cleo-prod.tar.gz && \
mv /tmp/.env.backup $TARGET_DEPLOY_DIR/.env && \
rm -f /tmp/cleo-prod.tar.gz
'
echo 'Configuration des permissions...'
incus exec $TARGET_CT_NAME -- sh -c '
chown -R $OWNER:$GROUP $TARGET_DEPLOY_DIR && \
find $TARGET_DEPLOY_DIR -type d -exec chmod 755 {} \; && \
find $TARGET_DEPLOY_DIR -type f -exec chmod 644 {} \; && \
mkdir -p $TARGET_DEPLOY_DIR/log && \
chmod 775 $TARGET_DEPLOY_DIR/log && \
touch $TARGET_DEPLOY_DIR/log/\$(date +%m%d).log && \
chmod 664 $TARGET_DEPLOY_DIR/log/\$(date +%m%d).log && \
chown $OWNER:$GROUP $TARGET_DEPLOY_DIR/log/*.log && \
chmod 640 $TARGET_DEPLOY_DIR/.env && \
if [ -d $TARGET_DEPLOY_DIR/pub/files/upload ]; then
chmod 775 $TARGET_DEPLOY_DIR/pub/files/upload && \
chown $OWNER:$GROUP $TARGET_DEPLOY_DIR/pub/files/upload
fi
'
rm -f /tmp/cleo-prod.tar.gz
echo 'Déploiement PROD terminé!'
"
ssh $TARGET_SSH_OPTS $TARGET_SSH_USER@$TARGET_HOST "$TARGET_SCRIPT"
if [ $? -ne 0 ]; then
echo -e "${RED}Erreur lors du déploiement PROD${NC}"
rm -f /tmp/cleo-prod.tar.gz
ssh $SOURCE_SSH_OPTS $SOURCE_SSH_USER@$SOURCE_HOST "rm -f /tmp/cleo-prod.tar.gz"
exit 1
fi
# Nettoyer les archives locales et distantes
echo -e "${YELLOW}4. Nettoyage...${NC}"
rm -f /tmp/cleo-prod.tar.gz
ssh $SOURCE_SSH_OPTS $SOURCE_SSH_USER@$SOURCE_HOST "rm -f /tmp/cleo-prod.tar.gz"
echo -e "${GREEN}✓ Nettoyage terminé${NC}"
else
# ===== MODE DEV: Déploiement local → IN3/dva-front =====
# 1. Créer l'archive tar.gz localement
echo -e "${YELLOW}1. Création de l'archive...${NC}"
tar -czf /tmp/cleo.tar.gz \
--exclude='.git' \
--exclude='log/*.log' \
--exclude='pub/files/upload/*' \
--exclude='docs/*.sql' \
--exclude='docs' \
--exclude='migration' \
--exclude='.claude' \
--exclude='.vscode' \
--exclude='vendor' \
--exclude='backup_*' \
--exclude='*.tar.gz' \
--exclude='.env' \
--exclude='.env.swp' \
.
if [ $? -ne 0 ]; then
if [ $? -ne 0 ]; then
echo -e "${RED}Erreur lors de la création de l'archive${NC}"
exit 1
fi
fi
ARCHIVE_SIZE=$(du -h /tmp/cleo.tar.gz | cut -f1)
echo -e "${GREEN}✓ Archive créée: /tmp/cleo.tar.gz ($ARCHIVE_SIZE)${NC}"
ARCHIVE_SIZE=$(du -h /tmp/cleo.tar.gz | cut -f1)
echo -e "${GREEN}✓ Archive créée: /tmp/cleo.tar.gz ($ARCHIVE_SIZE)${NC}"
# 2. Copier l'archive vers IN3
echo -e "${YELLOW}2. Transfert de l'archive vers IN3...${NC}"
scp -P $HOST_SSH_PORT -i $HOST_SSH_KEY /tmp/cleo.tar.gz $HOST_SSH_USER@$HOST_SSH_HOST:/tmp/
# 2. Copier l'archive vers IN3
echo -e "${YELLOW}2. Transfert de l'archive vers IN3...${NC}"
scp -P $HOST_SSH_PORT -i $HOST_SSH_KEY /tmp/cleo.tar.gz $HOST_SSH_USER@$HOST_SSH_HOST:/tmp/
if [ $? -ne 0 ]; then
if [ $? -ne 0 ]; then
echo -e "${RED}Erreur lors du transfert de l'archive${NC}"
rm -f /tmp/cleo.tar.gz
exit 1
fi
echo -e "${GREEN}✓ Archive transférée sur IN3${NC}"
fi
echo -e "${GREEN}✓ Archive transférée sur IN3${NC}"
# 3. Nettoyer l'ancien répertoire /tmp/cleo sur IN3 s'il existe
echo -e "${YELLOW}3. Nettoyage des anciens fichiers temporaires sur IN3...${NC}"
ssh $SSH_OPTS $HOST_SSH_USER@$HOST_SSH_HOST "rm -rf /tmp/cleo 2>/dev/null || true"
echo -e "${GREEN}✓ Nettoyage effectué${NC}"
# 3. Nettoyer l'ancien répertoire /tmp/cleo sur IN3 s'il existe
echo -e "${YELLOW}3. Nettoyage des anciens fichiers temporaires sur IN3...${NC}"
ssh $SSH_OPTS $HOST_SSH_USER@$HOST_SSH_HOST "rm -rf /tmp/cleo 2>/dev/null || true"
echo -e "${GREEN}✓ Nettoyage effectué${NC}"
# 4. Transférer et extraire dans le conteneur
echo -e "${YELLOW}4. Déploiement dans le conteneur $CT_NAME...${NC}"
# 4. Transférer et extraire dans le conteneur
echo -e "${YELLOW}4. Déploiement dans le conteneur $CT_NAME...${NC}"
# Script à exécuter sur IN3
REMOTE_SCRIPT="
# Script à exécuter sur IN3
REMOTE_SCRIPT="
set -e
# Sélectionner le projet Incus
@@ -108,11 +264,11 @@ incus exec $CT_NAME -- sh -c '
find $DEPLOY_DIR/$APP_NAME -type d -exec chmod 755 {} \; && \
find $DEPLOY_DIR/$APP_NAME -type f -exec chmod 644 {} \; && \
mkdir -p $DEPLOY_DIR/$APP_NAME/log && \
chmod 777 $DEPLOY_DIR/$APP_NAME/log && \
touch $DEPLOY_DIR/$APP_NAME/log/$(date +%m%d).log && \
chmod 777 $DEPLOY_DIR/$APP_NAME/log/$(date +%m%d).log && \
chown nobody:nobody $DEPLOY_DIR/$APP_NAME/log/*.log && \
chmod 644 $DEPLOY_DIR/$APP_NAME/.env && \
chmod 775 $DEPLOY_DIR/$APP_NAME/log && \
touch $DEPLOY_DIR/$APP_NAME/log/\$(date +%m%d).log && \
chmod 664 $DEPLOY_DIR/$APP_NAME/log/\$(date +%m%d).log && \
chown $OWNER:$GROUP $DEPLOY_DIR/$APP_NAME/log/*.log && \
chmod 640 $DEPLOY_DIR/$APP_NAME/.env && \
rm -f $DEPLOY_DIR/$APP_NAME/.env.swp && \
if [ -d $DEPLOY_DIR/$APP_NAME/pub/files/upload ]; then
chmod 775 $DEPLOY_DIR/$APP_NAME/pub/files/upload && \
@@ -126,26 +282,34 @@ rm -f /tmp/cleo.tar.gz
echo 'Déploiement terminé avec succès!'
"
# Exécuter le script sur IN3
ssh $SSH_OPTS $HOST_SSH_USER@$HOST_SSH_HOST "$REMOTE_SCRIPT"
# Exécuter le script sur IN3
ssh $SSH_OPTS $HOST_SSH_USER@$HOST_SSH_HOST "$REMOTE_SCRIPT"
if [ $? -ne 0 ]; then
if [ $? -ne 0 ]; then
echo -e "${RED}Erreur lors du déploiement${NC}"
# Nettoyer l'archive locale et distante
rm -f /tmp/cleo.tar.gz
ssh $SSH_OPTS $HOST_SSH_USER@$HOST_SSH_HOST "rm -f /tmp/cleo.tar.gz 2>/dev/null || true"
exit 1
fi
fi
# 5. Nettoyer l'archive locale
echo -e "${YELLOW}5. Nettoyage local...${NC}"
rm -f /tmp/cleo.tar.gz
echo -e "${GREEN}✓ Archive locale supprimée${NC}"
# 5. Nettoyer l'archive locale
echo -e "${YELLOW}5. Nettoyage local...${NC}"
rm -f /tmp/cleo.tar.gz
echo -e "${GREEN}✓ Archive locale supprimée${NC}"
fi
echo ""
echo -e "${GREEN}===================================================${NC}"
echo -e "${GREEN} Déploiement terminé avec succès ! ${NC}"
echo -e "${GREEN}===================================================${NC}"
echo "Site: http://dcleo.unikoffice.com"
echo "Chemin: $DEPLOY_DIR/$APP_NAME sur $CT_NAME"
if [ "$DEPLOY_MODE" = "pra" ]; then
echo "Site: https://cleo.unikoffice.com"
echo "Environnement: PRODUCTION (pra-front)"
else
echo "Site: http://dcleo.unikoffice.com"
echo "Environnement: DÉVELOPPEMENT (dva-front)"
echo "Chemin: $DEPLOY_DIR/$APP_NAME sur $CT_NAME"
fi
echo ""

133
deploy-file.sh Executable file
View File

@@ -0,0 +1,133 @@
#!/bin/bash
# Script de déploiement d'un fichier unique
# Version: 1.0
# Usage: ./deploy-file.sh <chemin_fichier> [pra]
# <chemin_fichier> : Chemin relatif du fichier à déployer (ex: pub/res/js/jdevis.js)
# pra (optionnel) : Déploie vers PROD (IN4/pra-front), sinon déploie vers DEV (IN3/dva-front)
# Vérifier les paramètres
if [ -z "$1" ]; then
echo "Usage: $0 <chemin_fichier> [pra]"
echo "Exemple: $0 pub/res/js/jdevis.js"
echo "Exemple: $0 pub/res/js/jdevis.js pra"
exit 1
fi
FILE_PATH="$1"
DEPLOY_MODE=${2:-dev}
# Vérifier que le fichier existe localement
if [ ! -f "$FILE_PATH" ]; then
echo "Erreur: Le fichier '$FILE_PATH' n'existe pas"
exit 1
fi
# Détecter le mode de déploiement
if [ "$DEPLOY_MODE" = "pra" ]; then
# Configuration PROD : IN4/pra-front
TARGET_HOST=11.1.2.14
TARGET_SSH_USER=root
TARGET_SSH_PORT=22
TARGET_SSH_KEY=/home/pierre/.ssh/id_rsa_mbpi
TARGET_CT_PROJECT=default
TARGET_CT_NAME=pra-front
TARGET_DEPLOY_DIR=/var/www/cleo
ENV_NAME="PRODUCTION"
else
# Configuration DEV : IN3/dva-front
TARGET_HOST=195.154.80.116
TARGET_SSH_USER=root
TARGET_SSH_PORT=22
TARGET_SSH_KEY=/home/pierre/.ssh/id_rsa_mbpi
TARGET_CT_PROJECT=default
TARGET_CT_NAME=dva-front
TARGET_DEPLOY_DIR=/var/www/cleo
ENV_NAME="DÉVELOPPEMENT"
fi
# Propriétaire et groupe
OWNER=nobody
GROUP=nginx
# Couleurs
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
# Options SSH
TARGET_SSH_OPTS="-p $TARGET_SSH_PORT -i $TARGET_SSH_KEY"
TARGET_SCP_OPTS="-P $TARGET_SSH_PORT -i $TARGET_SSH_KEY"
echo -e "${GREEN}=== Déploiement fichier unique ===${NC}"
echo "Fichier: $FILE_PATH"
echo "Environnement: $ENV_NAME ($TARGET_CT_NAME)"
echo "Destination: $TARGET_DEPLOY_DIR/$FILE_PATH"
echo "=================================="
# 1. Copier le fichier vers le serveur hôte
echo -e "${YELLOW}1. Transfert du fichier vers le serveur...${NC}"
scp $TARGET_SCP_OPTS "$FILE_PATH" $TARGET_SSH_USER@$TARGET_HOST:/tmp/deploy-file.tmp
if [ $? -ne 0 ]; then
echo -e "${RED}Erreur lors du transfert${NC}"
exit 1
fi
echo -e "${GREEN}✓ Fichier transféré${NC}"
# 2. Déployer dans le conteneur
echo -e "${YELLOW}2. Déploiement dans le conteneur...${NC}"
# Déterminer les permissions selon le type de fichier
if [[ "$FILE_PATH" == ".env"* ]]; then
FILE_PERMS=640
else
FILE_PERMS=644
fi
TARGET_SCRIPT="
set -e
incus project switch $TARGET_CT_PROJECT
# Créer le répertoire parent si nécessaire
incus exec $TARGET_CT_NAME -- sh -c 'mkdir -p \$(dirname $TARGET_DEPLOY_DIR/$FILE_PATH)'
# Transférer le fichier dans le conteneur
incus file push /tmp/deploy-file.tmp $TARGET_CT_NAME$TARGET_DEPLOY_DIR/$FILE_PATH
# Appliquer les permissions
incus exec $TARGET_CT_NAME -- sh -c '
chown $OWNER:$GROUP $TARGET_DEPLOY_DIR/$FILE_PATH && \
chmod $FILE_PERMS $TARGET_DEPLOY_DIR/$FILE_PATH
'
# Nettoyer
rm -f /tmp/deploy-file.tmp
echo 'Déploiement terminé!'
"
ssh $TARGET_SSH_OPTS $TARGET_SSH_USER@$TARGET_HOST "$TARGET_SCRIPT"
if [ $? -ne 0 ]; then
echo -e "${RED}Erreur lors du déploiement${NC}"
ssh $TARGET_SSH_OPTS $TARGET_SSH_USER@$TARGET_HOST "rm -f /tmp/deploy-file.tmp"
exit 1
fi
echo -e "${GREEN}✓ Fichier déployé avec succès${NC}"
echo ""
echo -e "${GREEN}===================================================${NC}"
echo -e "${GREEN} Déploiement terminé avec succès ! ${NC}"
echo -e "${GREEN}===================================================${NC}"
if [ "$DEPLOY_MODE" = "pra" ]; then
echo "Site: https://cleo.unikoffice.com"
echo "Environnement: PRODUCTION (pra-front)"
else
echo "Site: http://dcleo.unikoffice.com"
echo "Environnement: DÉVELOPPEMENT (dva-front)"
fi
echo "Fichier: $TARGET_DEPLOY_DIR/$FILE_PATH"
echo ""

View File

@@ -4,7 +4,7 @@
CLEO est une application web de gestion de devis développée en PHP 8.3 pour les PME. Elle utilise une architecture MVC classique avec un framework maison appelé "d6".
**Version actuelle** : 2.0.1 (migration complétée le 12 septembre 2025)
**Version actuelle** : 2.0.3 (gestion multi-contacts complétée le 21 octobre 2025)
## Architecture technique
@@ -53,7 +53,9 @@ cleo/
- Gestion des remises par paliers de quantité
2. **Gestion des clients** (`cclients.php`)
- Base clients avec contacts
- Base clients avec contacts multiples (table `clients_contacts`)
- Gestion des contacts via modale Bootstrap intégrée aux devis
- Contact principal automatique avec indicateur visuel
- Segmentation par secteur géographique
- Types de clients paramétrables
- Import/export de données
@@ -82,10 +84,11 @@ cleo/
- **Sécurité** : Requêtes préparées systématiques
### Tables principales
- `devis` : Table principale des devis
- `devis` : Table principale des devis (champ `fk_contact` depuis v2.0.3)
- `devis_produits` : Lignes de produits des devis
- `devis_histo` : Historique des modifications
- `clients` : Base clients
- `clients_contacts` : Contacts multiples par client (v2.0.3)
- `produits` : Catalogue produits
- `produits_familles` : Familles de produits avec marges
- `marches` : Référentiel des marchés
@@ -95,22 +98,32 @@ cleo/
## Points de sécurité
### Vulnérabilités corrigées (v2.0.1)
### Vulnérabilités corrigées
**1. Stockage des mots de passe**
**v2.0.1 - Stockage des mots de passe**
- Credentials externalisés dans `.env`
- Variables d'environnement utilisées systématiquement
**2. Protection contre les injections SQL**
**v2.0.1 - Protection contre les injections SQL**
- Migration complète vers PDO
- Requêtes préparées dans la classe `Database`
- Pattern Singleton pour la connexion
**3. Gestion des erreurs sécurisée**
**v2.0.1 - Gestion des erreurs sécurisée**
- Classe `Database` avec gestion d'erreurs centralisée
- Logging contrôlé par variables d'environnement
- Mode debug désactivable en production
**v2.0.2 - Corrections critiques**
- Sanitisation stricte des entrées utilisateur
- Validation des paramètres AJAX
- Fonction `nettoie_input()` utilisée systématiquement
**v2.0.3 - Gestion multi-contacts sécurisée**
- Contrôleur AJAX `cjxcontacts.php` avec requêtes préparées
- Validation des foreign keys et soft delete
- Prévention de suppression du dernier contact actif
### Vulnérabilités restantes à traiter
#### 1. Injections SQL résiduelles
@@ -208,15 +221,28 @@ cleo/
## Conclusion
CLEO v2.0.1 représente une évolution majeure avec la migration réussie vers une architecture sécurisée :
- ✅ Base de données unique et centralisée
- ✅ Connexions PDO avec requêtes préparées
- ✅ Configuration externalisée
- ✅ Séparation application/base de données
CLEO v2.0.3 représente l'aboutissement de trois itérations majeures d'amélioration :
Les priorités de sécurité critiques ont été adressées. L'application peut maintenant évoluer sereinement vers des standards plus modernes tout en maintenant sa stabilité opérationnelle.
**v2.0.1 - Architecture sécurisée**
- Base de données unique et centralisée
- Connexions PDO avec requêtes préparées
- Configuration externalisée
- Séparation application/base de données
**v2.0.2 - Sécurité renforcée**
- Sanitisation systématique des entrées
- Validation stricte des paramètres AJAX
- Corrections de vulnérabilités critiques
**v2.0.3 - Gestion multi-contacts**
- Migration vers table relationnelle `clients_contacts`
- Interface modale intégrée dans les devis
- CRUD complet avec soft delete
- Gestion automatique du contact principal
L'application dispose maintenant d'une base solide pour évoluer vers des standards modernes tout en maintenant sa stabilité opérationnelle.
---
*Document mis à jour le 12 septembre 2025*
*Version 2.0.1 - Post-migration*
*Document mis à jour le 21 octobre 2025*
*Version 2.0.3 - Gestion multi-contacts*

111
docs/RULES.md Normal file
View File

@@ -0,0 +1,111 @@
# Règles métier - Application CLEO
Ce document recense les règles métier et de développement identifiées dans l'application CLEO.
## 1. Gestion des rôles et permissions
### 1.1 Hiérarchie des rôles
- **DIR-CO** (fk_role = 1) : Direction commerciale
- Accès complet aux devis (propres + statut >= 2)
- Vision globale de l'activité
- **DV** (fk_role = 2) : Directeur des ventes
- Accès à ses propres devis
- Accès aux devis de ses RR subordonnés (statut >= 3)
- Récupération des RR via `fk_parent` dans la table `users`
- **RR** (fk_role = 3) : Responsable régional
- Accès uniquement à ses propres devis
- **Admin** (fk_role = 90) : Administration système
- Accès complet à l'administration
### 1.2 Visibilité des devis (mdevis.php)
La clause WHERE pour filtrer les devis dépend du rôle :
- **DIR-CO** : `d.fk_user = :fkUser OR d.fk_statut_devis >= 2`
- **DV** : `d.fk_user = :fkUser OR (d.fk_statut_devis >= 3 AND d.fk_user IN ([RR_IDS]))`
- **RR** : `d.fk_user = :fkUser`
## 2. Gestion des marchés et produits
### 2.1 Types de marchés
- **Marché standard** : Contient ses propres produits uniquement
- **Marché hybride** (`chk_marche_hybride = 1`) : Combine les produits du marché + produits du marché 999
- **Marché avec remise sur TG** (`chk_remise_sur_tg = 1`) : Charge uniquement les produits du marché 999
- **Marché 999** : Marché "Hors marché" ou "Tarif général"
### 2.2 Chargement des produits (load_devis_marche_produits)
| Type de marché | chk_remise_sur_tg | chk_marche_hybride | Produits chargés |
|---------------|-------------------|-------------------|------------------|
| Spécifique | 1 | - | Tous les produits du marché 999 |
| Spécifique | 0 | 0 | Produits du marché uniquement |
| Spécifique | 0 | 1 | Produits du marché + produits du 999 non présents |
| 999 (Hors marché) | - | - | Tous les produits du marché 999 |
### 2.3 Terme "Purchasing"
Quand `terme_achat = 'Purchasing'` dans `marches_listes` :
- Récupère les prix d'achat nets depuis le marché 999
- Applique les paliers de remise du marché 999
## 3. Sécurité et développement
### 3.1 Accès base de données
- **Obligatoire** : Utiliser la classe `Database` avec ses méthodes
- **Interdit** : Appeler directement `$db->prepare()` sur l'objet Database
- **Méthodes disponibles** :
- `$db->fetchAll($sql, $params)` : Récupérer plusieurs lignes
- `$db->fetchOne($sql, $params)` : Récupérer une ligne
- `$db->query($sql, $params)` : Exécuter une requête
- `$db->lastInsertId()` : Récupérer le dernier ID inséré
### 3.2 Protection contre les injections SQL
- Utiliser `intval()` pour les entiers dans les requêtes non préparées
- Utiliser `nettoie_input()` pour nettoyer les entrées utilisateur
- Privilégier les requêtes préparées via la classe Database
### 3.3 Variables de sécurité
- `$cidSafe = intval($cid)` : Version sécurisée pour les requêtes SQL
- Attention lors de la modification de variables : recalculer ou utiliser directement `intval()`
## 4. Statuts des devis
### 4.1 Statuts principaux
- **1** : En cours
- **2** : Validé niveau 1
- **3** : Validé niveau 2
- **20** : Archivé
### 4.2 Réactivation des devis
- Un devis archivé (statut 20) peut être réactivé (statut 1)
- La réactivation est tracée dans `devis_histo`
- Disponible selon les droits du rôle
## 5. Conventions de nommage
### 5.1 Fichiers
- **Contrôleurs** : `c*.php` pour les standards, `cjx*.php` pour AJAX
- **Modèles** : `m*.php`
- **Vues** : `v*.php`
### 5.2 Tables de base de données
- **Tables principales** : Nom simple (`devis`, `clients`, `produits`)
- **Tables de référence** : Préfixe `x_` (`x_familles`, `x_statuts`)
- **Tables système** : Préfixe `y_` (`y_pages`) ou `z_` (`z_logs`, `z_sessions`)
## 6. Gestion des prix et marges
### 6.1 Prix nets
- `chk_prix_net = 1` : Prix non modifiable (marché hybride)
- `chk_prix_net = 0` : Prix modifiable avec marges
### 6.2 Paliers de remise
Les produits peuvent avoir jusqu'à 6 paliers de remise :
- `prc_discount_1` avec `quantite_1`
- `prc_discount_2` avec `quantite_2`
- ... jusqu'à 6
---
*Document créé le 16 septembre 2025*
*À mettre à jour au fur et à mesure des découvertes*

View File

@@ -4,15 +4,6 @@
### Module Devis
#### 6. ✅ Modifier un devis archivé (TERMINÉ - 12/09/2025)
**Priorité**: Haute
**Description**: Permettre la modification d'un devis archivé et son renvoi pour traitement sans nécessiter de duplication.
**Tâches**:
- [x] Ajouter un bouton "Réactiver" sur les devis archivés (statut 20)
- [x] Permettre le changement de statut d'archivé vers "En cours"
- [x] Conserver l'historique de réactivation dans `devis_histo`
- [x] Adapter les droits selon les rôles (RR, DV, DIR-CO)
#### 8. Dupliquer une ligne produit
**Priorité**: Moyenne
**Description**: Permettre la duplication d'une ligne produit dans un même devis (utile pour les gratuités).
@@ -36,16 +27,6 @@
- [ ] Paginer les résultats de recherche
- [ ] Export des résultats en Excel
#### 19. Gestion des contacts multiples
**Priorité**: Haute
**Description**: Permettre la gestion de plusieurs contacts par client.
**Tâches**:
- [ ] Créer une table `clients_contacts`
- [ ] Migration des contacts existants vers la nouvelle structure
- [ ] Interface CRUD pour les contacts
- [ ] Sélecteur de contact à la création/modification de devis
- [ ] Historique des contacts par devis
#### 21. Actualisation tarifaire
**Priorité**: Moyenne
**Description**: Permettre l'actualisation des prix selon la dernière grille tarifaire.
@@ -56,8 +37,73 @@
- [ ] Recalculer automatiquement les marges
- [ ] Tracer l'actualisation dans l'historique
#### 22. Marchés hybrides et onglet Mercurial
**Priorité**: Haute
**Description**: Ajouter un onglet "Mercurial" dans la page devis pour les marchés de type hybride, listant tous les produits du marché.
**Tâches - Phase 1 (Onglet Mercurial)** :
- [x] Identifier le type de marché du devis sélectionné
- [x] Détecter si le marché est de type "hybride"
- [x] Ajouter un nouvel onglet "Mercurial" dans l'interface devis (au niveau de l'onglet Produits)
- [x] Récupérer tous les produits associés au marché
- [x] Filtrer les produits "Hors Marché 999"
- [x] Afficher la liste des produits dans l'onglet Mercurial
- [x] Gérer l'affichage/masquage de l'onglet selon le type de marché
**Tâches - Phase 2 (Améliorations visuelles et règles métier)** :
- [ ] **Visibilité de l'onglet Mercurial** : Rendre l'onglet "Mercurial" visuellement distinct (couleur de fond différente, par exemple) pour qu'il soit clairement identifiable par les commerciaux
**Types de marchés hybrides** :
Deux cas de marchés hybrides doivent être gérés différemment :
**CAS 1 - Mercuriale sans remise** :
- Liste mercuriale en prix nets SANS remise applicable sur ces références
- Reste du catalogue disponible avec possibilité de remises en autonomie
**CAS 2 - Mercuriale avec remise possible** :
- Liste mercuriale en prix nets AVEC possibilité de remises sur ces références
- Reste du catalogue disponible avec possibilité de remises en autonomie
**Règles communes aux 2 cas** :
- Quand le devis contient UNIQUEMENT des références mercuriales → pas de demande d'accord nécessaire, le RR peut valider directement
- Quand le devis contient références mercuriales + catalogue général → seules les références du catalogue sont concernées par les seuils de marge et peuvent générer une demande d'accord
- Si geste commercial souhaité sur un devis 100% mercurial → utiliser le champ "Demande geste commercial" existant
**Tâches - Paramétrage base de données** :
- [ ] Ajouter un champ dans la table `marches` pour définir le type de marché hybride :
- `type_mercurial` (ENUM ou INT) : NULL = non hybride, 1 = CAS 1 (sans remise), 2 = CAS 2 (avec remise)
- [ ] Modifier la fiche marché pour permettre la sélection du type de marché hybride
**Tâches - Logique métier de validation** :
- [ ] Détecter si un devis contient uniquement des produits mercuriaux
- [ ] Détecter si un devis contient un mix mercurial + catalogue
- [ ] Adapter le calcul des seuils de marge :
- Si devis 100% mercurial → pas de vérification de seuil, validation RR directe
- Si devis mixte → calculer les seuils uniquement sur les produits du catalogue général
- [ ] Bloquer/autoriser les remises sur produits mercuriaux selon le type de marché (CAS 1 vs CAS 2)
- [ ] Tester le workflow complet avec les 2 types de marchés hybrides
### Module SAP
#### 13. Import et contrôle des clients SAP
**Priorité**: Haute
**Description**: Contrôler les nouveaux clients créés dans la base CLEO et vérifier la correspondance avec la base SAP.
**Tâches**:
- [ ] Identifier le script/contrôleur d'import des clients SAP
- [ ] Analyser la structure des données importées
- [ ] Mettre en place un système de contrôle de correspondance
- [ ] Vérifier l'unicité du `clients.code` (identifiant SAP)
- [ ] Détecter les doublons potentiels (nom, adresse)
- [ ] Signaler les incohérences entre SAP et CLEO
- [ ] Créer un rapport d'import avec :
- [ ] Nombre de clients importés
- [ ] Nombre de clients mis à jour
- [ ] Nombre d'anomalies détectées
- [ ] Gestion des cas particuliers :
- [ ] Client existe dans CLEO mais pas dans SAP
- [ ] Client existe dans SAP mais code différent dans CLEO
- [ ] Contacts orphelins après import
#### 14. Gestion de la prise en charge
**Priorité**: Haute
**Description**: Ajouter la traçabilité de la prise en charge et du transfert EDI.
@@ -117,65 +163,7 @@
### Plan de migration - État d'avancement
#### ✅ Phase 0 - Refactoring base de données (COMPLÉTÉ - 12/09/2025)
- [x] Script de migration SQL créé
- [x] Table `y_pages` migrée depuis `uof_frontal`
- [x] Table `z_logs` créée dans `cleo`
- [x] Base `cleo` créée avec toutes les tables
- [x] Données migrées de `uof_linet` vers `cleo`
- [x] Références à `uof_frontal` supprimées
- [x] Classe Database PDO créée
- [x] Variables d'environnement `.env` implémentées
- [x] Tests validés en DEV
#### ✅ Phase 1 - Environnement DEV IN3 (COMPLÉTÉ - 12/09/2025)
- [x] Container `maria3` créé sur IN3
- [x] MariaDB 11.4 installé et configuré
- [x] Base `cleo` migrée vers `maria3`
- [x] Configuration pointant vers `maria3` (IP: 13.23.33.4)
- [x] Application testée et fonctionnelle
- [x] MariaDB supprimé de `dva-front`
- [x] Script de déploiement optimisé (`deploy-cleo-fast.sh`)
#### Phase 2 - Préparation PROD IN4 (À FAIRE)
**Export depuis IN3:**
- [ ] Exporter le container `dva-front` depuis IN3
```bash
incus export dva-front dva-front-export.tar.gz
```
- [ ] Exporter le container `maria3` depuis IN3
```bash
incus export maria3 maria3-export.tar.gz
```
**Import sur IN4:**
- [ ] Importer `dva-front` comme `pra-front` sur IN4
```bash
incus import dva-front-export.tar.gz pra-front
```
- [ ] Importer `maria3` comme `maria4` sur IN4
```bash
incus import maria3-export.tar.gz maria4
```
- [ ] Configurer les IPs et paramètres réseau sur IN4
- [ ] Adapter le fichier `.env` pour l'environnement PROD
#### Phase 3 - Migration des données PROD (À FAIRE)
- [ ] Effectuer une sauvegarde complète des bases PROD sur IN2/nx4
- [ ] Exporter les données de `uof_frontal` et `uof_linet` depuis IN2/nx4
- [ ] Utiliser le script de migration SQL pour fusionner les données
- [ ] Importer les données fusionnées dans `maria4` sur IN4
- [ ] Configurer `pra-front` pour pointer vers `maria4`
- [ ] Tests de validation en pré-production
#### Phase 4 - Bascule PROD (À FAIRE)
- [ ] Planifier la fenêtre de maintenance
- [ ] Arrêter l'application sur IN2
- [ ] Synchronisation finale des données vers IN4/maria4
- [ ] Basculer le DNS/proxy vers IN4
- [ ] Valider le fonctionnement en production
- [ ] Monitoring post-migration (48h)
- [ ] Décommissionner IN2 après période de stabilisation
**Migration complétée** - Toutes les phases (0 à 4) sont terminées.
### Configuration technique
@@ -207,6 +195,81 @@ DB_PASSWORD=<PROD_PASSWORD> # À sécuriser
- [ ] Scripts de backup automatisés à mettre en place
- [ ] Réplication master-slave pour haute disponibilité (optionnel)
## Modification Contacts Clients - Migration vers clients.code
### Contexte
La relation entre `clients_contacts` et `clients` utilise actuellement `clients.rowid` comme clé étrangère.
Cela pose problème lors des imports SAP qui peuvent écraser ou modifier les `rowid`.
Il faut migrer vers `clients.code` (identifiant SAP immuable) pour garantir l'intégrité des relations.
### Plan de correction
#### 1. Vérification préalable
- [ ] Lire la structure actuelle de la table `clients`
- [ ] Confirmer que `code` est de type INT
- [ ] Vérifier la contrainte UNIQUE sur `code`
- [ ] Vérifier l'index sur `code`
- [ ] Lire la structure de `clients_contacts`
- [ ] État actuel de `fk_client`
- [ ] Contraintes de clé étrangère existantes
- [ ] Vérifier les données existantes
- [ ] Nombre de contacts déjà enregistrés
- [ ] Cohérence des relations actuelles
#### 2. Modification de la structure
- [ ] Supprimer la contrainte FK actuelle sur `clients_contacts.fk_client`
- [ ] Modifier le type de `clients_contacts.fk_client` pour correspondre à `clients.code`
- [ ] Ajouter la nouvelle contrainte FK référençant `clients.code`
- [ ] `ON DELETE CASCADE`
- [ ] `ON UPDATE CASCADE`
- [ ] Vérifier/ajouter index UNIQUE sur `clients.code` si nécessaire
#### 3. Migration des données
- [ ] Créer un script de migration SQL
- [ ] Sauvegarder les données actuelles de `clients_contacts`
- [ ] Convertir les `fk_client` (rowid → code)
- [ ] Valider la cohérence des données migrées
- [ ] Tester l'intégrité référentielle
#### 4. Adaptation du code applicatif
- [x] ✅ Contrôleur `cjxcontacts.php`
- Aucune modification nécessaire (utilise déjà `fk_client` de manière générique)
- [x] ✅ Contrôleur `cjxdevis.php`
- `load_clients_devis` : modifié pour retourner `clients.code`
- `save_new_client` : modifié pour utiliser `newCode` au lieu de `newClientId`
- [x] ✅ JavaScript `jdevis.js`
- Fonction `autocompleteClient` : modifiée pour utiliser `list[i]['code']` au lieu de `list[i]['rowid']`
- `loadContactsClient(list[i]['code'])` : passe maintenant le code SAP
- [ ] **Import clients SAP** : À TRAITER EN PRIORITÉ
- [ ] Fichier concerné : identifier le contrôleur/script d'import
- [ ] Lors de l'import, si un client existe déjà (même `code`), mettre à jour ses infos SANS changer le `code`
- [ ] Gérer la mise à jour des contacts : les contacts existants doivent conserver leur lien via `fk_client = code`
- [ ] Si import d'un nouveau client : créer avec le `code` SAP fourni
- [ ] IMPORTANT : Ne jamais modifier `clients.code` après création (immuable)
#### 5. Tests et validation
- [ ] Tests de création de contact
- [ ] Tests de modification de contact
- [ ] Tests de suppression de contact (soft delete)
- [ ] Tests de sélection de contact dans un devis
- [ ] Simuler un import SAP et vérifier la stabilité des relations
### Notes techniques
```sql
-- Exemple de modification FK
ALTER TABLE clients_contacts
DROP FOREIGN KEY fk_clients_contacts_client;
ALTER TABLE clients_contacts
ADD CONSTRAINT fk_clients_contacts_client
FOREIGN KEY (fk_client)
REFERENCES clients(code)
ON DELETE CASCADE
ON UPDATE CASCADE;
```
---
## Améliorations techniques prioritaires
### Sécurité
@@ -252,26 +315,15 @@ DB_PASSWORD=<PROD_PASSWORD> # À sécuriser
## Notes de développement
### Structure de la table `clients_contacts` (à créer)
```sql
CREATE TABLE clients_contacts (
rowid INT PRIMARY KEY AUTO_INCREMENT,
fk_client INT NOT NULL,
nom VARCHAR(100),
prenom VARCHAR(100),
fonction VARCHAR(100),
telephone VARCHAR(20),
mobile VARCHAR(20),
email VARCHAR(255),
principal TINYINT DEFAULT 0,
active TINYINT DEFAULT 1,
date_creat DATETIME,
fk_user_creat INT,
date_modif DATETIME,
fk_user_modif INT,
FOREIGN KEY (fk_client) REFERENCES clients(rowid)
);
```
### Structure de la table `clients_contacts` (CRÉÉE - v2.0.3)
Table créée et opérationnelle avec :
- Clé étrangère vers `clients` avec CASCADE
- Gestion du contact principal (un seul par client)
- Soft delete via champ `active`
- Traçabilité (date_creat, fk_user_creat, date_modif, fk_user_modif)
- Index sur fk_client, principal et email
- Contrainte UNIQUE sur rowid
- Voir `docs/migration_clients_contacts.sql` pour la structure complète
### Modifications table `devis` pour SAP
```sql
@@ -285,24 +337,36 @@ ALTER TABLE devis ADD COLUMN erreur_transfert_edi TEXT;
## Résumé de l'état actuel
### ✅ Réalisations (v2.0.2 - 12 septembre 2025)
### ✅ Réalisations
**v2.0.1 (12 septembre 2025)**
1. **Migration DEV complétée** : Architecture séparée application/BDD
2. **Base unique `cleo`** : Fusion réussie de 3 bases en une seule
3. **Sécurité renforcée** : PDO, requêtes préparées, variables d'environnement
4. **Container `dva-front`** : MariaDB supprimé, application PHP uniquement
5. **Container `maria3`** : Base de données centralisée opérationnelle
6. **Audit de sécurité complété** : 14 vulnérabilités SQL identifiées et corrigées
**v2.0.2 (12 septembre 2025)**
1. **Audit de sécurité complété** : 14 vulnérabilités SQL identifiées et corrigées
- 8 critiques (fonction autocomplete, injections dans cjxpost.php, mclients.php, mdevis.php)
- 6 moyennes (cjxdevis.php, cjxexport.php, cjximport.php, mexpxls.php)
7. **Fonctionnalité Réactivation devis** : Bouton permettant de réactiver les devis archivés (statut 20 → 1)
2. **Fonctionnalité Réactivation devis** : Bouton permettant de réactiver les devis archivés (statut 20 → 1)
**v2.0.3 (21 octobre 2025)**
1. **Gestion multi-contacts par client** : Table `clients_contacts` opérationnelle
2. **Interface CRUD complète** : Modale Bootstrap avec création/modification/suppression de contacts
3. **Contrôleur AJAX `cjxcontacts.php`** : 5 endpoints sécurisés avec requêtes préparées
4. **Intégration dans les devis** : Sélecteur de contact avec affichage des infos en lecture seule
5. **Gestion automatique du contact principal** : Un seul contact principal par client
6. **Soft delete** : Prévention de la suppression du dernier contact actif
### 🎯 Prochaines étapes prioritaires
1. **Migration PROD vers IN4** : Export/Import des containers vers `pra-front` et `maria4`
2. **Fonctionnalités métier** : Points 14, 16 (voir sections ci-dessus)
3. **Sécurité XSS** : Audit et correction des failles XSS potentielles
4. **Tests** : Mise en place de tests automatisés de sécurité
1. **Nettoyage BDD** : Supprimer les anciens champs contact de la table `clients` (après validation)
2. **Migration PROD vers IN4** : Export/Import des containers vers `pra-front` et `maria4`
3. **Fonctionnalités métier** : Points 8, 14, 16, 21 (voir sections ci-dessus)
4. **Sécurité XSS** : Audit et correction des failles XSS potentielles
5. **Tests** : Mise en place de tests automatisés de sécurité
---
*Document mis à jour le 12 septembre 2025*
*Version 2.0.2 - Sécurité SQL complète*
*Document mis à jour le 21 octobre 2025*
*Version 2.0.3 - Gestion multi-contacts*

440
docs/backpm7.sh Normal file
View File

@@ -0,0 +1,440 @@
#!/bin/bash
set -uo pipefail
# Note: Removed -e to allow script to continue on errors
# Errors are handled explicitly with ERROR_COUNT
# Parse command line arguments
ONLY_DB=false
if [[ "${1:-}" == "-onlydb" ]]; then
ONLY_DB=true
echo "Mode: Database backup only"
fi
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_FILE="$SCRIPT_DIR/backpm7.yaml"
LOG_DIR="$SCRIPT_DIR/logs"
mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/backpm7-$(date +%Y%m%d).log"
ERROR_COUNT=0
EMAIL_TO="support@unikoffice.com"
RECAP_FILE="/tmp/backup_recap_$$.txt"
# Clean old log files (keep only last 10)
find "$LOG_DIR" -maxdepth 1 -name "backpm7-*.log" -type f 2>/dev/null | sort -r | tail -n +11 | xargs -r rm -f || true
# Check dependencies - COMMENTED OUT
# for cmd in yq ssh tar openssl; do
# if ! command -v "$cmd" &> /dev/null; then
# echo "ERROR: $cmd is required but not installed" | tee -a "$LOG_FILE"
# exit 1
# fi
# done
# Load config
DIR_BACKUP=$(yq '.global.dir_backup' "$CONFIG_FILE" | tr -d '"')
ENC_KEY_PATH=$(yq '.global.enc_key' "$CONFIG_FILE" | tr -d '"')
# Load encryption key
if [[ ! -f "$ENC_KEY_PATH" ]]; then
echo "ERROR: Encryption key not found: $ENC_KEY_PATH" | tee -a "$LOG_FILE"
exit 1
fi
ENC_KEY=$(cat "$ENC_KEY_PATH")
echo "=== Backup Started $(date) ===" | tee -a "$LOG_FILE"
echo "Backup directory: $DIR_BACKUP" | tee -a "$LOG_FILE"
# Check available disk space
DISK_USAGE=$(df "$DIR_BACKUP" | tail -1 | awk '{print $5}' | sed 's/%//')
DISK_FREE=$((100 - DISK_USAGE))
if [[ $DISK_FREE -lt 20 ]]; then
echo "WARNING: Low disk space! Only ${DISK_FREE}% free on backup partition" | tee -a "$LOG_FILE"
# Send warning email
echo "Sending DISK SPACE WARNING email to $EMAIL_TO (${DISK_FREE}% free)" | tee -a "$LOG_FILE"
if command -v msmtp &> /dev/null; then
{
echo "To: $EMAIL_TO"
echo "Subject: BackupPM7 WARNING - Low disk space (${DISK_FREE}% free)"
echo ""
echo "WARNING: Low disk space on $(hostname)"
echo ""
echo "Backup directory: $DIR_BACKUP"
echo "Disk usage: ${DISK_USAGE}%"
echo "Free space: ${DISK_FREE}%"
echo ""
echo "The backup will continue but please free up some space soon."
echo ""
echo "Date: $(date '+%d.%m.%Y %H:%M')"
} | msmtp "$EMAIL_TO"
echo "DISK SPACE WARNING email sent successfully to $EMAIL_TO" | tee -a "$LOG_FILE"
else
echo "WARNING: msmtp not found - DISK WARNING email NOT sent" | tee -a "$LOG_FILE"
fi
else
echo "Disk space OK: ${DISK_FREE}% free" | tee -a "$LOG_FILE"
fi
# Initialize recap file
echo "BACKUP REPORT - $(hostname) - $(date '+%d.%m.%Y %H')h" > "$RECAP_FILE"
echo "========================================" >> "$RECAP_FILE"
echo "" >> "$RECAP_FILE"
# Function to format size in MB with thousand separator
format_size_mb() {
local file="$1"
if [[ -f "$file" ]]; then
local size_kb=$(du -k "$file" | cut -f1)
local size_mb=$((size_kb / 1024))
# Add thousand separator with printf and sed
printf "%d" "$size_mb" | sed ':a;s/\B[0-9]\{3\}\>/\.&/;ta'
else
echo "0"
fi
}
# Function to backup a single database (must be defined before use)
backup_database() {
local database="$1"
local backup_file="$backup_dir/sql/${database}_$(date +%Y%m%d_%H).sql.gz.enc"
echo " Backing up database: $database" | tee -a "$LOG_FILE"
if [[ "$ssh_user" != "root" ]]; then
CMD_PREFIX="sudo"
else
CMD_PREFIX=""
fi
# Execute backup with encryption
# First test MySQL connection to get clear error messages (|| true to continue on error)
MYSQL_TEST=$(ssh -i "$ssh_key" -p "$ssh_port" -o ConnectTimeout=20 "$ssh_user@$host_ip" \
"$CMD_PREFIX incus exec $container_name -- mariadb -h $db_host -u$db_user -p$db_pass -e 'SELECT 1' 2>&1" 2>/dev/null || true)
if ssh -i "$ssh_key" -p "$ssh_port" -o ConnectTimeout=20 "$ssh_user@$host_ip" \
"$CMD_PREFIX incus exec $container_name -- bash -c 'mariadb-dump -h $db_host -u$db_user -p$db_pass --add-drop-table --create-options --databases $database 2>/dev/null | gzip'" | \
openssl enc -aes-256-cbc -salt -pass pass:"$ENC_KEY" -pbkdf2 > "$backup_file" 2>/dev/null; then
# Validate backup file size (encrypted SQL should be > 100 bytes)
if [[ -f "$backup_file" ]]; then
file_size=$(stat -c%s "$backup_file" 2>/dev/null || echo 0)
if [[ $file_size -lt 100 ]]; then
# Analyze MySQL connection test results
if [[ "$MYSQL_TEST" == *"Access denied"* ]]; then
echo " ERROR: MySQL authentication failed for $database on $host_name/$container_name" | tee -a "$LOG_FILE"
echo " User: $db_user@$db_host - Check password in configuration" | tee -a "$LOG_FILE"
elif [[ "$MYSQL_TEST" == *"Unknown database"* ]]; then
echo " ERROR: Database '$database' does not exist on $host_name/$container_name" | tee -a "$LOG_FILE"
elif [[ "$MYSQL_TEST" == *"Can't connect"* ]]; then
echo " ERROR: Cannot connect to MySQL server at $db_host in $container_name" | tee -a "$LOG_FILE"
else
echo " ERROR: Backup file too small (${file_size} bytes): $database on $host_name/$container_name" | tee -a "$LOG_FILE"
fi
((ERROR_COUNT++))
rm -f "$backup_file"
else
size=$(du -h "$backup_file" | cut -f1)
size_mb=$(format_size_mb "$backup_file")
echo " ✓ Saved (encrypted): $(basename "$backup_file") ($size)" | tee -a "$LOG_FILE"
echo " SQL: $(basename "$backup_file") - ${size_mb} Mo" >> "$RECAP_FILE"
fi
else
echo " ERROR: Backup file not created: $database" | tee -a "$LOG_FILE"
((ERROR_COUNT++))
fi
else
# Analyze MySQL connection test for failed backup
if [[ "$MYSQL_TEST" == *"Access denied"* ]]; then
echo " ERROR: MySQL authentication failed for $database on $host_name/$container_name" | tee -a "$LOG_FILE"
echo " User: $db_user@$db_host - Check password in configuration" | tee -a "$LOG_FILE"
elif [[ "$MYSQL_TEST" == *"Unknown database"* ]]; then
echo " ERROR: Database '$database' does not exist on $host_name/$container_name" | tee -a "$LOG_FILE"
elif [[ "$MYSQL_TEST" == *"Can't connect"* ]]; then
echo " ERROR: Cannot connect to MySQL server at $db_host in $container_name" | tee -a "$LOG_FILE"
else
echo " ERROR: Failed to backup database $database on $host_name/$container_name" | tee -a "$LOG_FILE"
fi
((ERROR_COUNT++))
rm -f "$backup_file"
fi
}
# Process each host
host_count=$(yq '.hosts | length' "$CONFIG_FILE")
for ((i=0; i<$host_count; i++)); do
host_name=$(yq ".hosts[$i].name" "$CONFIG_FILE" | tr -d '"')
host_ip=$(yq ".hosts[$i].ip" "$CONFIG_FILE" | tr -d '"')
ssh_user=$(yq ".hosts[$i].user" "$CONFIG_FILE" | tr -d '"')
ssh_key=$(yq ".hosts[$i].key" "$CONFIG_FILE" | tr -d '"')
ssh_port=$(yq ".hosts[$i].port // 22" "$CONFIG_FILE" | tr -d '"')
echo "Processing host: $host_name ($host_ip)" | tee -a "$LOG_FILE"
echo "" >> "$RECAP_FILE"
echo "HOST: $host_name ($host_ip)" >> "$RECAP_FILE"
echo "----------------------------" >> "$RECAP_FILE"
# Test SSH connection
if ! ssh -i "$ssh_key" -p "$ssh_port" -o ConnectTimeout=20 -o StrictHostKeyChecking=no "$ssh_user@$host_ip" "true" 2>/dev/null; then
echo " ERROR: Cannot connect to $host_name ($host_ip)" | tee -a "$LOG_FILE"
((ERROR_COUNT++))
continue
fi
# Process containers
container_count=$(yq ".hosts[$i].containers | length" "$CONFIG_FILE" 2>/dev/null || echo "0")
for ((c=0; c<$container_count; c++)); do
container_name=$(yq ".hosts[$i].containers[$c].name" "$CONFIG_FILE" | tr -d '"')
echo " Processing container: $container_name" | tee -a "$LOG_FILE"
# Add container to recap
echo " Container: $container_name" >> "$RECAP_FILE"
# Create backup directories
backup_dir="$DIR_BACKUP/$host_name/$container_name"
mkdir -p "$backup_dir"
mkdir -p "$backup_dir/sql"
# Backup directories (skip if -onlydb mode)
if [[ "$ONLY_DB" == "false" ]]; then
dir_count=$(yq ".hosts[$i].containers[$c].dirs | length" "$CONFIG_FILE" 2>/dev/null || echo "0")
for ((d=0; d<$dir_count; d++)); do
dir_path=$(yq ".hosts[$i].containers[$c].dirs[$d]" "$CONFIG_FILE" | sed 's/^"\|"$//g')
# Use sudo if not root
if [[ "$ssh_user" != "root" ]]; then
CMD_PREFIX="sudo"
else
CMD_PREFIX=""
fi
# Special handling for /var/www - backup each subdirectory separately
if [[ "$dir_path" == "/var/www" ]]; then
echo " Backing up subdirectories of $dir_path" | tee -a "$LOG_FILE"
# Get list of subdirectories
subdirs=$(ssh -i "$ssh_key" -p "$ssh_port" -o ConnectTimeout=20 "$ssh_user@$host_ip" \
"$CMD_PREFIX incus exec $container_name -- find /var/www -maxdepth 1 -type d ! -path /var/www" 2>/dev/null || echo "")
for subdir in $subdirs; do
subdir_name=$(basename "$subdir" | tr '/' '_')
backup_file="$backup_dir/www_${subdir_name}_$(date +%Y%m%d_%H).tar.gz"
echo " Backing up: $subdir" | tee -a "$LOG_FILE"
if ssh -i "$ssh_key" -p "$ssh_port" -o ConnectTimeout=20 "$ssh_user@$host_ip" \
"$CMD_PREFIX incus exec $container_name -- tar czf - $subdir 2>/dev/null" > "$backup_file"; then
# Validate backup file size (tar.gz should be > 1KB)
if [[ -f "$backup_file" ]]; then
file_size=$(stat -c%s "$backup_file" 2>/dev/null || echo 0)
if [[ $file_size -lt 1024 ]]; then
echo " WARNING: Backup file very small (${file_size} bytes): $subdir" | tee -a "$LOG_FILE"
# Keep the file but note it's small
size=$(du -h "$backup_file" | cut -f1)
size_mb=$(format_size_mb "$backup_file")
echo " ✓ Saved (small): $(basename "$backup_file") ($size)" | tee -a "$LOG_FILE"
echo " DIR: $(basename "$backup_file") - ${size_mb} Mo (WARNING: small)" >> "$RECAP_FILE"
else
size=$(du -h "$backup_file" | cut -f1)
size_mb=$(format_size_mb "$backup_file")
echo " ✓ Saved: $(basename "$backup_file") ($size)" | tee -a "$LOG_FILE"
echo " DIR: $(basename "$backup_file") - ${size_mb} Mo" >> "$RECAP_FILE"
fi
else
echo " ERROR: Backup file not created: $subdir" | tee -a "$LOG_FILE"
((ERROR_COUNT++))
fi
else
echo " ERROR: Failed to backup $subdir" | tee -a "$LOG_FILE"
((ERROR_COUNT++))
rm -f "$backup_file"
fi
done
else
# Normal backup for other directories
dir_name=$(basename "$dir_path" | tr '/' '_')
backup_file="$backup_dir/${dir_name}_$(date +%Y%m%d_%H).tar.gz"
echo " Backing up: $dir_path" | tee -a "$LOG_FILE"
if ssh -i "$ssh_key" -p "$ssh_port" -o ConnectTimeout=20 "$ssh_user@$host_ip" \
"$CMD_PREFIX incus exec $container_name -- tar czf - $dir_path 2>/dev/null" > "$backup_file"; then
# Validate backup file size (tar.gz should be > 1KB)
if [[ -f "$backup_file" ]]; then
file_size=$(stat -c%s "$backup_file" 2>/dev/null || echo 0)
if [[ $file_size -lt 1024 ]]; then
echo " WARNING: Backup file very small (${file_size} bytes): $dir_path" | tee -a "$LOG_FILE"
# Keep the file but note it's small
size=$(du -h "$backup_file" | cut -f1)
size_mb=$(format_size_mb "$backup_file")
echo " ✓ Saved (small): $(basename "$backup_file") ($size)" | tee -a "$LOG_FILE"
echo " DIR: $(basename "$backup_file") - ${size_mb} Mo (WARNING: small)" >> "$RECAP_FILE"
else
size=$(du -h "$backup_file" | cut -f1)
size_mb=$(format_size_mb "$backup_file")
echo " ✓ Saved: $(basename "$backup_file") ($size)" | tee -a "$LOG_FILE"
echo " DIR: $(basename "$backup_file") - ${size_mb} Mo" >> "$RECAP_FILE"
fi
else
echo " ERROR: Backup file not created: $dir_path" | tee -a "$LOG_FILE"
((ERROR_COUNT++))
fi
else
echo " ERROR: Failed to backup $dir_path" | tee -a "$LOG_FILE"
((ERROR_COUNT++))
rm -f "$backup_file"
fi
fi
done
fi # End of directory backup section
# Backup databases
db_user=$(yq ".hosts[$i].containers[$c].db_user" "$CONFIG_FILE" 2>/dev/null | tr -d '"')
db_pass=$(yq ".hosts[$i].containers[$c].db_pass" "$CONFIG_FILE" 2>/dev/null | tr -d '"')
db_host=$(yq ".hosts[$i].containers[$c].db_host // \"localhost\"" "$CONFIG_FILE" 2>/dev/null | tr -d '"')
# Check if we're in onlydb mode
if [[ "$ONLY_DB" == "true" ]]; then
# Use onlydb list if it exists
onlydb_count=$(yq ".hosts[$i].containers[$c].onlydb | length" "$CONFIG_FILE" 2>/dev/null || echo "0")
if [[ "$onlydb_count" != "0" ]] && [[ "$onlydb_count" != "null" ]]; then
db_count="$onlydb_count"
use_onlydb=true
else
# No onlydb list, skip this container in onlydb mode
continue
fi
else
# Normal mode - use databases list
db_count=$(yq ".hosts[$i].containers[$c].databases | length" "$CONFIG_FILE" 2>/dev/null || echo "0")
use_onlydb=false
fi
if [[ -n "$db_user" ]] && [[ -n "$db_pass" ]] && [[ "$db_count" != "0" ]]; then
for ((db=0; db<$db_count; db++)); do
if [[ "$use_onlydb" == "true" ]]; then
db_name=$(yq ".hosts[$i].containers[$c].onlydb[$db]" "$CONFIG_FILE" | tr -d '"')
else
db_name=$(yq ".hosts[$i].containers[$c].databases[$db]" "$CONFIG_FILE" | tr -d '"')
fi
if [[ "$db_name" == "ALL" ]]; then
echo " Fetching all databases..." | tee -a "$LOG_FILE"
# Get database list
if [[ "$ssh_user" != "root" ]]; then
db_list=$(ssh -i "$ssh_key" -p "$ssh_port" -o ConnectTimeout=20 "$ssh_user@$host_ip" \
"sudo incus exec $container_name -- mariadb -h $db_host -u$db_user -p$db_pass -e 'SHOW DATABASES;' 2>/dev/null" | \
grep -Ev '^(Database|information_schema|performance_schema|mysql|sys)$' || echo "")
else
db_list=$(ssh -i "$ssh_key" -p "$ssh_port" -o ConnectTimeout=20 "$ssh_user@$host_ip" \
"incus exec $container_name -- mariadb -h $db_host -u$db_user -p$db_pass -e 'SHOW DATABASES;' 2>/dev/null" | \
grep -Ev '^(Database|information_schema|performance_schema|mysql|sys)$' || echo "")
fi
# Backup each database
for single_db in $db_list; do
backup_database "$single_db"
done
else
backup_database "$db_name"
fi
done
fi
done
done
echo "=== Backup Completed $(date) ===" | tee -a "$LOG_FILE"
# Show summary
total_size=$(du -sh "$DIR_BACKUP" 2>/dev/null | cut -f1)
echo "Total backup size: $total_size" | tee -a "$LOG_FILE"
# Add summary to recap
echo "" >> "$RECAP_FILE"
echo "========================================" >> "$RECAP_FILE"
# Add size details per host/container
echo "BACKUP SIZES:" >> "$RECAP_FILE"
for host_dir in "$DIR_BACKUP"/*; do
if [[ -d "$host_dir" ]]; then
host_name=$(basename "$host_dir")
host_size=$(du -sh "$host_dir" 2>/dev/null | cut -f1)
echo " $host_name: $host_size" >> "$RECAP_FILE"
# Size per container
for container_dir in "$host_dir"/*; do
if [[ -d "$container_dir" ]]; then
container_name=$(basename "$container_dir")
container_size=$(du -sh "$container_dir" 2>/dev/null | cut -f1)
echo " - $container_name: $container_size" >> "$RECAP_FILE"
fi
done
fi
done
echo "" >> "$RECAP_FILE"
echo "TOTAL SIZE: $total_size" >> "$RECAP_FILE"
echo "COMPLETED: $(date '+%d.%m.%Y %H:%M')" >> "$RECAP_FILE"
# Prepare email subject with date format
DATE_SUBJECT=$(date '+%d.%m.%Y %H')
# Send recap email
if [[ $ERROR_COUNT -gt 0 ]]; then
echo "Total errors: $ERROR_COUNT" | tee -a "$LOG_FILE"
# Add errors to recap
echo "" >> "$RECAP_FILE"
echo "ERRORS DETECTED: $ERROR_COUNT" >> "$RECAP_FILE"
echo "----------------------------" >> "$RECAP_FILE"
grep -i "ERROR" "$LOG_FILE" >> "$RECAP_FILE"
# Send email with ERROR in subject
echo "Sending ERROR email to $EMAIL_TO (Errors found: $ERROR_COUNT)" | tee -a "$LOG_FILE"
if command -v msmtp &> /dev/null; then
{
echo "To: $EMAIL_TO"
echo "Subject: BackupPM7 ERROR $DATE_SUBJECT"
echo ""
cat "$RECAP_FILE"
} | msmtp "$EMAIL_TO"
echo "ERROR email sent successfully to $EMAIL_TO" | tee -a "$LOG_FILE"
else
echo "WARNING: msmtp not found - ERROR email NOT sent" | tee -a "$LOG_FILE"
fi
else
echo "Backup completed successfully with no errors" | tee -a "$LOG_FILE"
# Send success recap email
echo "Sending SUCCESS recap email to $EMAIL_TO" | tee -a "$LOG_FILE"
if command -v msmtp &> /dev/null; then
{
echo "To: $EMAIL_TO"
echo "Subject: BackupPM7 $DATE_SUBJECT"
echo ""
cat "$RECAP_FILE"
} | msmtp "$EMAIL_TO"
echo "SUCCESS recap email sent successfully to $EMAIL_TO" | tee -a "$LOG_FILE"
else
echo "WARNING: msmtp not found - SUCCESS recap email NOT sent" | tee -a "$LOG_FILE"
fi
fi
# Clean up recap file
rm -f "$RECAP_FILE"
# Exit with error code if there were errors
if [[ $ERROR_COUNT -gt 0 ]]; then
exit 1
fi

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,143 @@
-- ============================================================================
-- MIGRATION: Gestion multi-contacts par client
-- Version: 2.0.3
-- Date: 2025-10-21
--
-- Description:
-- - Création de la table clients_contacts
-- - Migration des contacts existants depuis la table clients
-- - Ajout du champ fk_contact dans la table devis
--
-- IMPORTANT: Ce script ne modifie PAS la table clients (champs conservés)
-- ============================================================================
USE cleo;
-- ============================================================================
-- ÉTAPE 1: Création de la table clients_contacts
-- ============================================================================
DROP TABLE IF EXISTS `clients_contacts`;
CREATE TABLE `clients_contacts` (
`rowid` int(11) NOT NULL AUTO_INCREMENT,
`fk_client` int(11) NOT NULL,
`nom` varchar(50) DEFAULT NULL,
`prenom` varchar(50) DEFAULT NULL,
`fonction` varchar(50) DEFAULT NULL,
`telephone` varchar(20) DEFAULT NULL,
`mobile` varchar(20) DEFAULT NULL,
`email` varchar(75) DEFAULT NULL,
`principal` tinyint(1) DEFAULT 0 COMMENT 'Contact principal du client',
`active` tinyint(1) DEFAULT 1,
`date_creat` datetime DEFAULT NULL,
`fk_user_creat` int(11) DEFAULT NULL,
`date_modif` datetime DEFAULT NULL,
`fk_user_modif` int(11) DEFAULT NULL,
PRIMARY KEY (`rowid`),
UNIQUE KEY `rowid_UNIQUE` (`rowid`),
KEY `fk_client` (`fk_client`),
KEY `principal` (`fk_client`, `principal`),
KEY `email` (`email`),
CONSTRAINT `clients_contacts_fk_client` FOREIGN KEY (`fk_client`) REFERENCES `clients` (`rowid`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='Contacts multiples par client' `PAGE_COMPRESSED`='ON';
-- ============================================================================
-- ÉTAPE 2: Migration des contacts existants depuis la table clients
-- ============================================================================
INSERT INTO `clients_contacts` (
`fk_client`,
`nom`,
`prenom`,
`fonction`,
`telephone`,
`mobile`,
`email`,
`principal`,
`active`,
`date_creat`,
`fk_user_creat`,
`date_modif`,
`fk_user_modif`
)
SELECT
c.rowid AS fk_client,
c.contact_nom AS nom,
c.contact_prenom AS prenom,
c.contact_fonction AS fonction,
c.telephone,
c.mobile,
c.email,
1 AS principal,
c.active,
c.date_creat,
c.fk_user_creat,
c.date_modif,
c.fk_user_modif
FROM `clients` c
WHERE c.active = 1
AND (
c.contact_nom IS NOT NULL
OR c.contact_prenom IS NOT NULL
OR c.email IS NOT NULL
OR c.telephone IS NOT NULL
OR c.mobile IS NOT NULL
);
-- ============================================================================
-- ÉTAPE 3: Ajout du champ fk_contact dans la table devis
-- ============================================================================
ALTER TABLE `devis`
ADD COLUMN `fk_contact` int(11) DEFAULT NULL AFTER `fk_client`,
ADD KEY `fk_contact` (`fk_contact`);
-- ============================================================================
-- ÉTAPE 4: Liaison des devis existants avec les contacts principaux
-- ============================================================================
UPDATE `devis` d
INNER JOIN `clients_contacts` cc ON d.fk_client = cc.fk_client AND cc.principal = 1
SET d.fk_contact = cc.rowid
WHERE d.fk_client > 0;
-- ============================================================================
-- ÉTAPE 5: Vérifications post-migration
-- ============================================================================
-- Nombre de clients avec contacts
SELECT COUNT(*) AS 'Clients avec contacts migrés'
FROM clients_contacts;
-- Nombre de clients actifs
SELECT COUNT(*) AS 'Total clients actifs'
FROM clients
WHERE active = 1;
-- Nombre de contacts principaux
SELECT COUNT(*) AS 'Contacts principaux'
FROM clients_contacts
WHERE principal = 1;
-- Devis avec contact associé
SELECT COUNT(*) AS 'Devis avec contact associé'
FROM devis
WHERE fk_contact IS NOT NULL;
-- Devis sans contact (à vérifier)
SELECT COUNT(*) AS 'Devis SANS contact (à vérifier)'
FROM devis
WHERE fk_client > 0 AND fk_contact IS NULL;
-- Clients sans contact migré (potentiellement vides)
SELECT c.rowid, c.code, c.libelle
FROM clients c
LEFT JOIN clients_contacts cc ON c.rowid = cc.fk_client
WHERE c.active = 1
AND cc.rowid IS NULL
LIMIT 10;
-- ============================================================================
-- FIN DE LA MIGRATION
-- ============================================================================

View File

@@ -21,11 +21,8 @@
OR c.contact_fonction LIKE :search
ORDER BY c.libelle';
$stmt = $db->prepare($sql);
$searchParam = '%' . $search . '%';
$stmt->bindParam(':search', $searchParam, PDO::PARAM_STR);
$stmt->execute();
$aModel["clients"] = $stmt->fetchAll(PDO::FETCH_ASSOC);
$aModel["clients"] = $db->fetchAll($sql, [':search' => $searchParam]);
} catch (Exception $e) {
error_log("Erreur recherche clients : " . $e->getMessage());
$aModel["clients"] = [];

View File

@@ -17,9 +17,7 @@ switch ($fkRole) {
try {
$db = Database::getInstance();
$sql = 'SELECT rowid FROM users WHERE fk_parent = :fkParent';
$stmt = $db->prepare($sql);
$stmt->execute([':fkParent' => $fkUser]);
$aRR = $stmt->fetchAll(PDO::FETCH_ASSOC);
$aRR = $db->fetchAll($sql, [':fkParent' => $fkUser]);
$rrIds = array_column($aRR, 'rowid');
if (!empty($rrIds)) {

View File

@@ -57,12 +57,18 @@ switch ($Route->_action) {
case "export_sap_devis":
$cid = nettoie_input($Route->_param1);
$cidSafe = intval($cid);
$sql = 'SELECT d.* FROM devis d WHERE d.rowid=' . $cidSafe . ';';
eLog("Export Excel SAP Devis : " . $sql);
$dev = getinfos($sql, "gen");
$devis = $dev[0];
try {
$db = Database::getInstance();
$sql = 'SELECT d.* FROM devis d WHERE d.rowid = :devis_id';
$devis = $db->fetchOne($sql, [':devis_id' => $cidSafe]);
if (!$devis) {
throw new Exception("Devis non trouvé");
}
eLog("Export Excel SAP Devis : " . $cidSafe);
$fileName = "devis_" . $cid . "_" . date('Y_m_d_hi') . ".xls";
@@ -71,48 +77,55 @@ switch ($Route->_action) {
$excelData = implode("\t", array_values($fields)) . "\n";
$fkClientSafe = intval($devis["fk_client"]);
$sql = 'SELECT c.code, c.libelle, c.adresse1, c.adresse2, c.adresse3, c.cp, c.ville FROM clients c WHERE c.rowid=' . $fkClientSafe . ';';
$cli = getinfos($sql, "gen");
if (count($cli) == 0) {
// c'est un nouveau client, on affiche les données client enregistrées dans le devis
$sql = 'SELECT "0" AS code, d.lib_new_client, d.adresse1_new_client, d.adresse2_new_client, d.adresse3_new_client, d.cp_new_client, d.ville_new_client FROM devis d WHERE d.rowid=' . $cidSafe . ';';
$cli = getinfos($sql, "gen");
$client = $cli[0];
if ($fkClientSafe == 0) {
// Nouveau client : données depuis le devis
$sql = 'SELECT "0" AS code, d.lib_new_client, d.adresse1_new_client, d.adresse2_new_client, d.adresse3_new_client, d.cp_new_client, d.ville_new_client FROM devis d WHERE d.rowid = :devis_id';
$client = $db->fetchOne($sql, [':devis_id' => $cidSafe]);
array_walk($client, 'filterData');
$excelData .= implode("\t", array_values($client)) . "\n";
// une ligne vierge de séparation
$excelData .= "\n";
// Les données du contact à prendre aussi dans le devis
$sql = 'SELECT d.contact_new_nom, d.contact_new_prenom, d.contact_new_fonction, d.new_telephone, d.new_mobile, d.new_email FROM devis d WHERE d.rowid=' . $cidSafe . ';';
$cont = getinfos($sql, "gen");
$contact = $cont[0];
// Contact depuis le devis
$sql = 'SELECT d.contact_new_nom, d.contact_new_prenom, d.contact_new_fonction, d.new_telephone, d.new_mobile, d.new_email FROM devis d WHERE d.rowid = :devis_id';
$contact = $db->fetchOne($sql, [':devis_id' => $cidSafe]);
$fields = array("Contact Nom", "Prenom", "Fonction", "Fixe", "Mobile", "Email");
$excelData .= implode("\t", array_values($fields)) . "\n";
array_walk($contact, 'filterData');
$excelData .= implode("\t", array_values($contact)) . "\n";
} else {
$client = $cli[0];
// Client existant : données depuis la table clients
$sql = 'SELECT c.code, c.libelle, c.adresse1, c.adresse2, c.adresse3, c.cp, c.ville FROM clients c WHERE c.rowid = :client_id';
$client = $db->fetchOne($sql, [':client_id' => $fkClientSafe]);
array_walk($client, 'filterData');
$excelData .= implode("\t", array_values($client)) . "\n";
// une ligne vierge de séparation
$excelData .= "\n";
// Les données du contact
$sql = 'SELECT c.contact_nom, c.contact_prenom, c.contact_fonction, c.telephone, c.mobile, c.email FROM clients c WHERE c.rowid=' . $fkClientSafe . ';';
$cont = getinfos($sql, "gen");
$contact = $cont[0];
// Contact lié au devis via devis.fk_contact et clients_contacts
$fkContactSafe = intval($devis["fk_contact"]);
if ($fkContactSafe > 0) {
$sql = 'SELECT cc.nom, cc.prenom, cc.fonction, cc.telephone, cc.mobile, cc.email FROM clients_contacts cc WHERE cc.rowid = :contact_id AND cc.active = 1';
$contact = $db->fetchOne($sql, [':contact_id' => $fkContactSafe]);
} else {
// Fallback : contact principal du client
$sql = 'SELECT cc.nom, cc.prenom, cc.fonction, cc.telephone, cc.mobile, cc.email FROM clients_contacts cc WHERE cc.fk_client = :client_id AND cc.chk_principal = 1 AND cc.active = 1 LIMIT 1';
$contact = $db->fetchOne($sql, [':client_id' => $fkClientSafe]);
}
if ($contact) {
$fields = array("Contact Nom", "Prenom", "Fonction", "Fixe", "Mobile", "Email");
$excelData .= implode("\t", array_values($fields)) . "\n";
array_walk($contact, 'filterData');
$excelData .= implode("\t", array_values($contact)) . "\n";
} else {
// Aucun contact trouvé
$excelData .= "Contact Nom\tPrenom\tFonction\tFixe\tMobile\tEmail\n";
$excelData .= "\t\t\t\t\t\n";
}
}
// une ligne vierge de séparation
$excelData .= "\n";
@@ -121,30 +134,24 @@ switch ($Route->_action) {
$sql = 'SELECT d.rowid, d.num_opportunite, IF(d.date_demande IS NULL OR d.date_demande="0000-00-00", "", DATE_FORMAT(d.date_demande, "%d/%m/%Y")) AS datedem, ';
$sql .= 'IF(d.date_remise IS NULL OR d.date_remise="0000-00-00", "", DATE_FORMAT(d.date_remise, "%d/%m/%Y")) AS daterem, m.libelle AS lib_marche, m.numero AS num_marche, m.nom AS nom_marche, ';
$sql .= 'IF(d.chk_devis_photos=1, "Oui", "Non") AS photos, d.commentaire, IF(d.chk_speciaux=1, "Oui", "Non") AS speciaux ';
$sql .= 'FROM devis d LEFT JOIN marches m ON d.fk_marche=m.rowid WHERE d.rowid=' . $cidSafe . ';';
$dev = getinfos($sql, "gen");
$devis = $dev[0];
$chkSpeciaux = $devis["speciaux"];
$sql .= 'FROM devis d LEFT JOIN marches m ON d.fk_marche=m.rowid WHERE d.rowid = :devis_id';
$devisData = $db->fetchOne($sql, [':devis_id' => $cidSafe]);
$chkSpeciaux = $devisData["speciaux"];
$fields = array("Devis", "Opportunite", "Date Demande", "Date remise client", "Marche", "Num Marche", "Nom Marche", "Avec photos", "Commentaire RR", "Speciaux");
$excelData .= implode("\t", array_values($fields)) . "\n";
array_walk($devis, 'filterData');
$excelData .= implode("\t", array_values($devis)) . "\n";
// une ligne vierge de séparation
array_walk($devisData, 'filterData');
$excelData .= implode("\t", array_values($devisData)) . "\n";
$excelData .= "\n";
// on affiche les totaux du devis
$sql = 'SELECT d.montant_total_ht, d.montant_total_ht_remise, d.marge_totale FROM devis d WHERE d.rowid=' . $cidSafe . ';';
$dev = getinfos($sql, "gen");
$totaux = $dev[0];
$sql = 'SELECT d.montant_total_ht, d.montant_total_ht_remise, d.marge_totale FROM devis d WHERE d.rowid = :devis_id';
$totaux = $db->fetchOne($sql, [':devis_id' => $cidSafe]);
$fields = array("Total HT", "Total HT Remise", "Marge Totale");
$excelData .= implode("\t", array_values($fields)) . "\n";
array_walk($totaux, 'filterData');
$excelData .= implode("\t", array_values($totaux)) . "\n";
// une ligne vierge de séparation
$excelData .= "\n";
// on affiche les produits
@@ -155,8 +162,8 @@ switch ($Route->_action) {
$sql .= 'LEFT JOIN produits p ON dp.fk_produit=p.rowid ';
$sql .= 'LEFT JOIN produits_familles pf ON p.groupe=pf.groupe ';
$sql .= 'LEFT JOIN x_familles xf ON pf.fk_famille=xf.rowid ';
$sql .= 'WHERE dp.fk_devis=' . $cidSafe . ' ORDER BY dp.ordre, xf.ordre, p.libelle;';
$data = getinfos($sql, "gen");
$sql .= 'WHERE dp.fk_devis = :devis_id ORDER BY dp.ordre, xf.ordre, p.libelle';
$data = $db->fetchAll($sql, [':devis_id' => $cidSafe]);
$fields = array("Code", "Designation", "Prix Vente", "Quantite", "Remise", "PU vente avec remise", "Total HT", "Marge", "Commentaire");
$excelData .= implode("\t", array_values($fields)) . "\n";
@@ -167,22 +174,19 @@ switch ($Route->_action) {
}
if ($chkSpeciaux == "Oui") {
// une ligne vierge de séparation
$excelData .= "\n";
$excelData .= "----" . "\n";
$excelData .= "PRODUITS SPECIAUX" . "\n";
$excelData .= "----" . "\n";
$sql = 'SELECT IF(ds.chk_livr_multi=1, "Oui", "Non") AS livr_multi, ds.nb_livr, DATE_FORMAT(ds.date_livr_1, "%d/%m/%Y") AS datelivr ';
$sql .= 'FROM devis_speciaux ds WHERE ds.fk_devis=' . $cidSafe . ';';
$spec = getinfos($sql, "gen");
$speciaux = $spec[0];
$sql .= 'FROM devis_speciaux ds WHERE ds.fk_devis = :devis_id';
$speciaux = $db->fetchOne($sql, [':devis_id' => $cidSafe]);
$fields = array("Livraisons multiples", "Nbre livraisons", "Date 1ere livraison");
$excelData .= implode("\t", array_values($fields)) . "\n";
array_walk($speciaux, 'filterData');
$excelData .= implode("\t", array_values($speciaux)) . "\n";
$excelData .= "\n";
$fields = array("#", "Code", "Designation", "Quantite", "Surcout", "Echantillon", "Date echantillon", "Concurrent", "Description");
@@ -191,29 +195,34 @@ switch ($Route->_action) {
for ($i = 1; $i <= 5; $i++) {
$sql = 'SELECT ds.fk_produit_' . $i . ', ds.code_produit_' . $i . ', ds.lib_produit_' . $i . ', ds.qte_' . $i . ', IF(ds.surcout_' . $i . '=0, "", FORMAT(ds.surcout_' . $i . ', 2, "fr_FR")), IF(ds.chk_echantillon_' . $i . '=1, "Oui", "Non") AS echantillon, ';
$sql .= 'DATE_FORMAT(ds.date_echantillon_' . $i . ', "%d/%m/%Y") AS date_ech, ds.lib_concurrent_' . $i . ', ds.description_' . $i . ' ';
$sql .= 'FROM devis_speciaux ds WHERE ds.fk_devis=' . $cidSafe . ';';
eLog($sql, "sql");
$spec = getinfos($sql, "gen");
$speciaux = $spec[0];
$sql .= 'FROM devis_speciaux ds WHERE ds.fk_devis = :devis_id';
if ($speciaux["fk_produit_" . $i] > 0) {
array_walk($speciaux, 'filterData');
$excelData .= implode("\t", array_values($speciaux)) . "\n";
$produitSpecial = $db->fetchOne($sql, [':devis_id' => $cidSafe]);
if ($produitSpecial && $produitSpecial["fk_produit_" . $i] > 0) {
array_walk($produitSpecial, 'filterData');
$excelData .= implode("\t", array_values($produitSpecial)) . "\n";
}
}
}
// une ligne vierge de séparation
$excelData .= "\n";
$excelData .= "----" . "\n";
$excelData .= "FIN DU DEVIS" . "\n";
$excelData .= "----" . "\n";
$excelData .= "\n";
header('Content-Type: application/vnd.ms-excel; charset=utf-16le');
header("Content-type: application/x-msexcel; charset=utf-16le");
header('Content-Disposition: attachment; filename="' . $fileName . '"');
header('Cache-Control: max-age=0');
echo $excelData;
} catch (Exception $e) {
error_log("Erreur export Excel : " . $e->getMessage());
http_response_code(500);
echo "Erreur lors de l'export du devis";
}
exit();
}

View File

@@ -254,3 +254,105 @@ function timeEnd($start, $label = '') {
return $time;
}
function loadtel($numero, $prefix = "+33") {
$lenumero = trim($numero);
$lenumero = preg_replace('/[^0-9]/', '', $lenumero);
if (strlen($lenumero) == 10) {
$lenumero = substr($lenumero, 1);
}
if (strlen($lenumero) == 9) {
$lenumero = $prefix . $lenumero;
}
return $lenumero;
}
function formattel($numero, $separateur = " ") {
if (strlen($numero) == 9) {
$numero = "0" . $numero;
}
if (strlen($numero) == 10) {
$numero = substr($numero, 0, 2) . $separateur . substr($numero, 2, 2) . $separateur . substr($numero, 4, 2) . $separateur . substr($numero, 6, 2) . $separateur . substr($numero, 8, 2);
}
return $numero;
}
function str_normalize($string, $minuscules = true) {
$result = "";
$string = trim($string);
if (strlen($string) > 0) {
if ($minuscules) {
$result = strtolower($string);
} else {
$result = $string;
}
$result = str_replace(" ", "_", $result);
$result = str_replace("é", "e", $result);
$result = str_replace("è", "e", $result);
$result = str_replace("ê", "e", $result);
$result = str_replace("ë", "e", $result);
$result = str_replace("à", "a", $result);
$result = str_replace("â", "a", $result);
$result = str_replace("ä", "a", $result);
$result = str_replace("ô", "o", $result);
$result = str_replace("ö", "o", $result);
$result = str_replace("ù", "u", $result);
$result = str_replace("û", "u", $result);
$result = str_replace("ü", "u", $result);
$result = str_replace("ç", "c", $result);
$result = str_replace("'", "", $result);
$result = str_replace("\"", "", $result);
$result = str_replace("/", "", $result);
$result = str_replace("(", "_", $result);
$result = str_replace(")", "_", $result);
$result = str_replace("!", "_", $result);
$result = str_replace("?", "_", $result);
$result = trim($result);
}
return $result;
}
function generateRandomPassword() {
$password = '';
$desired_length = rand(8, 12);
for ($length = 0; $length < $desired_length; $length++) {
$password .= chr(rand(44, 122));
}
$password = str_replace("/", "&", $password);
$password = str_replace("<", "!", $password);
$password = str_replace(">", "!", $password);
$password = str_replace("=", "#", $password);
$password = str_replace("\\", "&", $password);
$password = str_replace("^", "%", $password);
$password = str_replace(chr(96), "#", $password);
return $password;
}
function purge_old_logs($log_dir, $app_name, $days_to_keep = 10) {
if (!is_dir($log_dir)) {
return;
}
$date_limit = strtotime("-{$days_to_keep} days");
$patterns = array(
$app_name . '_????-??-??.log',
$app_name . '_debug_????-??-??.log'
);
foreach ($patterns as $pattern) {
$files = glob($log_dir . $pattern);
if ($files) {
foreach ($files as $file) {
if (preg_match('/(\d{4}-\d{2}-\d{2})\.log$/', $file, $matches)) {
$file_date = strtotime($matches[1]);
if ($file_date < $date_limit) {
@unlink($file);
}
}
}
}
}
}

View File

@@ -21,6 +21,8 @@ let chkShowDevisArchives = false // indique si on affiche les devis archivés ou
let chkCreateClient = false
// On charge les produits du marché du devis en cours dans un tableau pour ne pas avoir à les recharger à chaque fois
let dataProduitsMarche = []
let dataProduitsMercurial = [] // Produits du marché hybride pour l'onglet Mercurial
let chkMarcheHybride = false // Indique si le marché du devis est hybride
//! Pour ne charger les clients du secteur ou de toute la France qu'en cas de changement de la valeur du chkbox
let oldChkClientsSecteur = 2
let clients = []
@@ -407,18 +409,24 @@ window.addEventListener('DOMContentLoaded', (event) => {
document.getElementById('inp_adresse3').value = data.adresse3
document.getElementById('inp_cp').value = data.cp
document.getElementById('inp_ville').value = data.ville
document.getElementById('inp_contact_nom').value = data.contact_nom
document.getElementById('inp_contact_prenom').value = data.contact_prenom
document.getElementById('inp_contact_fonction').value = data.contact_fonction
document.getElementById('inp_email').value = data.email
document.getElementById('inp_telephone').value = data.telephone
document.getElementById('inp_mobile').value = data.mobile
document.getElementById('selTypeEtab').value = data.type_client
elBtnCreateClient.innerHTML = 'Créer un nouveau client'
if (elBtnCreateClient.classList.contains('btn-info')) {
elBtnCreateClient.classList.remove('btn-info')
elBtnCreateClient.classList.add('btn-primary')
}
// Charger les contacts du client et sélectionner le contact du devis
loadContactsClient(data.code).then(() => {
if (data.fk_contact && data.fk_contact > 0) {
document.getElementById('sel_contact').value = data.fk_contact
// Afficher les infos du contact
const contact = contactsClient.find(c => c.rowid == data.fk_contact)
if (contact) {
displayContactInfos(contact)
}
}
})
}
if (data.chk_devis_photos == '1') {
@@ -908,6 +916,86 @@ window.addEventListener('DOMContentLoaded', (event) => {
if (ret.length == 1) {
let line = ret[0]
chkPrixNets = line.chk_prix_nets == 1 ? true : false
chkMarcheHybride = line.chk_marche_hybride == 1 ? true : false
// Si le marché est hybride, créer le panel et charger les produits
if (chkMarcheHybride) {
// Créer le panel Mercurial s'il n'existe pas
if (!document.getElementById('tabMercurial')) {
const tabContent = document.querySelector('#divProduitsDisponibles .tab-content')
const divPanel = document.createElement('div')
divPanel.setAttribute('role', 'tabpanel')
divPanel.className = 'tab-pane'
divPanel.id = 'tabMercurial'
divPanel.innerHTML = `
<div class="form-group">
<label for="inpSearchProduct_Mercurial">Recherche de produits Mercurial : </label>
<div class="input-group">
<input type="text" class="form-control" id="inpSearchProduct_Mercurial" placeholder="code ou libellé" size="50px" />
<div class="input-group-addon">
<svg width="18px" height="18px" viewBox="0 0 20 20" role="img" xmlns="http://www.w3.org/2000/svg" aria-labelledby="returnIconTitle" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none" color="#000000">
<path d="M19,8 L19,11 C19,12.1045695 18.1045695,13 17,13 L6,13"/>
<polyline points="8 16 5 13 8 10"/>
</svg>
</div>
</div>
</div>
<div class="border cm-scrollbar cm-table-w-scroll table-responsive mt-1 table-400">
<table class="table table-striped table-bordered table-responsive table-fixed" id="tblProduits_Mercurial">
<thead>
<tr>
<th class="header" scope="col" width="10%">Sélection</th>
<th class="header" scope="col" width="20%">Code</th>
<th class="header" scope="col" width="30%">Libellé</th>
<th class="header" scope="col" width="10%">Famille</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
`
tabContent.appendChild(divPanel)
}
// Charger les produits Mercurial
fetch('/jxdevis/load_produits_mercurial', {
method: 'POST',
body: JSON.stringify({ fk_marche: idMarche }),
headers: {
'Content-Type': 'application/json;charset=utf-8',
Accept: 'application/json;charset=utf-8',
},
}).then((response) => {
if (response.ok) {
const ret = response.json()
ret.then(function (data) {
dataProduitsMercurial = data
showProduitsMercurial(data)
// Créer l'onglet Mercurial en dernière position après affichage des produits
if (!document.getElementById('liOngletMercurial')) {
const ulOnglets = document.getElementById('listOngletsProduits')
const liOnglet = document.createElement('li')
liOnglet.setAttribute('role', 'presentation')
liOnglet.id = 'liOngletMercurial'
liOnglet.innerHTML = '<a href="#tabMercurial" id="onglet_mercurial" aria-controls="tabMercurial" role="tab" data-toggle="tab" style="background-color: #5cb85c; color: white; font-weight: bold;">Mercurial<br/>&nbsp;</a>'
ulOnglets.appendChild(liOnglet)
}
})
}
})
} else {
// Supprimer l'onglet et le panel Mercurial s'ils existent
const liOnglet = document.getElementById('liOngletMercurial')
if (liOnglet) {
liOnglet.remove()
}
const divPanel = document.getElementById('tabMercurial')
if (divPanel) {
divPanel.remove()
}
}
document.getElementById('inp_latitudeRR').value = seuilMargeRR
document.getElementById('inp_latitudeDV').value = seuilMargeDV
document.getElementById('titleMarche').innerHTML = '<bolder>Informations du marché ' + line.libelle + '</bolder>' // le titre du panel des infos marché dans l'onglet 3. Devis
@@ -1146,6 +1234,54 @@ window.addEventListener('DOMContentLoaded', (event) => {
celFamille.innerHTML = lineProduit.lib_famille
}
function showProduitsMercurial(dProduits) {
// Affiche tous les produits Mercurial dans l'onglet dédié
let tblBody = document.getElementById('tblProduits_Mercurial').getElementsByTagName('tbody')[0]
tblBody.innerHTML = ''
if (dProduits && dProduits.length > 0) {
dProduits.forEach(function (lineProduit) {
let newRow = tblBody.insertRow(-1)
newRow.className = 'ligProduit_Mercurial'
newRow.id = 'ligProduit_Mercurial_' + lineProduit.rowid
newRow.setAttribute('data-rid', lineProduit.rowid)
let celChkBox = newRow.insertCell(0)
celChkBox.className = 'chkBox_Mercurial text-center'
celChkBox.setAttribute('data-rid', lineProduit.rowid)
celChkBox.innerHTML =
'<input type="checkbox" class="chkBox" id="chkBoxProd_' +
lineProduit.rowid +
'" name="chkBoxProd_' +
lineProduit.rowid +
'" data-rid="' +
lineProduit.rowid +
'" data-code="' +
lineProduit.code +
'" data-libelle="' +
lineProduit.libelle +
'" data-famille="Mercurial" />'
let celCode = newRow.insertCell(1)
celCode.innerHTML = lineProduit.code
let celLibelle = newRow.insertCell(2)
celLibelle.innerHTML = lineProduit.libelle
let celFamille = newRow.insertCell(3)
celFamille.innerHTML = lineProduit.lib_famille || '-'
})
// Brancher l'autocomplete sur le champ de recherche
autocompleteProduitsFamille(
document.getElementById('inpSearchProduct_Mercurial'),
dProduits,
'Mercurial',
0
)
}
}
$('a[data-toggle="tab"]').on('show.bs.tab', function (e) {
if (idDevis == 0) {
if ($(this).attr('href') == '#tabproduits' || $(this).attr('href') == '#tabdevis') {
@@ -1346,33 +1482,63 @@ window.addEventListener('DOMContentLoaded', (event) => {
}
let clickSaveCreateClient = function () {
// on regarde si c'est une création de devis ou une modification
// on enregistre le fait que ça soit un nouveau client
// on met à jour les champs du devis avec les infos du nouveau client
// et quand on enregistre le devis on enregistre le nouveau client dans le devis
document.getElementById('inp_fk_client').value = '0'
document.getElementById('inp_lib_client').value = document.getElementById('inp_create_libelle').value
document.getElementById('selTypeEtab').value = document.getElementById('inp_create_type_client').value
document.getElementById('inp_type_client').value = document.getElementById('inp_create_type_client').value
document.getElementById('inp_adresse1').value = document.getElementById('inp_create_adresse1').value
document.getElementById('inp_adresse2').value = document.getElementById('inp_create_adresse2').value
document.getElementById('inp_adresse3').value = document.getElementById('inp_create_adresse3').value
document.getElementById('inp_cp').value = document.getElementById('inp_create_cp').value
document.getElementById('inp_ville').value = document.getElementById('inp_create_ville').value
showLoading()
const clientData = {
libelle: document.getElementById('inp_create_libelle').value,
type_client: document.getElementById('inp_create_type_client').value,
adresse1: document.getElementById('inp_create_adresse1').value,
adresse2: document.getElementById('inp_create_adresse2').value,
adresse3: document.getElementById('inp_create_adresse3').value,
cp: document.getElementById('inp_create_cp').value,
ville: document.getElementById('inp_create_ville').value
}
fetch('/jxdevis/save_new_client', {
method: 'POST',
body: JSON.stringify(clientData),
headers: {
'Content-Type': 'application/json;charset=utf-8',
Accept: 'application/json;charset=utf-8',
},
})
.then((response) => response.json())
.then((data) => {
hideLoading()
if (data.success) {
const newClientId = data.rowid
const newClientCode = data.code
document.getElementById('inp_fk_client').value = newClientId
document.getElementById('inp_lib_client').value = clientData.libelle
document.getElementById('selTypeEtab').value = clientData.type_client
document.getElementById('inp_type_client').value = clientData.type_client
document.getElementById('inp_adresse1').value = clientData.adresse1
document.getElementById('inp_adresse2').value = clientData.adresse2
document.getElementById('inp_adresse3').value = clientData.adresse3
document.getElementById('inp_cp').value = clientData.cp
document.getElementById('inp_ville').value = clientData.ville
console.log(
'nouveau client créé : ' +
document.getElementById('inp_create_type_client').value +
' -> ' +
document.getElementById('selTypeEtab').value
)
hideModal(document.getElementById('modalCreateClient'))
// on change le texte et la couleur du bouton de nouveau client
document.getElementById('btnCreateClient').innerHTML = 'Modifier le nouveau client'
document.getElementById('btnCreateClient').innerHTML = 'Modifier le client'
document.getElementById('btnCreateClient').classList.remove('btn-primary')
document.getElementById('btnCreateClient').classList.add('btn-info')
chkCreateClient = true
loadContactsClient(newClientCode).then(() => {
showNotification('Succès', 'Client créé avec succès. Vous pouvez maintenant gérer ses contacts.', 'success')
})
chkCreateClient = false
} else {
showNotification('Erreur', data.message || 'Erreur lors de la création du client', 'error')
}
})
.catch((error) => {
hideLoading()
console.error('Erreur création client:', error)
showNotification('Erreur', 'Impossible de créer le client', 'error')
})
}
let clickSpeciaux = function () {
@@ -1510,45 +1676,13 @@ window.addEventListener('DOMContentLoaded', (event) => {
return false
}
if (document.getElementById('inp_contact_nom').value == '') {
if (document.getElementById('sel_contact').value == '0') {
showNotification(
'Erreur',
'Enregistrement impossible : vous devez renseigner le nom et prénom du contact',
'Enregistrement impossible : vous devez sélectionner un contact',
'error'
)
document.getElementById('inp_contact_nom').focus()
return false
}
if (document.getElementById('inp_contact_prenom').value == '') {
showNotification(
'Erreur',
'Enregistrement impossible : vous devez renseigner le nom et prénom du contact',
'error'
)
document.getElementById('inp_contact_prenom').focus()
return false
}
if (document.getElementById('inp_contact_fonction').value == '') {
showNotification('Erreur', 'Enregistrement impossible : vous devez renseigner la fonction du contact', 'error')
document.getElementById('inp_contact_fonction').focus()
return false
}
if (document.getElementById('inp_email').value == '') {
showNotification('Erreur', "Enregistrement impossible : vous devez renseigner l'email du contact", 'error')
document.getElementById('inp_email').focus()
return false
}
if (document.getElementById('inp_telephone').value == '' && document.getElementById('inp_mobile').value == '') {
showNotification(
'Erreur',
'Enregistrement impossible : vous devez renseigner au moins un numéro de téléphone du contact (fixe ou mobile)',
'error'
)
document.getElementById('inp_telephone').focus()
document.getElementById('sel_contact').focus()
return false
}
@@ -1817,10 +1951,10 @@ window.addEventListener('DOMContentLoaded', (event) => {
showDevisPro(data)
hideLoading()
showNotification('Succès', 'Enregistrement des ' + nbProduits + ' produits de ce devis effectué', 'success')
chkChange = 0
})
}
})
chkChange = 0
return false
}
@@ -2891,13 +3025,9 @@ window.addEventListener('DOMContentLoaded', (event) => {
document.getElementById('inp_adresse3').value = list[i]['adresse3']
document.getElementById('inp_cp').value = list[i]['cp']
document.getElementById('inp_ville').value = list[i]['ville']
document.getElementById('inp_contact_nom').value = list[i]['contact_nom']
document.getElementById('inp_contact_prenom').value = list[i]['contact_prenom']
document.getElementById('inp_contact_fonction').value = list[i]['contact_fonction']
document.getElementById('inp_telephone').value = list[i]['telephone']
document.getElementById('inp_email').value = list[i]['email']
document.getElementById('inp_mobile').value = list[i]['mobile']
document.getElementById('selTypeEtab').value = list[i]['type_client']
// Charger les contacts du client sélectionné
loadContactsClient(list[i]['code'])
// on ferme la liste des suggestions
closeList()
})
@@ -3305,6 +3435,284 @@ window.addEventListener('DOMContentLoaded', (event) => {
hideModal(document.getElementById('modalCommentProd'))
}
//! ========== GESTION DES CONTACTS ==========
let contactsClient = [] // Liste des contacts du client en cours
let currentFkClient = 0 // ID du client en cours
// Charger les contacts d'un client
function loadContactsClient(fkClient) {
if (fkClient == 0) {
document.getElementById('sel_contact').innerHTML = '<option value="0">- Sélectionner un contact -</option>'
document.getElementById('btnGererContacts').disabled = true
document.getElementById('divContactInfos').style.display = 'none'
contactsClient = []
currentFkClient = 0
return Promise.resolve()
}
currentFkClient = fkClient
return fetch('/jxcontacts/load_contacts', {
method: 'POST',
body: JSON.stringify({ fk_client: fkClient }),
headers: {
'Content-Type': 'application/json;charset=utf-8',
Accept: 'application/json;charset=utf-8',
},
})
.then((response) => response.json())
.then((data) => {
contactsClient = data
const selContact = document.getElementById('sel_contact')
selContact.innerHTML = '<option value="0">- Sélectionner un contact -</option>'
data.forEach((contact) => {
const option = document.createElement('option')
option.value = contact.rowid
option.textContent = contact.nom + ' ' + contact.prenom + (contact.principal == 1 ? ' ⭐' : '')
selContact.appendChild(option)
})
document.getElementById('btnGererContacts').disabled = false
// Si un seul contact, le sélectionner automatiquement
if (data.length === 1) {
selContact.value = data[0].rowid
displayContactInfos(data[0])
}
})
.catch((error) => {
console.error('Erreur chargement contacts:', error)
showNotification('Erreur', 'Impossible de charger les contacts', 'error')
})
}
// Afficher les infos d'un contact en lecture seule
function displayContactInfos(contact) {
document.getElementById('info_contact_nom_prenom').textContent = (contact.prenom || '') + ' ' + (contact.nom || '')
document.getElementById('info_contact_fonction').textContent = contact.fonction || '-'
document.getElementById('info_contact_email').textContent = contact.email || '-'
document.getElementById('info_contact_telephone').textContent = contact.telephone || '-'
document.getElementById('info_contact_mobile').textContent = contact.mobile || '-'
document.getElementById('divContactInfos').style.display = 'block'
}
// Événement changement de contact
document.getElementById('sel_contact').addEventListener('change', function () {
const contactId = this.value
if (contactId == 0) {
document.getElementById('divContactInfos').style.display = 'none'
return
}
const contact = contactsClient.find((c) => c.rowid == contactId)
if (contact) {
displayContactInfos(contact)
}
})
// Ouvrir la modale de gestion des contacts
document.getElementById('btnGererContacts').addEventListener('click', function () {
document.getElementById('inp_fk_client_contacts').value = currentFkClient
loadContactsTable()
showModal(document.getElementById('modalGererContacts'))
})
// Charger la table des contacts
function loadContactsTable() {
const tbody = document.querySelector('#tblContacts tbody')
tbody.innerHTML = '<tr><td colspan="7" class="text-center">Chargement...</td></tr>'
fetch('/jxcontacts/load_contacts', {
method: 'POST',
body: JSON.stringify({ fk_client: currentFkClient }),
headers: {
'Content-Type': 'application/json;charset=utf-8',
Accept: 'application/json;charset=utf-8',
},
})
.then((response) => response.json())
.then((data) => {
tbody.innerHTML = ''
if (data.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center">Aucun contact trouvé</td></tr>'
return
}
data.forEach((contact) => {
const tr = document.createElement('tr')
tr.innerHTML = `
<td>${contact.nom || ''}</td>
<td>${contact.prenom || ''}</td>
<td>${contact.fonction || ''}</td>
<td>${contact.telephone || ''}</td>
<td>${contact.email || ''}</td>
<td class="text-center">
${contact.principal == 1 ? '<span class="label label-success">Oui</span>' : '<button class="btn btn-xs btn-default btnSetPrincipal" data-id="' + contact.rowid + '">Définir</button>'}
</td>
<td class="text-center">
<button class="btn btn-xs btn-primary btnEditContact" data-id="${contact.rowid}"><i class="fa fa-edit"></i></button>
<button class="btn btn-xs btn-danger btnDeleteContact" data-id="${contact.rowid}"><i class="fa fa-trash"></i></button>
</td>
`
tbody.appendChild(tr)
})
// Attacher les événements
document.querySelectorAll('.btnEditContact').forEach((btn) => {
btn.addEventListener('click', function () {
editContact(this.getAttribute('data-id'))
})
})
document.querySelectorAll('.btnDeleteContact').forEach((btn) => {
btn.addEventListener('click', function () {
deleteContact(this.getAttribute('data-id'))
})
})
document.querySelectorAll('.btnSetPrincipal').forEach((btn) => {
btn.addEventListener('click', function () {
setPrincipalContact(this.getAttribute('data-id'))
})
})
})
.catch((error) => {
console.error('Erreur chargement table contacts:', error)
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-danger">Erreur de chargement</td></tr>'
})
}
// Nouveau contact
document.getElementById('btnNouveauContact').addEventListener('click', function () {
document.getElementById('modEditContactTitreText').textContent = 'Nouveau contact'
document.getElementById('frmEditContact').reset()
document.getElementById('inp_contact_rowid').value = '0'
document.getElementById('inp_contact_fk_client').value = currentFkClient
showModal(document.getElementById('modalEditContact'))
})
// Éditer un contact
function editContact(contactId) {
fetch('/jxcontacts/load_contact', {
method: 'POST',
body: JSON.stringify({ rowid: contactId }),
headers: {
'Content-Type': 'application/json;charset=utf-8',
Accept: 'application/json;charset=utf-8',
},
})
.then((response) => response.json())
.then((contact) => {
document.getElementById('modEditContactTitreText').textContent = 'Modifier le contact'
document.getElementById('inp_contact_rowid').value = contact.rowid
document.getElementById('inp_contact_fk_client').value = contact.fk_client
document.getElementById('inp_contact_nom_edit').value = contact.nom || ''
document.getElementById('inp_contact_prenom_edit').value = contact.prenom || ''
document.getElementById('inp_contact_fonction_edit').value = contact.fonction || ''
document.getElementById('inp_contact_email_edit').value = contact.email || ''
document.getElementById('inp_contact_telephone_edit').value = contact.telephone || ''
document.getElementById('inp_contact_mobile_edit').value = contact.mobile || ''
document.getElementById('inp_contact_principal_edit').checked = contact.principal == 1
showModal(document.getElementById('modalEditContact'))
})
.catch((error) => {
console.error('Erreur chargement contact:', error)
showNotification('Erreur', 'Impossible de charger le contact', 'error')
})
}
// Sauvegarder un contact
document.getElementById('btnSaveEditContact').addEventListener('click', function () {
const form = document.getElementById('frmEditContact')
const formData = new FormData(form)
fetch('/jxcontacts/save_contact', {
method: 'POST',
body: formData,
})
.then((response) => response.json())
.then((data) => {
if (data.ret === 'ok') {
showNotification('Succès', data.msg, 'success')
hideModal(document.getElementById('modalEditContact'))
loadContactsTable()
loadContactsClient(currentFkClient) // Recharger le sélecteur
} else {
showNotification('Erreur', data.msg, 'error')
}
})
.catch((error) => {
console.error('Erreur sauvegarde contact:', error)
showNotification('Erreur', 'Impossible de sauvegarder le contact', 'error')
})
})
// Supprimer un contact
function deleteContact(contactId) {
if (!confirm('Êtes-vous sûr de vouloir supprimer ce contact ?')) return
fetch('/jxcontacts/delete_contact', {
method: 'POST',
body: JSON.stringify({ rowid: contactId }),
headers: {
'Content-Type': 'application/json;charset=utf-8',
Accept: 'application/json;charset=utf-8',
},
})
.then((response) => response.json())
.then((data) => {
if (data.ret === 'ok') {
showNotification('Succès', data.msg, 'success')
loadContactsTable()
loadContactsClient(currentFkClient)
} else {
showNotification('Erreur', data.msg, 'error')
}
})
.catch((error) => {
console.error('Erreur suppression contact:', error)
showNotification('Erreur', 'Impossible de supprimer le contact', 'error')
})
}
// Définir contact principal
function setPrincipalContact(contactId) {
fetch('/jxcontacts/set_principal', {
method: 'POST',
body: JSON.stringify({ rowid: contactId, fk_client: currentFkClient }),
headers: {
'Content-Type': 'application/json;charset=utf-8',
Accept: 'application/json;charset=utf-8',
},
})
.then((response) => response.json())
.then((data) => {
if (data.ret === 'ok') {
showNotification('Succès', data.msg, 'success')
loadContactsTable()
loadContactsClient(currentFkClient)
} else {
showNotification('Erreur', data.msg, 'error')
}
})
.catch((error) => {
console.error('Erreur définition contact principal:', error)
showNotification('Erreur', 'Impossible de définir le contact principal', 'error')
})
}
// Fermer les modales
document.getElementById('btnFermerContacts').addEventListener('click', function () {
hideModal(document.getElementById('modalGererContacts'))
})
document.getElementById('btnCancelEditContact').addEventListener('click', function () {
hideModal(document.getElementById('modalEditContact'))
})
//! ========== FIN GESTION DES CONTACTS ==========
//! Configuration des événements
//! Sur chaque cellule du tableau des devis ayant la classe celDevis, on affecte un événement click qui appelle la fonction clickLigDevis()
Array.from(elCelDevis).forEach(function (lnDevis) {

View File

@@ -376,34 +376,34 @@ ob_start();
</div>
</div>
<div class="form-group">
<label class="control-label col-md-2" for="inp_contact_nom">Contact :</label>
<label class="control-label col-md-2" for="sel_contact">Contact :</label>
<div class="col-md-4">
<input type="text" class="form-control" id="inp_contact_nom" name="contact_nom" placeholder="Nom" required="required"/>
<p class="help-block">Nom du contact</p>
<select class="form-control" id="sel_contact" name="fk_contact" required="required">
<option value="0">- Sélectionner un contact -</option>
</select>
</div>
<div class="col-md-4">
<input type="text" class="form-control" id="inp_contact_prenom" name="contact_prenom" placeholder="Prénom" required="required"/>
<p class="help-block">Prénom du contact</p>
<div class="col-md-3">
<button type="button" class="btn btn-primary" id="btnGererContacts" disabled>Gérer les contacts</button>
</div>
</div>
<div id="divContactInfos" style="display: none;">
<div class="form-group">
<label class="control-label col-md-2" for="inp_contact_fonction">Fonction du contact :</label>
<div class="col-md-3">
<input type="text" class="form-control" id="inp_contact_fonction" name="contact_fonction" required="required"/>
<label class="control-label col-md-2"></label>
<div class="col-md-8">
<div class="well well-sm">
<div class="row">
<div class="col-md-6">
<p class="mb-0"><strong>Nom et Prénom :</strong> <span id="info_contact_nom_prenom"></span></p>
<p class="mb-0"><strong>Fonction :</strong> <span id="info_contact_fonction"></span></p>
<p class="mb-0"><strong>Email :</strong> <span id="info_contact_email"></span></p>
</div>
<div class="col-md-6">
<p class="mb-0"><strong>Téléphone :</strong> <span id="info_contact_telephone"></span></p>
<p class="mb-0"><strong>Mobile :</strong> <span id="info_contact_mobile"></span></p>
</div>
<label class="control-label col-md-2" for="inp_email">Email :</label>
<div class="col-md-3">
<input type="text" class="form-control" id="inp_email" name="email" required="required"/>
</div>
</div>
<div class=" form-group">
<label class=" control-label col-md-2" for="inp_telephone">Tél :</label>
<div class="col-md-2">
<input type="text" class="form-control" id="inp_telephone" name="telephone" size="10" maxlength="18" placeholder="Fixe"/>
</div>
<label class=" control-label col-md-2" for="inp_mobile">Mob :</label>
<div class="col-md-2">
<input type="text" class="form-control" id="inp_mobile" name="mobile" size=" 10" maxlength="18" placeholder="Mobile"/>
</div>
</div>
<div class="form-group">
@@ -556,25 +556,14 @@ ob_start();
<input type="text" class="form-control numeric" id="inpTotalHT" name="inpTotalHT" readonly="readonly" tabindex="-1" size="12" maxlength="12"/>
<div class="input-group-addon">&euro;</div>
</div>
</td>
<td>
<?php
if ($Conf->_devIp) {
echo '<div class="form-group">';
echo '<label for="inpCoutTotalAchat">Coût total achat :</label>';
}
?>
</td>
<td>
<?php
if ($Conf->_devIp) {
echo '<div class="input-group">';
echo '<input type="text" class="form-control numeric" id="inpCoutTotalAchat" name="inpCoutTotalAchat" readonly="readonly" tabindex="-1" size="12" maxlength="12"/>';
echo '<div class="input-group-addon">&euro;</div>';
echo '</div></div>';
echo '<input type="hidden" id="inpCoutTotalAchat" name="inpCoutTotalAchat"/>';
}
?>
</td>
<td></td>
<td></td>
</tr>
<tr>
<td>
@@ -952,6 +941,117 @@ ob_start();
</div>
</div>
</div>
<div class="modal draggable fade" id="modalGererContacts" tabindex="-1" role="dialog" aria-labelledby="modalGererContactsTitre" data-backdrop="static">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title" id="modGererContactsTitre"><i class="fa fa-address-book fa-lg"></i> Gestion des contacts du client</h4>
</div>
<div class="modal-body">
<input type="hidden" id="inp_fk_client_contacts" value="0"/>
<div class="row mb-2">
<div class="col-md-12">
<button type="button" class="btn btn-success btn-sm" id="btnNouveauContact"><i class="fa fa-plus"></i> Nouveau contact</button>
</div>
</div>
<div class="row">
<div class="col-md-12">
<div class="table-responsive">
<table class="table table-bordered table-striped" id="tblContacts">
<thead>
<tr>
<th width="20%">Nom</th>
<th width="15%">Prénom</th>
<th width="20%">Fonction</th>
<th width="15%">Téléphone</th>
<th width="20%">Email</th>
<th width="10%" class="text-center">Principal</th>
<th width="15%" class="text-center">Actions</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" id="btnFermerContacts">Fermer</button>
</div>
</div>
</div>
</div>
<div class="modal draggable fade" id="modalEditContact" tabindex="-1" role="dialog" aria-labelledby="modalEditContactTitre" data-backdrop="static">
<div class="modal-dialog modal-md">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title" id="modEditContactTitre"><i class="fa fa-user fa-lg"></i> <span id="modEditContactTitreText">Nouveau contact</span></h4>
</div>
<div class="modal-body">
<form id="frmEditContact" class="form-horizontal" autocomplete="off">
<input type="hidden" id="inp_contact_rowid" name="rowid" value="0"/>
<input type="hidden" id="inp_contact_fk_client" name="fk_client" value="0"/>
<div class="form-group">
<label class="control-label col-md-3" for="inp_contact_nom_edit">Nom :</label>
<div class="col-md-8">
<input type="text" class="form-control" id="inp_contact_nom_edit" name="nom" maxlength="50" required/>
</div>
</div>
<div class="form-group">
<label class="control-label col-md-3" for="inp_contact_prenom_edit">Prénom :</label>
<div class="col-md-8">
<input type="text" class="form-control" id="inp_contact_prenom_edit" name="prenom" maxlength="50" required/>
</div>
</div>
<div class="form-group">
<label class="control-label col-md-3" for="inp_contact_fonction_edit">Fonction :</label>
<div class="col-md-8">
<input type="text" class="form-control" id="inp_contact_fonction_edit" name="fonction" maxlength="50"/>
</div>
</div>
<div class="form-group">
<label class="control-label col-md-3" for="inp_contact_email_edit">Email :</label>
<div class="col-md-8">
<input type="email" class="form-control" id="inp_contact_email_edit" name="email" maxlength="75"/>
</div>
</div>
<div class="form-group">
<label class="control-label col-md-3" for="inp_contact_telephone_edit">Téléphone :</label>
<div class="col-md-8">
<input type="text" class="form-control" id="inp_contact_telephone_edit" name="telephone" maxlength="20"/>
</div>
</div>
<div class="form-group">
<label class="control-label col-md-3" for="inp_contact_mobile_edit">Mobile :</label>
<div class="col-md-8">
<input type="text" class="form-control" id="inp_contact_mobile_edit" name="mobile" maxlength="20"/>
</div>
</div>
<div class="form-group">
<label class="control-label col-md-3" for="inp_contact_principal_edit">Contact principal :</label>
<div class="col-md-8">
<input type="checkbox" id="inp_contact_principal_edit" name="principal" value="1"/>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" id="btnCancelEditContact">Annuler</button>
<button type="button" class="btn btn-success" id="btnSaveEditContact">Enregistrer</button>
</div>
</div>
</div>
</div>
<?php
$modal = ob_get_contents();
ob_clean();