2 Commits

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

245
controllers/cjxcontacts.php Normal file
View File

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

View File

@@ -54,7 +54,7 @@ switch ($Route->_action) {
//! 4. On met à jour la date_demande, date_remise, num_opportunite et fk_statut_devis du nouveau devis //! 4. On met à jour la date_demande, date_remise, num_opportunite et fk_statut_devis du nouveau devis
$newRowidSafe = intval($newRowid); $newRowidSafe = intval($newRowid);
$sql = 'UPDATE devis SET date_demande = "' . date("Y-m-d H:i:s") . '", date_remise = "", num_opportunite = "", fk_statut_devis = 1 WHERE rowid = ' . $newRowidSafe . ';'; $sql = 'UPDATE devis SET date_demande = "' . date("Y-m-d H:i:s") . '", date_remise = NULL, num_opportunite = "", fk_statut_devis = 1 WHERE rowid = ' . $newRowidSafe . ';';
eLog($sql); eLog($sql);
qSQL($sql, "gen"); qSQL($sql, "gen");
@@ -124,14 +124,16 @@ switch ($Route->_action) {
$sql = 'UPDATE devis SET chk_maj = 0 WHERE rowid = ' . $cidSafe . ';'; $sql = 'UPDATE devis SET chk_maj = 0 WHERE rowid = ' . $cidSafe . ';';
qSQL($sql, "gen"); qSQL($sql, "gen");
} }
$sql = 'SELECT d.rowid, d.fk_user, d.fk_client, d.fk_marche, m.libelle AS lib_marche, d.fk_statut_devis, d.dossier, d.num_opportunite, d.montant_total_ht, '; $sql = 'SELECT d.rowid, d.fk_user, d.fk_client, d.fk_contact, d.fk_marche, m.libelle AS lib_marche, d.fk_statut_devis, d.dossier, d.num_opportunite, d.montant_total_ht, ';
$sql .= 'd.date_demande, d.date_remise, d.montant_total_ht_remise, d.marge_totale, d.commentaire, d.comment_devis, d.chk_clients_secteur, d.chk_speciaux, '; $sql .= 'd.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.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.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 .= '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 .= 'xs.libelle as lib_statut_devis, c.code, c.libelle, c.adresse1, c.adresse2, c.adresse3, c.cp, c.ville, c.type_client, ';
$sql .= 'c.contact_nom, c.contact_prenom, c.contact_fonction, c.telephone, c.mobile, c.email, d.chk_devis_photos '; $sql .= '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 LEFT JOIN x_statuts_devis xs ON d.fk_statut_devis = xs.rowid '; $sql .= 'FROM devis d LEFT JOIN clients c ON d.fk_client = c.rowid ';
$sql .= 'LEFT JOIN clients_contacts cc ON d.fk_contact = cc.rowid ';
$sql .= 'LEFT JOIN x_statuts_devis xs ON d.fk_statut_devis = xs.rowid ';
$sql .= 'LEFT JOIN marches m ON d.fk_marche = m.rowid '; $sql .= 'LEFT JOIN marches m ON d.fk_marche = m.rowid ';
$sql .= 'WHERE d.rowid = ' . $cidSafe . ';'; $sql .= 'WHERE d.rowid = ' . $cidSafe . ';';
echo getinfos($sql, "gen", "json"); echo getinfos($sql, "gen", "json");
@@ -162,7 +164,7 @@ switch ($Route->_action) {
if (isset($data->secteur)) { if (isset($data->secteur)) {
$chkSecteur = nettoie_input($data->secteur); $chkSecteur = nettoie_input($data->secteur);
$fkUser = nettoie_input($data->user); $fkUser = nettoie_input($data->user);
$sql = 'SELECT rowid, libelle, CONCAT(libelle, ", ", adresse1, ", ", cp, " ", ville) AS rech, adresse1, adresse2, adresse3, cp, ville, contact_nom, contact_prenom, contact_fonction, telephone, mobile, email, type_client FROM clients WHERE active=1 '; $sql = 'SELECT rowid, code, libelle, CONCAT(libelle, ", ", adresse1, ", ", cp, " ", ville) AS rech, adresse1, adresse2, adresse3, cp, ville, contact_nom, contact_prenom, contact_fonction, telephone, mobile, email, type_client FROM clients WHERE active=1 ';
if ($chkSecteur == "1") { if ($chkSecteur == "1") {
//! on ne prend que les clients du secteur de l'utilisateur //! on ne prend que les clients du secteur de l'utilisateur
$fkUserSafe = intval($fkUser); $fkUserSafe = intval($fkUser);
@@ -197,6 +199,32 @@ switch ($Route->_action) {
} }
break; 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": case "load_devis_marche_produits":
//! Charge les produits enregistrés pour un marché //! Charge les produits enregistrés pour un marché
$data = json_decode(file_get_contents("php://input")); $data = json_decode(file_get_contents("php://input"));
@@ -234,7 +262,7 @@ switch ($Route->_action) {
$sql = 'SELECT p.*, CONCAT(p.code, " - ", p.libelle) AS rech, pf.fk_famille, xf.libelle AS lib_famille, "0" AS chk_prix_net '; $sql = '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 .= 'FROM produits p LEFT JOIN produits_familles pf ON p.groupe=pf.groupe LEFT JOIN x_familles xf on pf.fk_famille = xf.rowid ';
$sql .= 'WHERE p.fk_marche = ' . $cidSafe . ' AND p.active=1 ORDER BY xf.ordre, pf.ordre;'; $sql .= 'WHERE p.fk_marche = ' . intval($cid) . ' AND p.active=1 ORDER BY xf.ordre, pf.ordre;';
$upls = getinfos($sql, "gen"); $upls = getinfos($sql, "gen");
if ($cid != "999") { if ($cid != "999") {
@@ -390,31 +418,13 @@ switch ($Route->_action) {
$commentaire = nettoie_input($_POST["commentaire"]); $commentaire = nettoie_input($_POST["commentaire"]);
$newCommentaire = 0; $newCommentaire = 0;
$contact_nom = nettoie_input($_POST["contact_nom"]); // Récupération du contact sélectionné
$contact_prenom = nettoie_input($_POST["contact_prenom"]); $fk_contact = isset($_POST["fk_contact"]) ? intval($_POST["fk_contact"]) : 0;
$contact_fonction = nettoie_input($_POST["contact_fonction"]); if ($fk_contact == 0) $fk_contact = NULL;
$email = nettoie_input($_POST["email"]);
$telephone = formattel(nettoie_input($_POST["telephone"]));
$mobile = formattel(nettoie_input($_POST["mobile"]));
$set = 'fk_client=' . $fk_client . ', num_opportunite="' . $num_opportunite . '", date_demande="' . $date_demande . '", date_remise="' . $date_remise . '", '; $set = 'fk_client=' . $fk_client . ', fk_contact=' . ($fk_contact === NULL ? 'NULL' : $fk_contact) . ', num_opportunite="' . $num_opportunite . '", date_demande="' . $date_demande . '", date_remise="' . $date_remise . '", ';
$set .= 'fk_user=' . $fk_user . ', fk_marche=' . $fk_marche . ', commentaire="' . $commentaire . '", chk_devis_photos=' . $chk_devis_photos . ', '; $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) { if ($_POST["rowid"] == 0) {
//! C'est un nouveau devis //! C'est un nouveau devis
//! On le range dans un dossier //! On le range dans un dossier
@@ -465,14 +475,8 @@ switch ($Route->_action) {
} }
eLog('Entete Devis Save : ' . $sql); eLog('Entete Devis Save : ' . $sql);
if ($fk_client != "0") { // NOTE: Les contacts sont maintenant gérés via la table clients_contacts
//! On sauvegarde aussi les infos complémentaires du client qui peuvent ête mises à jour // et non plus directement dans la table clients
$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 // 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) { if ($newCommentaire > 0) {
@@ -651,11 +655,8 @@ switch ($Route->_action) {
try { try {
$db = Database::getInstance(); $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'; $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 . '%'; $termParam = '%' . $term . '%';
$stmt->bindParam(':term', $termParam, PDO::PARAM_STR); $upls = $db->fetchAll($sql, [':term' => $termParam]);
$stmt->execute();
$upls = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (Exception $e) { } catch (Exception $e) {
error_log("Erreur recherche produits : " . $e->getMessage()); error_log("Erreur recherche produits : " . $e->getMessage());
$upls = []; $upls = [];
@@ -1303,5 +1304,69 @@ switch ($Route->_action) {
echo json_encode(array("success" => "true", "message" => "Devis refusé avec succès")); echo json_encode(array("success" => "true", "message" => "Devis refusé avec succès"));
} }
break; 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(); exit();

View File

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

View File

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

View File

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

133
deploy-file.sh Executable file
View File

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

View File

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

111
docs/RULES.md Normal file
View File

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

View File

@@ -4,15 +4,6 @@
### Module Devis ### 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 #### 8. Dupliquer une ligne produit
**Priorité**: Moyenne **Priorité**: Moyenne
**Description**: Permettre la duplication d'une ligne produit dans un même devis (utile pour les gratuités). **Description**: Permettre la duplication d'une ligne produit dans un même devis (utile pour les gratuités).
@@ -36,16 +27,6 @@
- [ ] Paginer les résultats de recherche - [ ] Paginer les résultats de recherche
- [ ] Export des résultats en Excel - [ ] 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 #### 21. Actualisation tarifaire
**Priorité**: Moyenne **Priorité**: Moyenne
**Description**: Permettre l'actualisation des prix selon la dernière grille tarifaire. **Description**: Permettre l'actualisation des prix selon la dernière grille tarifaire.
@@ -56,8 +37,73 @@
- [ ] Recalculer automatiquement les marges - [ ] Recalculer automatiquement les marges
- [ ] Tracer l'actualisation dans l'historique - [ ] 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 ### 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 #### 14. Gestion de la prise en charge
**Priorité**: Haute **Priorité**: Haute
**Description**: Ajouter la traçabilité de la prise en charge et du transfert EDI. **Description**: Ajouter la traçabilité de la prise en charge et du transfert EDI.
@@ -117,65 +163,7 @@
### Plan de migration - État d'avancement ### Plan de migration - État d'avancement
#### ✅ Phase 0 - Refactoring base de données (COMPLÉTÉ - 12/09/2025) **Migration complétée** - Toutes les phases (0 à 4) sont terminées.
- [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 ### Configuration technique
@@ -207,6 +195,81 @@ DB_PASSWORD=<PROD_PASSWORD> # À sécuriser
- [ ] Scripts de backup automatisés à mettre en place - [ ] Scripts de backup automatisés à mettre en place
- [ ] Réplication master-slave pour haute disponibilité (optionnel) - [ ] 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 ## Améliorations techniques prioritaires
### Sécurité ### Sécurité
@@ -252,26 +315,15 @@ DB_PASSWORD=<PROD_PASSWORD> # À sécuriser
## Notes de développement ## Notes de développement
### Structure de la table `clients_contacts` (à créer) ### Structure de la table `clients_contacts` (CRÉÉE - v2.0.3)
```sql Table créée et opérationnelle avec :
CREATE TABLE clients_contacts ( - Clé étrangère vers `clients` avec CASCADE
rowid INT PRIMARY KEY AUTO_INCREMENT, - Gestion du contact principal (un seul par client)
fk_client INT NOT NULL, - Soft delete via champ `active`
nom VARCHAR(100), - Traçabilité (date_creat, fk_user_creat, date_modif, fk_user_modif)
prenom VARCHAR(100), - Index sur fk_client, principal et email
fonction VARCHAR(100), - Contrainte UNIQUE sur rowid
telephone VARCHAR(20), - Voir `docs/migration_clients_contacts.sql` pour la structure complète
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 ### Modifications table `devis` pour SAP
```sql ```sql
@@ -285,24 +337,36 @@ ALTER TABLE devis ADD COLUMN erreur_transfert_edi TEXT;
## Résumé de l'état actuel ## Résumé de l'état actuel
### ✅ Réalisations (v2.0.2 - 12 septembre 2025) ### ✅ Réalisations
**v2.0.1 (12 septembre 2025)**
1. **Migration DEV complétée** : Architecture séparée application/BDD 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 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 3. **Sécurité renforcée** : PDO, requêtes préparées, variables d'environnement
4. **Container `dva-front`** : MariaDB supprimé, application PHP uniquement 4. **Container `dva-front`** : MariaDB supprimé, application PHP uniquement
5. **Container `maria3`** : Base de données centralisée opérationnelle 5. **Container `maria3`** : Base de données centralisée opérationnelle
6. **Audit de sécurité complété** : 14 vulnérabilités SQL identifiées et corrigées
**v2.0.2 (12 septembre 2025)**
1. **Audit de sécurité complété** : 14 vulnérabilités SQL identifiées et corrigées
- 8 critiques (fonction autocomplete, injections dans cjxpost.php, mclients.php, mdevis.php) - 8 critiques (fonction autocomplete, injections dans cjxpost.php, mclients.php, mdevis.php)
- 6 moyennes (cjxdevis.php, cjxexport.php, cjximport.php, mexpxls.php) - 6 moyennes (cjxdevis.php, cjxexport.php, cjximport.php, mexpxls.php)
7. **Fonctionnalité Réactivation devis** : Bouton permettant de réactiver les devis archivés (statut 20 → 1) 2. **Fonctionnalité Réactivation devis** : Bouton permettant de réactiver les devis archivés (statut 20 → 1)
**v2.0.3 (21 octobre 2025)**
1. **Gestion multi-contacts par client** : Table `clients_contacts` opérationnelle
2. **Interface CRUD complète** : Modale Bootstrap avec création/modification/suppression de contacts
3. **Contrôleur AJAX `cjxcontacts.php`** : 5 endpoints sécurisés avec requêtes préparées
4. **Intégration dans les devis** : Sélecteur de contact avec affichage des infos en lecture seule
5. **Gestion automatique du contact principal** : Un seul contact principal par client
6. **Soft delete** : Prévention de la suppression du dernier contact actif
### 🎯 Prochaines étapes prioritaires ### 🎯 Prochaines étapes prioritaires
1. **Migration PROD vers IN4** : Export/Import des containers vers `pra-front` et `maria4` 1. **Nettoyage BDD** : Supprimer les anciens champs contact de la table `clients` (après validation)
2. **Fonctionnalités métier** : Points 14, 16 (voir sections ci-dessus) 2. **Migration PROD vers IN4** : Export/Import des containers vers `pra-front` et `maria4`
3. **Sécurité XSS** : Audit et correction des failles XSS potentielles 3. **Fonctionnalités métier** : Points 8, 14, 16, 21 (voir sections ci-dessus)
4. **Tests** : Mise en place de tests automatisés de sécurité 4. **Sécurité XSS** : Audit et correction des failles XSS potentielles
5. **Tests** : Mise en place de tests automatisés de sécurité
--- ---
*Document mis à jour le 12 septembre 2025* *Document mis à jour le 21 octobre 2025*
*Version 2.0.2 - Sécurité SQL complète* *Version 2.0.3 - Gestion multi-contacts*

440
docs/backpm7.sh Normal file
View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,6 +21,8 @@ let chkShowDevisArchives = false // indique si on affiche les devis archivés ou
let chkCreateClient = false let chkCreateClient = false
// On charge les produits du marché du devis en cours dans un tableau pour ne pas avoir à les recharger à chaque fois // On charge les produits du marché du devis en cours dans un tableau pour ne pas avoir à les recharger à chaque fois
let dataProduitsMarche = [] 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 //! 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 oldChkClientsSecteur = 2
let clients = [] let clients = []
@@ -407,18 +409,24 @@ window.addEventListener('DOMContentLoaded', (event) => {
document.getElementById('inp_adresse3').value = data.adresse3 document.getElementById('inp_adresse3').value = data.adresse3
document.getElementById('inp_cp').value = data.cp document.getElementById('inp_cp').value = data.cp
document.getElementById('inp_ville').value = data.ville 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 document.getElementById('selTypeEtab').value = data.type_client
elBtnCreateClient.innerHTML = 'Créer un nouveau client' elBtnCreateClient.innerHTML = 'Créer un nouveau client'
if (elBtnCreateClient.classList.contains('btn-info')) { if (elBtnCreateClient.classList.contains('btn-info')) {
elBtnCreateClient.classList.remove('btn-info') elBtnCreateClient.classList.remove('btn-info')
elBtnCreateClient.classList.add('btn-primary') 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') { if (data.chk_devis_photos == '1') {
@@ -908,6 +916,86 @@ window.addEventListener('DOMContentLoaded', (event) => {
if (ret.length == 1) { if (ret.length == 1) {
let line = ret[0] let line = ret[0]
chkPrixNets = line.chk_prix_nets == 1 ? true : false 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_latitudeRR').value = seuilMargeRR
document.getElementById('inp_latitudeDV').value = seuilMargeDV 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 document.getElementById('titleMarche').innerHTML = '<bolder>Informations du marché ' + line.libelle + '</bolder>' // le titre du panel des infos marché dans l'onglet 3. Devis
@@ -1146,6 +1234,54 @@ window.addEventListener('DOMContentLoaded', (event) => {
celFamille.innerHTML = lineProduit.lib_famille 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) { $('a[data-toggle="tab"]').on('show.bs.tab', function (e) {
if (idDevis == 0) { if (idDevis == 0) {
if ($(this).attr('href') == '#tabproduits' || $(this).attr('href') == '#tabdevis') { if ($(this).attr('href') == '#tabproduits' || $(this).attr('href') == '#tabdevis') {
@@ -1346,33 +1482,63 @@ window.addEventListener('DOMContentLoaded', (event) => {
} }
let clickSaveCreateClient = function () { let clickSaveCreateClient = function () {
// on regarde si c'est une création de devis ou une modification showLoading()
// on enregistre le fait que ça soit un nouveau client
// on met à jour les champs du devis avec les infos du nouveau client const clientData = {
// et quand on enregistre le devis on enregistre le nouveau client dans le devis libelle: document.getElementById('inp_create_libelle').value,
document.getElementById('inp_fk_client').value = '0' type_client: document.getElementById('inp_create_type_client').value,
document.getElementById('inp_lib_client').value = document.getElementById('inp_create_libelle').value adresse1: document.getElementById('inp_create_adresse1').value,
document.getElementById('selTypeEtab').value = document.getElementById('inp_create_type_client').value adresse2: document.getElementById('inp_create_adresse2').value,
document.getElementById('inp_type_client').value = document.getElementById('inp_create_type_client').value adresse3: document.getElementById('inp_create_adresse3').value,
document.getElementById('inp_adresse1').value = document.getElementById('inp_create_adresse1').value cp: document.getElementById('inp_create_cp').value,
document.getElementById('inp_adresse2').value = document.getElementById('inp_create_adresse2').value ville: document.getElementById('inp_create_ville').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 fetch('/jxdevis/save_new_client', {
method: 'POST',
body: JSON.stringify(clientData),
headers: {
'Content-Type': 'application/json;charset=utf-8',
Accept: 'application/json;charset=utf-8',
},
})
.then((response) => response.json())
.then((data) => {
hideLoading()
if (data.success) {
const newClientId = data.rowid
const newClientCode = data.code
document.getElementById('inp_fk_client').value = newClientId
document.getElementById('inp_lib_client').value = clientData.libelle
document.getElementById('selTypeEtab').value = clientData.type_client
document.getElementById('inp_type_client').value = clientData.type_client
document.getElementById('inp_adresse1').value = clientData.adresse1
document.getElementById('inp_adresse2').value = clientData.adresse2
document.getElementById('inp_adresse3').value = clientData.adresse3
document.getElementById('inp_cp').value = clientData.cp
document.getElementById('inp_ville').value = clientData.ville
console.log(
'nouveau client créé : ' +
document.getElementById('inp_create_type_client').value +
' -> ' +
document.getElementById('selTypeEtab').value
)
hideModal(document.getElementById('modalCreateClient')) hideModal(document.getElementById('modalCreateClient'))
// on change le texte et la couleur du bouton de nouveau client
document.getElementById('btnCreateClient').innerHTML = 'Modifier le nouveau client' document.getElementById('btnCreateClient').innerHTML = 'Modifier le client'
document.getElementById('btnCreateClient').classList.remove('btn-primary') document.getElementById('btnCreateClient').classList.remove('btn-primary')
document.getElementById('btnCreateClient').classList.add('btn-info') document.getElementById('btnCreateClient').classList.add('btn-info')
chkCreateClient = true loadContactsClient(newClientCode).then(() => {
showNotification('Succès', 'Client créé avec succès. Vous pouvez maintenant gérer ses contacts.', 'success')
})
chkCreateClient = false
} else {
showNotification('Erreur', data.message || 'Erreur lors de la création du client', 'error')
}
})
.catch((error) => {
hideLoading()
console.error('Erreur création client:', error)
showNotification('Erreur', 'Impossible de créer le client', 'error')
})
} }
let clickSpeciaux = function () { let clickSpeciaux = function () {
@@ -1510,45 +1676,13 @@ window.addEventListener('DOMContentLoaded', (event) => {
return false return false
} }
if (document.getElementById('inp_contact_nom').value == '') { if (document.getElementById('sel_contact').value == '0') {
showNotification( showNotification(
'Erreur', 'Erreur',
'Enregistrement impossible : vous devez renseigner le nom et prénom du contact', 'Enregistrement impossible : vous devez sélectionner un contact',
'error' 'error'
) )
document.getElementById('inp_contact_nom').focus() document.getElementById('sel_contact').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 return false
} }
@@ -1817,10 +1951,10 @@ window.addEventListener('DOMContentLoaded', (event) => {
showDevisPro(data) showDevisPro(data)
hideLoading() hideLoading()
showNotification('Succès', 'Enregistrement des ' + nbProduits + ' produits de ce devis effectué', 'success') showNotification('Succès', 'Enregistrement des ' + nbProduits + ' produits de ce devis effectué', 'success')
chkChange = 0
}) })
} }
}) })
chkChange = 0
return false return false
} }
@@ -2891,13 +3025,9 @@ window.addEventListener('DOMContentLoaded', (event) => {
document.getElementById('inp_adresse3').value = list[i]['adresse3'] document.getElementById('inp_adresse3').value = list[i]['adresse3']
document.getElementById('inp_cp').value = list[i]['cp'] document.getElementById('inp_cp').value = list[i]['cp']
document.getElementById('inp_ville').value = list[i]['ville'] 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'] 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 // on ferme la liste des suggestions
closeList() closeList()
}) })
@@ -3305,6 +3435,284 @@ window.addEventListener('DOMContentLoaded', (event) => {
hideModal(document.getElementById('modalCommentProd')) 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 //! 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() //! 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) { Array.from(elCelDevis).forEach(function (lnDevis) {

View File

@@ -376,34 +376,34 @@ ob_start();
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="control-label col-md-2" for="inp_contact_nom">Contact :</label> <label class="control-label col-md-2" for="sel_contact">Contact :</label>
<div class="col-md-4"> <div class="col-md-4">
<input type="text" class="form-control" id="inp_contact_nom" name="contact_nom" placeholder="Nom" required="required"/> <select class="form-control" id="sel_contact" name="fk_contact" required="required">
<p class="help-block">Nom du contact</p> <option value="0">- Sélectionner un contact -</option>
</select>
</div> </div>
<div class="col-md-4">
<input type="text" class="form-control" id="inp_contact_prenom" name="contact_prenom" placeholder="Prénom" required="required"/>
<p class="help-block">Prénom du contact</p>
</div>
</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"> <div class="col-md-3">
<input type="text" class="form-control" id="inp_contact_fonction" name="contact_fonction" required="required"/> <button type="button" class="btn btn-primary" id="btnGererContacts" disabled>Gérer les contacts</button>
</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> </div>
<div id="divContactInfos" style="display: none;">
<div class="form-group"> <div class="form-group">
<label class=" control-label col-md-2" for="inp_telephone">Tél :</label> <label class="control-label col-md-2"></label>
<div class="col-md-2"> <div class="col-md-8">
<input type="text" class="form-control" id="inp_telephone" name="telephone" size="10" maxlength="18" placeholder="Fixe"/> <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>
<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> </div>
<div class="form-group"> <div class="form-group">
@@ -556,25 +556,14 @@ ob_start();
<input type="text" class="form-control numeric" id="inpTotalHT" name="inpTotalHT" readonly="readonly" tabindex="-1" size="12" maxlength="12"/> <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 class="input-group-addon">&euro;</div>
</div> </div>
</td>
<td>
<?php <?php
if ($Conf->_devIp) { if ($Conf->_devIp) {
echo '<div class="form-group">'; echo '<input type="hidden" id="inpCoutTotalAchat" name="inpCoutTotalAchat"/>';
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></td>
</tr> </tr>
<tr> <tr>
<td> <td>
@@ -952,6 +941,117 @@ ob_start();
</div> </div>
</div> </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 <?php
$modal = ob_get_contents(); $modal = ob_get_contents();
ob_clean(); ob_clean();