1 Commits

Author SHA1 Message Date
c46359deea Supprimer docs/uof_linet_20250911.sql 2025-09-12 20:28:55 +02:00
21 changed files with 901 additions and 11541 deletions

View File

@@ -2,7 +2,8 @@
require_once dirname(__FILE__) . '/Database.php';
class Conf {
class Conf
{
const admin = 1;
const intra = 1;
const erp = 1;
@@ -10,7 +11,7 @@ class Conf {
public $_appname = "cleo";
public $_appscript = "login";
public $_appversion = "2.0.3";
public $_appversion = "2.0.2";
public $_appenv;
public $_apptitle = "CLEO - Gestion de devis";
@@ -47,7 +48,8 @@ class Conf {
public $_entite = '';
public $_new_version = false;
public function __construct() {
public function __construct()
{
$this->loadEnvironment();
$this->loadConfiguration();
$this->setupDebug();
@@ -113,6 +115,7 @@ class Conf {
$this->_brandemail = $entite["email"] ?? "";
$this->_brandlogo = $entite["appname"] ?? "cleo";
}
} catch (Exception $e) {
error_log("Erreur de configuration: " . $e->getMessage());
$this->setDefaultConfiguration();
@@ -176,7 +179,7 @@ class Conf {
// Ne pas afficher les commentaires HTML pour les requêtes AJAX
$isAjax = !empty($_SERVER['HTTP_X_REQUESTED_WITH']) &&
strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) == 'xmlhttprequest';
strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) == 'xmlhttprequest';
if ($this->_devIp && ini_get('display_errors') && !$isAjax) {
echo "<!-- $message -->\n";

184
config/conf_new.php Normal file
View File

@@ -0,0 +1,184 @@
<?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";
}
}
}

148
config/conf_old.php Normal file
View File

@@ -0,0 +1,148 @@
<?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;
}
}
}

View File

@@ -1,245 +0,0 @@
<?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 = NULL, num_opportunite = "", fk_statut_devis = 1 WHERE rowid = ' . $newRowidSafe . ';';
$sql = 'UPDATE devis SET date_demande = "' . date("Y-m-d H:i:s") . '", date_remise = "", num_opportunite = "", fk_statut_devis = 1 WHERE rowid = ' . $newRowidSafe . ';';
eLog($sql);
qSQL($sql, "gen");
@@ -124,16 +124,14 @@ 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_contact, 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_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 .= '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 .= '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 .= 'LEFT JOIN marches m ON d.fk_marche = m.rowid ';
$sql .= 'WHERE d.rowid = ' . $cidSafe . ';';
echo getinfos($sql, "gen", "json");
@@ -164,7 +162,7 @@ switch ($Route->_action) {
if (isset($data->secteur)) {
$chkSecteur = nettoie_input($data->secteur);
$fkUser = nettoie_input($data->user);
$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 ';
$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 ';
if ($chkSecteur == "1") {
//! on ne prend que les clients du secteur de l'utilisateur
$fkUserSafe = intval($fkUser);
@@ -199,32 +197,6 @@ 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"));
@@ -262,7 +234,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 = ' . intval($cid) . ' AND p.active=1 ORDER BY xf.ordre, pf.ordre;';
$sql .= 'WHERE p.fk_marche = ' . $cidSafe . ' AND p.active=1 ORDER BY xf.ordre, pf.ordre;';
$upls = getinfos($sql, "gen");
if ($cid != "999") {
@@ -418,13 +390,31 @@ switch ($Route->_action) {
$commentaire = nettoie_input($_POST["commentaire"]);
$newCommentaire = 0;
// 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;
$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"]));
$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_client=' . $fk_client . ', 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
@@ -475,8 +465,14 @@ switch ($Route->_action) {
}
eLog('Entete Devis Save : ' . $sql);
// NOTE: Les contacts sont maintenant gérés via la table clients_contacts
// et non plus directement dans la table clients
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");
}
// 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) {
@@ -655,8 +651,11 @@ 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 . '%';
$upls = $db->fetchAll($sql, [':term' => $termParam]);
$stmt->bindParam(':term', $termParam, PDO::PARAM_STR);
$stmt->execute();
$upls = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (Exception $e) {
error_log("Erreur recherche produits : " . $e->getMessage());
$upls = [];
@@ -1304,69 +1303,5 @@ 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,7 +140,10 @@ switch ($Route->_action) {
try {
$db = Database::getInstance();
$sql = 'SELECT c.* FROM clients c WHERE c.code = :code';
$record = $db->fetchAll($sql, [':code' => $code]);
$stmt = $db->prepare($sql);
$stmt->bindParam(':code', $code, PDO::PARAM_STR);
$stmt->execute();
$record = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (Exception $e) {
error_log("Erreur recherche client : " . $e->getMessage());
$record = [];
@@ -152,7 +155,8 @@ 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->query($sql, [
$stmt = $db->prepare($sql);
$stmt->execute([
':code' => $code,
':libelle' => $libelle,
':siret' => $siret,
@@ -194,7 +198,8 @@ 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->query($sql, [
$stmt = $db->prepare($sql);
$stmt->execute([
':libelle' => $libelle,
':siret' => $siret,
':adresse1' => $adresse1,
@@ -373,18 +378,12 @@ 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") {
// 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);
$libelle = utf8_decode($libelle);
}
if ($codOrigin != "ISO-8859-1") {
// Convertir en ISO 8859-1
$libelle = iconv($codOrigin, "ISO-8859-15//IGNORE", $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]);
@@ -533,10 +532,7 @@ switch ($Route->_action) {
$ret = array('ret' => "ko", 'msg' => "Aucun fichier à importer");
} else {
if ($erreur == "") {
$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);
$ret = array('ret' => "ok", 'msg' => "L'importation est terminée et s'est bien déroulée");
} else {
$ret = array('ret' => "ko", 'msg' => $erreur);
}

View File

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

View File

@@ -1,53 +1,25 @@
#!/bin/bash
# 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)
# Script de déploiement optimisé de Cleo vers l'environnement de développement
# Version: 2.0 - Utilise tar.gz pour un transfert plus rapide
cd /home/pierre/dev/cleo
# Détecter le mode de déploiement
DEPLOY_MODE=${1:-dev}
# 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
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
# 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
# Propriétaire et groupe pour les fichiers et dossiers de destination
OWNER=nobody
OWNER=nginx
GROUP=nginx
# Couleurs pour l'affichage
@@ -64,181 +36,53 @@ fi
# Afficher les paramètres
echo -e "${GREEN}=== Déploiement optimisé CLEO ===${NC}"
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 "Serveur hôte: $HOST_SSH_USER@$HOST_SSH_HOST:$HOST_SSH_PORT"
echo "Conteneur: $CT_NAME"
echo "Déploiement: $DEPLOY_DIR/$APP_NAME"
echo "=================================="
if [ "$DEPLOY_MODE" = "pra" ]; then
# ===== MODE PROD: Déploiement IN3/dva-front → IN4/pra-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='vendor' \
--exclude='backup_*' \
--exclude='*.tar.gz' \
--exclude='.env.swp' \
.
echo -e "${YELLOW}1. Création de l'archive depuis IN3/dva-front...${NC}"
if [ $? -ne 0 ]; then
echo -e "${RED}Erreur lors de la création de l'archive${NC}"
exit 1
fi
# 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"
ARCHIVE_SIZE=$(du -h /tmp/cleo.tar.gz | cut -f1)
echo -e "${GREEN}✓ Archive créée: /tmp/cleo.tar.gz ($ARCHIVE_SIZE)${NC}"
# 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"
# 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/
# 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
"
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}"
ssh $SOURCE_SSH_OPTS $SOURCE_SSH_USER@$SOURCE_HOST "$SOURCE_SCRIPT"
# 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}"
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}"
# 4. Transférer et extraire dans le conteneur
echo -e "${YELLOW}4. Déploiement dans le conteneur $CT_NAME...${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' \
--exclude='migration' \
--exclude='.claude' \
--exclude='.vscode' \
--exclude='vendor' \
--exclude='backup_*' \
--exclude='*.tar.gz' \
--exclude='.env' \
--exclude='.env.swp' \
.
if [ $? -ne 0 ]; then
echo -e "${RED}Erreur lors de la création de l'archive${NC}"
exit 1
fi
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/
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}"
# 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}"
# Script à exécuter sur IN3
REMOTE_SCRIPT="
# Script à exécuter sur IN3
REMOTE_SCRIPT="
set -e
# Sélectionner le projet Incus
@@ -264,11 +108,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 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 && \
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 && \
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 && \
@@ -282,34 +126,26 @@ 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
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
# 5. Nettoyer l'archive locale
echo -e "${YELLOW}5. Nettoyage local...${NC}"
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
echo -e "${GREEN}✓ Archive locale supprimée${NC}"
ssh $SSH_OPTS $HOST_SSH_USER@$HOST_SSH_HOST "rm -f /tmp/cleo.tar.gz 2>/dev/null || true"
exit 1
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}"
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)"
echo "Chemin: $DEPLOY_DIR/$APP_NAME sur $CT_NAME"
fi
echo "Site: http://dcleo.unikoffice.com"
echo "Chemin: $DEPLOY_DIR/$APP_NAME sur $CT_NAME"
echo ""

View File

@@ -1,133 +0,0 @@
#!/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.3 (gestion multi-contacts complétée le 21 octobre 2025)
**Version actuelle** : 2.0.1 (migration complétée le 12 septembre 2025)
## Architecture technique
@@ -53,9 +53,7 @@ cleo/
- Gestion des remises par paliers de quantité
2. **Gestion des clients** (`cclients.php`)
- 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
- Base clients avec contacts
- Segmentation par secteur géographique
- Types de clients paramétrables
- Import/export de données
@@ -84,11 +82,10 @@ cleo/
- **Sécurité** : Requêtes préparées systématiques
### Tables principales
- `devis` : Table principale des devis (champ `fk_contact` depuis v2.0.3)
- `devis` : Table principale des devis
- `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
@@ -98,32 +95,22 @@ cleo/
## Points de sécurité
### Vulnérabilités corrigées
### Vulnérabilités corrigées (v2.0.1)
**v2.0.1 - Stockage des mots de passe**
**1. Stockage des mots de passe**
- Credentials externalisés dans `.env`
- Variables d'environnement utilisées systématiquement
**v2.0.1 - Protection contre les injections SQL**
**2. Protection contre les injections SQL**
- Migration complète vers PDO
- Requêtes préparées dans la classe `Database`
- Pattern Singleton pour la connexion
**v2.0.1 - Gestion des erreurs sécurisée**
**3. 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
@@ -221,28 +208,15 @@ cleo/
## Conclusion
CLEO v2.0.3 représente l'aboutissement de trois itérations majeures d'amélioration :
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
**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.
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.
---
*Document mis à jour le 21 octobre 2025*
*Version 2.0.3 - Gestion multi-contacts*
*Document mis à jour le 12 septembre 2025*
*Version 2.0.1 - Post-migration*

View File

@@ -1,111 +0,0 @@
# 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,6 +4,15 @@
### 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).
@@ -27,6 +36,16 @@
- [ ] 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.
@@ -37,73 +56,8 @@
- [ ] 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.
@@ -163,7 +117,65 @@ Deux cas de marchés hybrides doivent être gérés différemment :
### Plan de migration - État d'avancement
**Migration complétée** - Toutes les phases (0 à 4) sont terminées.
#### ✅ 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
### Configuration technique
@@ -195,81 +207,6 @@ 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é
@@ -315,15 +252,26 @@ ALTER TABLE clients_contacts
## Notes de développement
### 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
### 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)
);
```
### Modifications table `devis` pour SAP
```sql
@@ -337,36 +285,24 @@ ALTER TABLE devis ADD COLUMN erreur_transfert_edi TEXT;
## Résumé de l'état actuel
### ✅ Réalisations
**v2.0.1 (12 septembre 2025)**
### ✅ Réalisations (v2.0.2 - 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
**v2.0.2 (12 septembre 2025)**
1. **Audit de sécurité complété** : 14 vulnérabilités SQL identifiées et corrigées
6. **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)
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
7. **Fonctionnalité Réactivation devis** : Bouton permettant de réactiver les devis archivés (statut 20 → 1)
### 🎯 Prochaines étapes prioritaires
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é
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é
---
*Document mis à jour le 21 octobre 2025*
*Version 2.0.3 - Gestion multi-contacts*
*Document mis à jour le 12 septembre 2025*
*Version 2.0.2 - Sécurité SQL complète*

View File

@@ -1,440 +0,0 @@
#!/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

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

View File

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

View File

@@ -57,172 +57,163 @@ 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]);
$fileName = "devis_" . $cid . "_" . date('Y_m_d_hi') . ".xls";
if (!$devis) {
throw new Exception("Devis non trouvé");
}
// On affiche les données client
$fields = array("Code", "Etablissement", "Adresse 1", "Adresse 2", "Adresse 3", "Code Postal", "Ville");
$excelData = implode("\t", array_values($fields)) . "\n";
eLog("Export Excel SAP Devis : " . $cidSafe);
$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];
$fileName = "devis_" . $cid . "_" . date('Y_m_d_hi') . ".xls";
array_walk($client, 'filterData');
$excelData .= implode("\t", array_values($client)) . "\n";
// On affiche les données client
$fields = array("Code", "Etablissement", "Adresse 1", "Adresse 2", "Adresse 3", "Code Postal", "Ville");
$excelData = implode("\t", array_values($fields)) . "\n";
$fkClientSafe = intval($devis["fk_client"]);
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";
$excelData .= "\n";
// 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 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";
$excelData .= "\n";
// 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";
// On affiche les données devis
$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 = :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");
// 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];
$fields = array("Contact Nom", "Prenom", "Fonction", "Fixe", "Mobile", "Email");
$excelData .= implode("\t", array_values($fields)) . "\n";
array_walk($devisData, 'filterData');
$excelData .= implode("\t", array_values($devisData)) . "\n";
array_walk($contact, 'filterData');
$excelData .= implode("\t", array_values($contact)) . "\n";
} else {
$client = $cli[0];
array_walk($client, 'filterData');
$excelData .= implode("\t", array_values($client)) . "\n";
// une ligne vierge de séparation
$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 = :devis_id';
$totaux = $db->fetchOne($sql, [':devis_id' => $cidSafe]);
$fields = array("Total HT", "Total HT Remise", "Marge Totale");
// 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];
$fields = array("Contact Nom", "Prenom", "Fonction", "Fixe", "Mobile", "Email");
$excelData .= implode("\t", array_values($fields)) . "\n";
array_walk($totaux, 'filterData');
$excelData .= implode("\t", array_values($totaux)) . "\n";
array_walk($contact, 'filterData');
$excelData .= implode("\t", array_values($contact)) . "\n";
}
// une ligne vierge de séparation
$excelData .= "\n";
// On affiche les données devis
$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"];
$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
$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];
$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
$sql = 'SELECT p.code, p.libelle, IF(p.prix_vente=0, "", FORMAT(p.prix_vente, 2, "fr_FR")) AS vente, ';
$sql .= 'IF(dp.qte=0, "", dp.qte) AS qute, IF(dp.remise=0, "", FORMAT(dp.remise, 2, "fr_FR")) as remise, IF(dp.pu_vente_remise=0, "", FORMAT(dp.pu_vente_remise, 2, "fr_FR")) as puventeremise, ';
$sql .= 'IF(dp.totalht=0, "", FORMAT(dp.totalht, 2, "fr_FR")) AS totht, IF(dp.marge=0, "", FORMAT(dp.marge, 2, "fr_FR")) AS marge, dp.commentaire ';
$sql .= 'FROM devis_produits dp ';
$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");
$fields = array("Code", "Designation", "Prix Vente", "Quantite", "Remise", "PU vente avec remise", "Total HT", "Marge", "Commentaire");
$excelData .= implode("\t", array_values($fields)) . "\n";
foreach ($data as $row) {
array_walk($row, 'filterData');
$excelData .= implode("\t", $row) . "\n";
}
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];
$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";
// on affiche les produits
$sql = 'SELECT p.code, p.libelle, IF(p.prix_vente=0, "", FORMAT(p.prix_vente, 2, "fr_FR")) AS vente, ';
$sql .= 'IF(dp.qte=0, "", dp.qte) AS qute, IF(dp.remise=0, "", FORMAT(dp.remise, 2, "fr_FR")) as remise, IF(dp.pu_vente_remise=0, "", FORMAT(dp.pu_vente_remise, 2, "fr_FR")) as puventeremise, ';
$sql .= 'IF(dp.totalht=0, "", FORMAT(dp.totalht, 2, "fr_FR")) AS totht, IF(dp.marge=0, "", FORMAT(dp.marge, 2, "fr_FR")) AS marge, dp.commentaire ';
$sql .= 'FROM devis_produits dp ';
$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 = :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");
$fields = array("#", "Code", "Designation", "Quantite", "Surcout", "Echantillon", "Date echantillon", "Concurrent", "Description");
$excelData .= implode("\t", array_values($fields)) . "\n";
foreach ($data as $row) {
array_walk($row, 'filterData');
$excelData .= implode("\t", $row) . "\n";
}
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];
if ($chkSpeciaux == "Oui") {
$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 = :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");
$excelData .= implode("\t", array_values($fields)) . "\n";
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 = :devis_id';
$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";
}
if ($speciaux["fk_produit_" . $i] > 0) {
array_walk($speciaux, 'filterData');
$excelData .= implode("\t", array_values($speciaux)) . "\n";
}
}
$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";
}
// 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;
exit();
}

View File

@@ -254,105 +254,3 @@ 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,8 +21,6 @@ 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 = []
@@ -409,24 +407,18 @@ 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') {
@@ -916,86 +908,6 @@ 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
@@ -1234,54 +1146,6 @@ 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') {
@@ -1482,63 +1346,33 @@ window.addEventListener('DOMContentLoaded', (event) => {
}
let clickSaveCreateClient = function () {
showLoading()
// 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
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
}
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').classList.remove('btn-primary')
document.getElementById('btnCreateClient').classList.add('btn-info')
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
hideModal(document.getElementById('modalCreateClient'))
document.getElementById('btnCreateClient').innerHTML = 'Modifier le client'
document.getElementById('btnCreateClient').classList.remove('btn-primary')
document.getElementById('btnCreateClient').classList.add('btn-info')
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')
})
chkCreateClient = true
}
let clickSpeciaux = function () {
@@ -1676,13 +1510,45 @@ window.addEventListener('DOMContentLoaded', (event) => {
return false
}
if (document.getElementById('sel_contact').value == '0') {
if (document.getElementById('inp_contact_nom').value == '') {
showNotification(
'Erreur',
'Enregistrement impossible : vous devez sélectionner un contact',
'Enregistrement impossible : vous devez renseigner le nom et prénom du contact',
'error'
)
document.getElementById('sel_contact').focus()
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()
return false
}
@@ -1951,10 +1817,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
}
@@ -3025,9 +2891,13 @@ 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()
})
@@ -3435,284 +3305,6 @@ 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="sel_contact">Contact :</label>
<label class="control-label col-md-2" for="inp_contact_nom">Contact :</label>
<div class="col-md-4">
<select class="form-control" id="sel_contact" name="fk_contact" required="required">
<option value="0">- Sélectionner un contact -</option>
</select>
<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>
</div>
<div class="col-md-3">
<button type="button" class="btn btn-primary" id="btnGererContacts" disabled>Gérer les contacts</button>
<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>
</div>
<div id="divContactInfos" style="display: none;">
<div class="form-group">
<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>
</div>
</div>
</div>
<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"/>
</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,14 +556,25 @@ 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 '<input type="hidden" id="inpCoutTotalAchat" name="inpCoutTotalAchat"/>';
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>';
}
?>
</td>
<td></td>
<td></td>
</tr>
<tr>
<td>
@@ -941,117 +952,6 @@ 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();