feat: Version 3.5.2 - Configuration Stripe et gestion des immeubles
- Configuration complète Stripe pour les 3 environnements (DEV/REC/PROD) * DEV: Clés TEST Pierre (mode test) * REC: Clés TEST Client (mode test) * PROD: Clés LIVE Client (mode live) - Ajout de la gestion des bases de données immeubles/bâtiments * Configuration buildings_database pour DEV/REC/PROD * Service BuildingService pour enrichissement des adresses - Optimisations pages et améliorations ergonomie - Mises à jour des dépendances Composer - Nettoyage des fichiers obsolètes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -3,14 +3,14 @@ namespace App\Controllers;
|
||||
|
||||
use Database;
|
||||
use Response;
|
||||
use LogService;
|
||||
use ApiService;
|
||||
use AddressService;
|
||||
use DepartmentBoundaryService;
|
||||
use App\Services\LogService;
|
||||
use App\Services\EventLogService;
|
||||
use App\Services\ApiService;
|
||||
use App\Services\AddressService;
|
||||
use App\Services\DepartmentBoundaryService;
|
||||
|
||||
require_once __DIR__ . '/../Services/EventLogService.php';
|
||||
require_once __DIR__ . '/../Services/ApiService.php';
|
||||
require_once __DIR__ . '/../Services/AddressService.php';
|
||||
require_once __DIR__ . '/../Services/DepartmentBoundaryService.php';
|
||||
|
||||
class SectorController
|
||||
{
|
||||
@@ -193,14 +193,31 @@ class SectorController
|
||||
|
||||
// Affectation des users si fournis
|
||||
if (!empty($users)) {
|
||||
$queryMember = "INSERT INTO ope_users_sectors (fk_operation, fk_user, fk_sector, created_at, fk_user_creat, chk_active)
|
||||
VALUES (:operation_id, :user_id, :sector_id, NOW(), :user_creat, 1)";
|
||||
$queryMember = "INSERT INTO ope_users_sectors (fk_operation, fk_user, fk_sector, created_at, updated_at, fk_user_creat, chk_active)
|
||||
VALUES (:operation_id, :user_id, :sector_id, NOW(), NOW(), :user_creat, 1)";
|
||||
$stmtMember = $this->db->prepare($queryMember);
|
||||
|
||||
|
||||
foreach ($users as $memberId) {
|
||||
// $memberId est DÉJÀ ope_users.id (envoyé par Flutter)
|
||||
// Vérifier que cet ope_users.id existe et appartient bien à l'opération
|
||||
$stmtOpeUser = $this->db->prepare('
|
||||
SELECT id FROM ope_users
|
||||
WHERE id = ? AND fk_operation = ?
|
||||
');
|
||||
$stmtOpeUser->execute([$memberId, $operationId]);
|
||||
$opeUserId = $stmtOpeUser->fetchColumn();
|
||||
|
||||
if (!$opeUserId) {
|
||||
$this->logService->warning('ope_users.id non trouvé pour cette opération', [
|
||||
'ope_users_id' => $memberId,
|
||||
'operation_id' => $operationId
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$stmtMember->execute([
|
||||
'operation_id' => $operationId,
|
||||
'user_id' => $memberId,
|
||||
'user_id' => $opeUserId,
|
||||
'sector_id' => $sectorId,
|
||||
'user_creat' => $userId
|
||||
]);
|
||||
@@ -268,16 +285,24 @@ class SectorController
|
||||
$passagesCreated = 0; // Initialiser le compteur de passages
|
||||
try {
|
||||
$addresses = $this->addressService->getAddressesInPolygon($coordinates, $entityId);
|
||||
|
||||
|
||||
// Enrichir les adresses avec les données bâtiments
|
||||
$addresses = $this->addressService->enrichAddressesWithBuildings($addresses, $entityId);
|
||||
|
||||
if (!empty($addresses)) {
|
||||
$queryAddress = "INSERT INTO sectors_adresses (fk_sector, fk_adresse, numero, rue, rue_bis, cp, ville, gps_lat, gps_lng)
|
||||
VALUES (:sector_id, :address_id, :numero, :rue, :rue_bis, :cp, :ville, :gps_lat, :gps_lng)";
|
||||
$queryAddress = "INSERT INTO sectors_adresses (
|
||||
fk_sector, fk_adresse, numero, rue, rue_bis, cp, ville, gps_lat, gps_lng,
|
||||
fk_batiment, fk_habitat, nb_niveau, nb_log, residence, alt_sol
|
||||
) VALUES (
|
||||
:sector_id, :address_id, :numero, :rue, :rue_bis, :cp, :ville, :gps_lat, :gps_lng,
|
||||
:fk_batiment, :fk_habitat, :nb_niveau, :nb_log, :residence, :alt_sol
|
||||
)";
|
||||
$stmtAddress = $this->db->prepare($queryAddress);
|
||||
|
||||
|
||||
foreach ($addresses as $address) {
|
||||
// Extraire le rue_bis si présent (généralement vide)
|
||||
$rueBis = '';
|
||||
|
||||
|
||||
$stmtAddress->execute([
|
||||
'sector_id' => $sectorId,
|
||||
'address_id' => $address['id'],
|
||||
@@ -287,60 +312,111 @@ class SectorController
|
||||
'cp' => $address['code_postal'],
|
||||
'ville' => $address['commune'],
|
||||
'gps_lat' => $address['latitude'],
|
||||
'gps_lng' => $address['longitude']
|
||||
'gps_lng' => $address['longitude'],
|
||||
'fk_batiment' => $address['fk_batiment'] ?? null,
|
||||
'fk_habitat' => $address['fk_habitat'] ?? 1,
|
||||
'nb_niveau' => $address['nb_niveau'] ?? null,
|
||||
'nb_log' => $address['nb_log'] ?? null,
|
||||
'residence' => $address['residence'] ?? '',
|
||||
'alt_sol' => $address['alt_sol'] ?? null
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
// Créer les passages pour chaque adresse
|
||||
if (!empty($users)) {
|
||||
$firstUserId = $users[0]; // Premier user pour l'affectation des passages
|
||||
$passageQuery = "INSERT INTO ope_pass (
|
||||
fk_operation, fk_sector, fk_user, fk_adresse,
|
||||
numero, rue, rue_bis, ville,
|
||||
gps_lat, gps_lng, fk_type, encrypted_name,
|
||||
created_at, fk_user_creat, chk_active
|
||||
) VALUES (
|
||||
:operation_id, :sector_id, :user_id, :fk_adresse,
|
||||
:numero, :rue, :rue_bis, :ville,
|
||||
:gps_lat, :gps_lng, 2, '',
|
||||
NOW(), :user_creat, 1
|
||||
)";
|
||||
$passageStmt = $this->db->prepare($passageQuery);
|
||||
|
||||
$passagesCreated = 0;
|
||||
foreach ($addresses as $address) {
|
||||
// Vérifier si cette adresse n'est pas déjà utilisée par un passage orphelin
|
||||
if (in_array($address['id'], $addressesToExclude)) {
|
||||
continue; // Passer à l'adresse suivante
|
||||
}
|
||||
|
||||
try {
|
||||
// Extraire le rue_bis si présent (généralement vide)
|
||||
$rueBis = '';
|
||||
|
||||
$passageStmt->execute([
|
||||
'operation_id' => $operationId,
|
||||
'sector_id' => $sectorId,
|
||||
'user_id' => $firstUserId,
|
||||
'fk_adresse' => $address['id'],
|
||||
'numero' => $address['numero'],
|
||||
'rue' => $address['voie'],
|
||||
'rue_bis' => $rueBis,
|
||||
'ville' => $address['commune'],
|
||||
'gps_lat' => $address['latitude'],
|
||||
'gps_lng' => $address['longitude'],
|
||||
'user_creat' => $userId
|
||||
]);
|
||||
$passagesCreated++;
|
||||
} catch (\Exception $e) {
|
||||
$this->logService->warning('Erreur lors de la création d\'un passage', [
|
||||
'address_id' => $address['id'],
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
// Récupérer ope_users.id pour le premier utilisateur
|
||||
// $users[0] est DÉJÀ ope_users.id (envoyé par Flutter)
|
||||
$stmtFirstOpeUser = $this->db->prepare('
|
||||
SELECT id FROM ope_users
|
||||
WHERE id = ? AND fk_operation = ?
|
||||
');
|
||||
$stmtFirstOpeUser->execute([$users[0], $operationId]);
|
||||
$firstOpeUserId = $stmtFirstOpeUser->fetchColumn();
|
||||
|
||||
if (!$firstOpeUserId) {
|
||||
$this->logService->warning('Premier ope_users.id non trouvé pour cette opération', [
|
||||
'ope_users_id' => $users[0],
|
||||
'operation_id' => $operationId
|
||||
]);
|
||||
// Pas de création de passages sans utilisateur valide dans ope_users
|
||||
} else {
|
||||
$passageQuery = "INSERT INTO ope_pass (
|
||||
fk_operation, fk_sector, fk_user, fk_adresse,
|
||||
numero, rue, rue_bis, ville, residence, appt, fk_habitat,
|
||||
gps_lat, gps_lng, fk_type, nb_passages, encrypted_name,
|
||||
created_at, fk_user_creat, chk_active
|
||||
) VALUES (
|
||||
:operation_id, :sector_id, :user_id, :fk_adresse,
|
||||
:numero, :rue, :rue_bis, :ville, :residence, :appt, :fk_habitat,
|
||||
:gps_lat, :gps_lng, 2, 0, '',
|
||||
NOW(), :user_creat, 1
|
||||
)";
|
||||
$passageStmt = $this->db->prepare($passageQuery);
|
||||
|
||||
$passagesCreated = 0;
|
||||
foreach ($addresses as $address) {
|
||||
// Vérifier si cette adresse n'est pas déjà utilisée par un passage orphelin
|
||||
if (in_array($address['id'], $addressesToExclude)) {
|
||||
continue; // Passer à l'adresse suivante
|
||||
}
|
||||
|
||||
try {
|
||||
// Extraire le rue_bis si présent (généralement vide)
|
||||
$rueBis = '';
|
||||
|
||||
// Déterminer le nombre de passages à créer
|
||||
$fkHabitat = $address['fk_habitat'] ?? 1;
|
||||
$nbLog = ($fkHabitat == 2 && isset($address['nb_log'])) ? (int)$address['nb_log'] : 1;
|
||||
$residence = $address['residence'] ?? '';
|
||||
|
||||
// IMPORTANT : Uniformisation GPS pour les immeubles
|
||||
// Tous les passages d'une même adresse partagent les mêmes coordonnées GPS
|
||||
// Issues de la table adresses enrichie (gps_lat, gps_lng)
|
||||
$gpsLat = $address['latitude'];
|
||||
$gpsLng = $address['longitude'];
|
||||
|
||||
// Créer 1 passage pour maison individuelle, nb_log passages pour immeuble
|
||||
for ($i = 1; $i <= $nbLog; $i++) {
|
||||
$appt = ($fkHabitat == 2) ? (string)$i : ''; // Numéro d'appartement pour immeubles
|
||||
|
||||
$passageStmt->execute([
|
||||
'operation_id' => $operationId,
|
||||
'sector_id' => $sectorId,
|
||||
'user_id' => $firstOpeUserId,
|
||||
'fk_adresse' => $address['id'],
|
||||
'numero' => $address['numero'],
|
||||
'rue' => $address['voie'],
|
||||
'rue_bis' => $rueBis,
|
||||
'ville' => $address['commune'],
|
||||
'residence' => $residence,
|
||||
'appt' => $appt,
|
||||
'fk_habitat' => $fkHabitat,
|
||||
'gps_lat' => $gpsLat,
|
||||
'gps_lng' => $gpsLng,
|
||||
'user_creat' => $userId
|
||||
]);
|
||||
$passagesCreated++;
|
||||
}
|
||||
|
||||
// Log pour vérifier l'uniformisation GPS (surtout pour immeubles)
|
||||
if ($fkHabitat == 2 && $nbLog > 1) {
|
||||
$this->logService->info('[SectorController] Création passages immeuble avec GPS uniformisés', [
|
||||
'address_id' => $address['id'],
|
||||
'nb_passages' => $nbLog,
|
||||
'gps_lat' => $gpsLat,
|
||||
'gps_lng' => $gpsLng,
|
||||
'residence' => $residence
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logService->warning('Erreur lors de la création d\'un passage', [
|
||||
'address_id' => $address['id'],
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
@@ -351,9 +427,16 @@ class SectorController
|
||||
'entity_id' => $entityId
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
$this->db->commit();
|
||||
|
||||
|
||||
// Log de création du secteur
|
||||
EventLogService::logSectorCreated(
|
||||
(int)$sectorId,
|
||||
(int)$operationId,
|
||||
$sectorData['libelle']
|
||||
);
|
||||
|
||||
// Préparer les données de réponse
|
||||
$responseData = [
|
||||
'sector_id' => $sectorId
|
||||
@@ -413,9 +496,10 @@ class SectorController
|
||||
}
|
||||
|
||||
// Récupérer les users affectés
|
||||
$usersQuery = "SELECT u.id, u.first_name, u.sect_name, u.encrypted_name, ous.fk_sector
|
||||
$usersQuery = "SELECT u.id, ou.id as ope_user_id, u.first_name, u.sect_name, u.encrypted_name, ous.fk_sector
|
||||
FROM ope_users_sectors ous
|
||||
JOIN users u ON ous.fk_user = u.id
|
||||
JOIN ope_users ou ON ous.fk_user = ou.id
|
||||
JOIN users u ON ou.fk_user = u.id
|
||||
WHERE ous.fk_sector = :sector_id";
|
||||
$usersStmt = $this->db->prepare($usersQuery);
|
||||
$usersStmt->execute(['sector_id' => $sectorId]);
|
||||
@@ -425,7 +509,8 @@ class SectorController
|
||||
$responseData['users_sectors'] = [];
|
||||
foreach ($usersSectors as $userSector) {
|
||||
$userData = [
|
||||
'id' => $userSector['id'],
|
||||
'user_id' => $userSector['id'],
|
||||
'ope_user_id' => $userSector['ope_user_id'],
|
||||
'first_name' => $userSector['first_name'] ?? '',
|
||||
'sect_name' => $userSector['sect_name'] ?? '',
|
||||
'fk_sector' => $userSector['fk_sector'],
|
||||
@@ -498,24 +583,27 @@ class SectorController
|
||||
try {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$entityId = $_SESSION['entity_id'] ?? null;
|
||||
|
||||
|
||||
if (!$entityId) {
|
||||
Response::json(['status' => 'error', 'message' => 'Entité non définie'], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Vérifier que le secteur appartient à l'entité
|
||||
$checkQuery = "SELECT s.id
|
||||
$checkQuery = "SELECT s.id, s.fk_operation, s.libelle
|
||||
FROM ope_sectors s
|
||||
JOIN operations o ON s.fk_operation = o.id
|
||||
WHERE s.id = :id AND o.fk_entite = :entity_id";
|
||||
$checkStmt = $this->db->prepare($checkQuery);
|
||||
$checkStmt->execute(['id' => $id, 'entity_id' => $entityId]);
|
||||
|
||||
if (!$checkStmt->fetch()) {
|
||||
|
||||
$existingSector = $checkStmt->fetch();
|
||||
if (!$existingSector) {
|
||||
Response::json(['status' => 'error', 'message' => 'Secteur non trouvé'], 404);
|
||||
return;
|
||||
}
|
||||
|
||||
$operationId = $existingSector['fk_operation'];
|
||||
|
||||
$this->db->beginTransaction();
|
||||
|
||||
@@ -580,8 +668,8 @@ class SectorController
|
||||
|
||||
// Ajouter les nouvelles affectations
|
||||
if (!empty($data['users'])) {
|
||||
$insertQuery = "INSERT INTO ope_users_sectors (fk_operation, fk_user, fk_sector, created_at, fk_user_creat, chk_active)
|
||||
VALUES (:operation_id, :user_id, :sector_id, NOW(), :user_creat, 1)";
|
||||
$insertQuery = "INSERT INTO ope_users_sectors (fk_operation, fk_user, fk_sector, created_at, updated_at, fk_user_creat, chk_active)
|
||||
VALUES (:operation_id, :user_id, :sector_id, NOW(), NOW(), :user_creat, 1)";
|
||||
$this->logService->info('[UPDATE USERS] SQL - Requête INSERT préparée', [
|
||||
'query' => $insertQuery
|
||||
]);
|
||||
@@ -591,9 +679,27 @@ class SectorController
|
||||
$failedUsers = [];
|
||||
foreach ($data['users'] as $memberId) {
|
||||
try {
|
||||
// $memberId est DÉJÀ ope_users.id (envoyé par Flutter)
|
||||
// Vérifier que cet ope_users.id existe et appartient bien à l'opération
|
||||
$stmtOpeUser = $this->db->prepare('
|
||||
SELECT id FROM ope_users
|
||||
WHERE id = ? AND fk_operation = ?
|
||||
');
|
||||
$stmtOpeUser->execute([$memberId, $operationId]);
|
||||
$opeUserId = $stmtOpeUser->fetchColumn();
|
||||
|
||||
if (!$opeUserId) {
|
||||
$this->logService->warning('[UPDATE USERS] ope_users.id non trouvé pour cette opération', [
|
||||
'ope_users_id' => $memberId,
|
||||
'operation_id' => $operationId
|
||||
]);
|
||||
$failedUsers[] = $memberId;
|
||||
continue;
|
||||
}
|
||||
|
||||
$params = [
|
||||
'operation_id' => $operationId,
|
||||
'user_id' => $memberId,
|
||||
'user_id' => $opeUserId,
|
||||
'sector_id' => $id,
|
||||
'user_creat' => $_SESSION['user_id'] ?? null
|
||||
];
|
||||
@@ -626,14 +732,25 @@ class SectorController
|
||||
}
|
||||
}
|
||||
|
||||
// Gérer les passages si le secteur a changé
|
||||
// Gérer les passages si le secteur a changé ET si chk_adresses_change = 1
|
||||
$passageCounters = [
|
||||
'passages_orphaned' => 0,
|
||||
'passages_updated' => 0,
|
||||
'passages_created' => 0,
|
||||
'passages_kept' => 0
|
||||
];
|
||||
if (isset($data['sector'])) {
|
||||
|
||||
// chk_adresses_change : 0=ne pas toucher aux adresses/passages, 1=recalculer (défaut)
|
||||
$chkAdressesChange = $data['chk_adresses_change'] ?? 1;
|
||||
|
||||
if (isset($data['sector']) && $chkAdressesChange == 0) {
|
||||
$this->logService->info('[UPDATE] Modification secteur sans recalcul adresses/passages', [
|
||||
'sector_id' => $id,
|
||||
'chk_adresses_change' => $chkAdressesChange
|
||||
]);
|
||||
}
|
||||
|
||||
if (isset($data['sector']) && $chkAdressesChange == 1) {
|
||||
// Mettre à jour les adresses du secteur AVANT de traiter les passages
|
||||
try {
|
||||
// Supprimer les anciennes adresses
|
||||
@@ -660,17 +777,25 @@ class SectorController
|
||||
]);
|
||||
|
||||
$addresses = $this->addressService->getAddressesInPolygon($coordinates, $entityId);
|
||||
|
||||
|
||||
// Enrichir les adresses avec les données bâtiments
|
||||
$addresses = $this->addressService->enrichAddressesWithBuildings($addresses, $entityId);
|
||||
|
||||
$this->logService->info('[UPDATE] Adresses récupérées', [
|
||||
'sector_id' => $id,
|
||||
'nb_addresses' => count($addresses)
|
||||
]);
|
||||
|
||||
|
||||
if (!empty($addresses)) {
|
||||
$queryAddress = "INSERT INTO sectors_adresses (fk_sector, fk_adresse, numero, rue, cp, ville, gps_lat, gps_lng)
|
||||
VALUES (:sector_id, :address_id, :numero, :rue, :cp, :ville, :gps_lat, :gps_lng)";
|
||||
$queryAddress = "INSERT INTO sectors_adresses (
|
||||
fk_sector, fk_adresse, numero, rue, cp, ville, gps_lat, gps_lng,
|
||||
fk_batiment, fk_habitat, nb_niveau, nb_log, residence, alt_sol
|
||||
) VALUES (
|
||||
:sector_id, :address_id, :numero, :rue, :cp, :ville, :gps_lat, :gps_lng,
|
||||
:fk_batiment, :fk_habitat, :nb_niveau, :nb_log, :residence, :alt_sol
|
||||
)";
|
||||
$stmtAddress = $this->db->prepare($queryAddress);
|
||||
|
||||
|
||||
foreach ($addresses as $address) {
|
||||
$stmtAddress->execute([
|
||||
'sector_id' => $id,
|
||||
@@ -680,7 +805,13 @@ class SectorController
|
||||
'cp' => $address['code_postal'],
|
||||
'ville' => $address['commune'],
|
||||
'gps_lat' => $address['latitude'],
|
||||
'gps_lng' => $address['longitude']
|
||||
'gps_lng' => $address['longitude'],
|
||||
'fk_batiment' => $address['fk_batiment'] ?? null,
|
||||
'fk_habitat' => $address['fk_habitat'] ?? 1,
|
||||
'nb_niveau' => $address['nb_niveau'] ?? null,
|
||||
'nb_log' => $address['nb_log'] ?? null,
|
||||
'residence' => $address['residence'] ?? '',
|
||||
'alt_sol' => $address['alt_sol'] ?? null
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -715,10 +846,29 @@ class SectorController
|
||||
|
||||
// Commit des modifications (users et/ou secteur)
|
||||
$this->db->commit();
|
||||
|
||||
|
||||
// Log de mise à jour du secteur
|
||||
$changes = [];
|
||||
if (isset($data['libelle'])) {
|
||||
$changes['libelle'] = ['new' => $data['libelle']];
|
||||
}
|
||||
if (isset($data['color'])) {
|
||||
$changes['color'] = ['new' => $data['color']];
|
||||
}
|
||||
if (isset($data['sector'])) {
|
||||
$changes['sector'] = true; // Polygon modifié
|
||||
}
|
||||
if (isset($data['users'])) {
|
||||
$changes['users'] = true; // Affectation modifiée
|
||||
}
|
||||
|
||||
if (!empty($changes)) {
|
||||
EventLogService::logSectorUpdated((int)$id, (int)$operationId, $changes);
|
||||
}
|
||||
|
||||
// Récupérer le secteur mis à jour
|
||||
$query = "
|
||||
SELECT
|
||||
SELECT
|
||||
s.id,
|
||||
s.libelle,
|
||||
s.color,
|
||||
@@ -726,57 +876,61 @@ class SectorController
|
||||
FROM ope_sectors s
|
||||
WHERE s.id = :id
|
||||
";
|
||||
|
||||
|
||||
$stmt = $this->db->prepare($query);
|
||||
$stmt->execute(['id' => $id]);
|
||||
$sector = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||
|
||||
// Récupérer tous les passages du secteur
|
||||
$passagesQuery = "SELECT id, fk_operation, fk_sector, fk_user, fk_type, fk_adresse, passed_at,
|
||||
numero, rue, rue_bis, ville, residence, fk_habitat, appt, niveau,
|
||||
gps_lat, gps_lng, nom_recu, encrypted_name, remarque, encrypted_email,
|
||||
encrypted_phone, montant, fk_type_reglement, email_erreur, nb_passages
|
||||
FROM ope_pass
|
||||
WHERE fk_sector = :sector_id
|
||||
ORDER BY id";
|
||||
$passagesStmt = $this->db->prepare($passagesQuery);
|
||||
$passagesStmt->execute(['sector_id' => $id]);
|
||||
$passages = $passagesStmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||
|
||||
// Déchiffrer les données sensibles des passages
|
||||
|
||||
// Récupérer les passages UNIQUEMENT si chk_adresses_change = 1
|
||||
$passagesDecrypted = [];
|
||||
foreach ($passages as $passage) {
|
||||
// Déchiffrement du nom
|
||||
$passage['name'] = '';
|
||||
if (!empty($passage['encrypted_name'])) {
|
||||
$passage['name'] = ApiService::decryptData($passage['encrypted_name']);
|
||||
}
|
||||
unset($passage['encrypted_name']);
|
||||
|
||||
// Déchiffrement de l'email
|
||||
$passage['email'] = '';
|
||||
if (!empty($passage['encrypted_email'])) {
|
||||
$decryptedEmail = ApiService::decryptSearchableData($passage['encrypted_email']);
|
||||
if ($decryptedEmail) {
|
||||
$passage['email'] = $decryptedEmail;
|
||||
if ($chkAdressesChange == 1) {
|
||||
// Récupérer tous les passages du secteur
|
||||
$passagesQuery = "SELECT id, fk_operation, fk_sector, fk_user, fk_type, fk_adresse, passed_at,
|
||||
numero, rue, rue_bis, ville, residence, fk_habitat, appt, niveau,
|
||||
gps_lat, gps_lng, nom_recu, encrypted_name, remarque, encrypted_email,
|
||||
encrypted_phone, montant, fk_type_reglement, email_erreur, nb_passages
|
||||
FROM ope_pass
|
||||
WHERE fk_sector = :sector_id
|
||||
ORDER BY id";
|
||||
$passagesStmt = $this->db->prepare($passagesQuery);
|
||||
$passagesStmt->execute(['sector_id' => $id]);
|
||||
$passages = $passagesStmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||
|
||||
// Déchiffrer les données sensibles des passages
|
||||
foreach ($passages as $passage) {
|
||||
// Déchiffrement du nom
|
||||
$passage['name'] = '';
|
||||
if (!empty($passage['encrypted_name'])) {
|
||||
$passage['name'] = ApiService::decryptData($passage['encrypted_name']);
|
||||
}
|
||||
unset($passage['encrypted_name']);
|
||||
|
||||
// Déchiffrement de l'email
|
||||
$passage['email'] = '';
|
||||
if (!empty($passage['encrypted_email'])) {
|
||||
$decryptedEmail = ApiService::decryptSearchableData($passage['encrypted_email']);
|
||||
if ($decryptedEmail) {
|
||||
$passage['email'] = $decryptedEmail;
|
||||
}
|
||||
}
|
||||
unset($passage['encrypted_email']);
|
||||
|
||||
// Déchiffrement du téléphone
|
||||
$passage['phone'] = '';
|
||||
if (!empty($passage['encrypted_phone'])) {
|
||||
$passage['phone'] = ApiService::decryptData($passage['encrypted_phone']);
|
||||
}
|
||||
unset($passage['encrypted_phone']);
|
||||
|
||||
$passagesDecrypted[] = $passage;
|
||||
}
|
||||
unset($passage['encrypted_email']);
|
||||
|
||||
// Déchiffrement du téléphone
|
||||
$passage['phone'] = '';
|
||||
if (!empty($passage['encrypted_phone'])) {
|
||||
$passage['phone'] = ApiService::decryptData($passage['encrypted_phone']);
|
||||
}
|
||||
unset($passage['encrypted_phone']);
|
||||
|
||||
$passagesDecrypted[] = $passage;
|
||||
}
|
||||
|
||||
// Récupérer les users affectés (avec READ UNCOMMITTED pour forcer la lecture des données fraîches)
|
||||
$usersQuery = "SELECT u.id, u.first_name, u.sect_name, u.encrypted_name, ous.fk_sector
|
||||
$usersQuery = "SELECT u.id, ou.id as ope_user_id, u.first_name, u.sect_name, u.encrypted_name, ous.fk_sector
|
||||
FROM ope_users_sectors ous
|
||||
JOIN users u ON ous.fk_user = u.id
|
||||
JOIN ope_users ou ON ous.fk_user = ou.id
|
||||
JOIN users u ON ou.fk_user = u.id
|
||||
WHERE ous.fk_sector = :sector_id
|
||||
ORDER BY u.id";
|
||||
|
||||
@@ -801,7 +955,8 @@ class SectorController
|
||||
$usersDecrypted = [];
|
||||
foreach ($usersSectors as $userSector) {
|
||||
$userData = [
|
||||
'id' => $userSector['id'],
|
||||
'user_id' => $userSector['id'],
|
||||
'ope_user_id' => $userSector['ope_user_id'],
|
||||
'first_name' => $userSector['first_name'] ?? '',
|
||||
'sect_name' => $userSector['sect_name'] ?? '',
|
||||
'fk_sector' => $userSector['fk_sector'],
|
||||
@@ -934,18 +1089,20 @@ class SectorController
|
||||
}
|
||||
|
||||
// Vérifier que le secteur existe et récupérer ses informations
|
||||
$checkQuery = "SELECT s.id, s.libelle, o.fk_entite
|
||||
$checkQuery = "SELECT s.id, s.libelle, s.fk_operation, o.fk_entite
|
||||
FROM ope_sectors s
|
||||
JOIN operations o ON s.fk_operation = o.id
|
||||
WHERE s.id = :id";
|
||||
$checkStmt = $this->db->prepare($checkQuery);
|
||||
$checkStmt->execute(['id' => $id]);
|
||||
$sector = $checkStmt->fetch();
|
||||
|
||||
|
||||
if (!$sector || $sector['fk_entite'] != $entityId) {
|
||||
Response::json(['status' => 'error', 'message' => 'Secteur non trouvé ou non autorisé'], 404);
|
||||
return;
|
||||
}
|
||||
|
||||
$operationId = $sector['fk_operation'];
|
||||
|
||||
$this->db->beginTransaction();
|
||||
|
||||
@@ -1001,9 +1158,16 @@ class SectorController
|
||||
$deleteQuery = "DELETE FROM ope_sectors WHERE id = :id";
|
||||
$deleteStmt = $this->db->prepare($deleteQuery);
|
||||
$deleteStmt->execute(['id' => $id]);
|
||||
|
||||
|
||||
$this->db->commit();
|
||||
|
||||
|
||||
// Log de suppression du secteur (suppression physique = false)
|
||||
EventLogService::logSectorDeleted(
|
||||
(int)$id,
|
||||
(int)$operationId,
|
||||
false // suppression physique (DELETE)
|
||||
);
|
||||
|
||||
// Déchiffrer les données sensibles des passages
|
||||
$passagesDecrypted = [];
|
||||
foreach ($passagesToUpdate as $passage) {
|
||||
@@ -1249,8 +1413,11 @@ class SectorController
|
||||
}
|
||||
|
||||
// 2. CRÉATION/MISE À JOUR DES PASSAGES POUR LES NOUVELLES ADRESSES (OPTIMISÉE)
|
||||
// Récupérer toutes les adresses du secteur depuis sectors_adresses
|
||||
$addressesQuery = "SELECT * FROM sectors_adresses WHERE fk_sector = :sector_id";
|
||||
// Récupérer toutes les adresses du secteur depuis sectors_adresses (avec colonnes bâtiments)
|
||||
$addressesQuery = "SELECT
|
||||
fk_sector, fk_adresse, numero, rue, rue_bis, cp, ville, gps_lat, gps_lng,
|
||||
fk_batiment, fk_habitat, nb_niveau, nb_log, residence, alt_sol
|
||||
FROM sectors_adresses WHERE fk_sector = :sector_id";
|
||||
$addressesStmt = $this->db->prepare($addressesQuery);
|
||||
$addressesStmt->execute(['sector_id' => $sectorId]);
|
||||
$addresses = $addressesStmt->fetchAll();
|
||||
@@ -1268,93 +1435,121 @@ class SectorController
|
||||
$firstUserId = $firstUser ? $firstUser['fk_user'] : null;
|
||||
|
||||
if ($firstUserId && !empty($addresses)) {
|
||||
$this->logService->info('[updatePassagesForSector] Optimisation passages', [
|
||||
$this->logService->info('[updatePassagesForSector] Traitement des passages', [
|
||||
'user_id' => $firstUserId,
|
||||
'nb_addresses' => count($addresses)
|
||||
]);
|
||||
|
||||
// OPTIMISATION : Récupérer TOUS les passages existants en UNE requête
|
||||
$addressIds = array_filter(array_column($addresses, 'fk_adresse'));
|
||||
|
||||
// Construire la requête pour récupérer tous les passages existants
|
||||
// Récupérer TOUS les passages existants pour cette opération en UNE requête
|
||||
$existingQuery = "
|
||||
SELECT id, fk_adresse, numero, rue, rue_bis, ville
|
||||
SELECT id, fk_adresse, numero, rue, rue_bis, ville, residence, appt, fk_habitat,
|
||||
fk_type, encrypted_name, created_at
|
||||
FROM ope_pass
|
||||
WHERE fk_operation = :operation_id
|
||||
AND (";
|
||||
|
||||
$params = ['operation_id' => $operationId];
|
||||
$conditions = [];
|
||||
|
||||
// Condition pour les fk_adresse
|
||||
if (!empty($addressIds)) {
|
||||
$placeholders = [];
|
||||
foreach ($addressIds as $idx => $addrId) {
|
||||
$key = 'addr_' . $idx;
|
||||
$placeholders[] = ':' . $key;
|
||||
$params[$key] = $addrId;
|
||||
}
|
||||
$conditions[] = "fk_adresse IN (" . implode(',', $placeholders) . ")";
|
||||
}
|
||||
|
||||
// Condition pour les données d'adresse (numero, rue, ville)
|
||||
$addressConditions = [];
|
||||
foreach ($addresses as $idx => $addr) {
|
||||
$numKey = 'num_' . $idx;
|
||||
$rueKey = 'rue_' . $idx;
|
||||
$bisKey = 'bis_' . $idx;
|
||||
$villeKey = 'ville_' . $idx;
|
||||
|
||||
$addressConditions[] = "(numero = :$numKey AND rue = :$rueKey AND rue_bis = :$bisKey AND ville = :$villeKey)";
|
||||
$params[$numKey] = $addr['numero'];
|
||||
$params[$rueKey] = $addr['rue'];
|
||||
$params[$bisKey] = $addr['rue_bis'];
|
||||
$params[$villeKey] = $addr['ville'];
|
||||
}
|
||||
|
||||
if (!empty($addressConditions)) {
|
||||
$conditions[] = "(" . implode(' OR ', $addressConditions) . ")";
|
||||
}
|
||||
|
||||
$existingQuery .= implode(' OR ', $conditions) . ")";
|
||||
WHERE fk_operation = :operation_id";
|
||||
|
||||
$existingStmt = $this->db->prepare($existingQuery);
|
||||
$existingStmt->execute($params);
|
||||
$existingStmt->execute(['operation_id' => $operationId]);
|
||||
$existingPassages = $existingStmt->fetchAll();
|
||||
|
||||
// Indexer les passages existants pour recherche rapide
|
||||
// Indexer les passages existants par clé : numero|rue|rue_bis|ville
|
||||
$passagesByAddress = [];
|
||||
$passagesByData = [];
|
||||
foreach ($existingPassages as $p) {
|
||||
if (!empty($p['fk_adresse'])) {
|
||||
$passagesByAddress[$p['fk_adresse']] = $p;
|
||||
$addressKey = $p['numero'] . '|' . $p['rue'] . '|' . ($p['rue_bis'] ?? '') . '|' . $p['ville'];
|
||||
if (!isset($passagesByAddress[$addressKey])) {
|
||||
$passagesByAddress[$addressKey] = [];
|
||||
}
|
||||
$dataKey = $p['numero'] . '|' . $p['rue'] . '|' . $p['rue_bis'] . '|' . $p['ville'];
|
||||
$passagesByData[$dataKey] = $p;
|
||||
$passagesByAddress[$addressKey][] = $p;
|
||||
}
|
||||
|
||||
// Préparer les listes pour batch insert/update
|
||||
// Traiter chaque adresse du secteur
|
||||
$toInsert = [];
|
||||
$toUpdate = [];
|
||||
$toDelete = [];
|
||||
|
||||
foreach ($addresses as $address) {
|
||||
// Vérification en mémoire PHP (0 requête)
|
||||
if (!empty($address['fk_adresse']) && isset($passagesByAddress[$address['fk_adresse']])) {
|
||||
continue; // Déjà existant avec bon fk_adresse
|
||||
}
|
||||
$addressKey = $address['numero'] . '|' . $address['rue'] . '|' . ($address['rue_bis'] ?? '') . '|' . $address['ville'];
|
||||
$existingAtAddress = $passagesByAddress[$addressKey] ?? [];
|
||||
$nbExisting = count($existingAtAddress);
|
||||
|
||||
$dataKey = $address['numero'] . '|' . $address['rue'] . '|' . $address['rue_bis'] . '|' . $address['ville'];
|
||||
if (isset($passagesByData[$dataKey])) {
|
||||
// Passage existant mais sans fk_adresse ou avec fk_adresse différent
|
||||
if (!empty($address['fk_adresse']) && $passagesByData[$dataKey]['fk_adresse'] != $address['fk_adresse']) {
|
||||
$toUpdate[] = [
|
||||
'id' => $passagesByData[$dataKey]['id'],
|
||||
'fk_adresse' => $address['fk_adresse']
|
||||
$fkHabitat = $address['fk_habitat'] ?? 1;
|
||||
$nbLog = ($fkHabitat == 2 && isset($address['nb_log'])) ? (int)$address['nb_log'] : 1;
|
||||
$residence = $address['residence'] ?? '';
|
||||
|
||||
// IMPORTANT : Uniformisation GPS pour les immeubles
|
||||
// Tous les passages d'une même adresse doivent partager les mêmes coordonnées GPS
|
||||
// Issues de sectors_adresses (gps_lat, gps_lng)
|
||||
$gpsLat = $address['gps_lat'];
|
||||
$gpsLng = $address['gps_lng'];
|
||||
|
||||
// CAS 1 : Maison individuelle (fk_habitat=1)
|
||||
if ($fkHabitat == 1) {
|
||||
if ($nbExisting == 0) {
|
||||
// INSERT 1 passage
|
||||
$toInsert[] = [
|
||||
'address' => $address,
|
||||
'residence' => '',
|
||||
'appt' => '',
|
||||
'fk_habitat' => 1
|
||||
];
|
||||
} else {
|
||||
// UPDATE le premier passage avec fk_habitat=1
|
||||
$toUpdate[] = [
|
||||
'id' => $existingAtAddress[0]['id'],
|
||||
'fk_habitat' => 1,
|
||||
'residence' => '',
|
||||
'gps_lat' => $gpsLat,
|
||||
'gps_lng' => $gpsLng
|
||||
];
|
||||
// Les autres passages (si >1) ne sont PAS touchés
|
||||
}
|
||||
}
|
||||
// CAS 2 : Immeuble (fk_habitat=2)
|
||||
else if ($fkHabitat == 2) {
|
||||
// UPDATE TOUS les passages existants avec fk_habitat=2, residence et GPS
|
||||
foreach ($existingAtAddress as $existing) {
|
||||
$updates = [
|
||||
'id' => $existing['id'],
|
||||
'fk_habitat' => 2,
|
||||
'gps_lat' => $gpsLat,
|
||||
'gps_lng' => $gpsLng
|
||||
];
|
||||
// Update residence seulement si non vide
|
||||
if (!empty($residence)) {
|
||||
$updates['residence'] = $residence;
|
||||
}
|
||||
$toUpdate[] = $updates;
|
||||
}
|
||||
|
||||
// Si moins de nb_log passages : INSERT les manquants
|
||||
if ($nbExisting < $nbLog) {
|
||||
$nbToInsert = $nbLog - $nbExisting;
|
||||
for ($i = 0; $i < $nbToInsert; $i++) {
|
||||
$toInsert[] = [
|
||||
'address' => $address,
|
||||
'residence' => $residence,
|
||||
'appt' => '', // Pas de numéro d'appt prédéfini
|
||||
'fk_habitat' => 2
|
||||
];
|
||||
}
|
||||
}
|
||||
// Si plus de nb_log passages : DELETE les non visités en trop
|
||||
else if ($nbExisting > $nbLog) {
|
||||
$nbToDelete = $nbExisting - $nbLog;
|
||||
// Trier les passages par created_at ASC (les plus anciens d'abord)
|
||||
usort($existingAtAddress, function($a, $b) {
|
||||
return strtotime($a['created_at']) - strtotime($b['created_at']);
|
||||
});
|
||||
|
||||
$deleted = 0;
|
||||
foreach ($existingAtAddress as $existing) {
|
||||
if ($deleted >= $nbToDelete) break;
|
||||
// Supprimer seulement si fk_type=2 ET encrypted_name vide
|
||||
if ($existing['fk_type'] == 2 && ($existing['encrypted_name'] === '' || $existing['encrypted_name'] === null)) {
|
||||
$toDelete[] = $existing['id'];
|
||||
$deleted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Nouveau passage à créer
|
||||
$toInsert[] = $address;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1364,19 +1559,24 @@ class SectorController
|
||||
$insertParams = [];
|
||||
$paramIndex = 0;
|
||||
|
||||
foreach ($toInsert as $addr) {
|
||||
foreach ($toInsert as $item) {
|
||||
$addr = $item['address'];
|
||||
$values[] = "(:op$paramIndex, :sect$paramIndex, :usr$paramIndex, :addr$paramIndex,
|
||||
:num$paramIndex, :rue$paramIndex, :bis$paramIndex, :ville$paramIndex,
|
||||
:lat$paramIndex, :lng$paramIndex, 2, '', NOW(), :creat$paramIndex, 1)";
|
||||
:res$paramIndex, :appt$paramIndex, :habitat$paramIndex,
|
||||
:lat$paramIndex, :lng$paramIndex, 2, 0, '', NOW(), :creat$paramIndex, 1)";
|
||||
|
||||
$insertParams["op$paramIndex"] = $operationId;
|
||||
$insertParams["sect$paramIndex"] = $sectorId;
|
||||
$insertParams["usr$paramIndex"] = $firstUserId;
|
||||
$insertParams["addr$paramIndex"] = $addr['fk_adresse'];
|
||||
$insertParams["addr$paramIndex"] = $addr['fk_adresse'] ?? null;
|
||||
$insertParams["num$paramIndex"] = $addr['numero'];
|
||||
$insertParams["rue$paramIndex"] = $addr['rue'];
|
||||
$insertParams["bis$paramIndex"] = $addr['rue_bis'];
|
||||
$insertParams["bis$paramIndex"] = $addr['rue_bis'] ?? '';
|
||||
$insertParams["ville$paramIndex"] = $addr['ville'];
|
||||
$insertParams["res$paramIndex"] = $item['residence'];
|
||||
$insertParams["appt$paramIndex"] = $item['appt'];
|
||||
$insertParams["habitat$paramIndex"] = $item['fk_habitat'];
|
||||
$insertParams["lat$paramIndex"] = $addr['gps_lat'];
|
||||
$insertParams["lng$paramIndex"] = $addr['gps_lng'];
|
||||
$insertParams["creat$paramIndex"] = $_SESSION['user_id'] ?? null;
|
||||
@@ -1386,7 +1586,7 @@ class SectorController
|
||||
|
||||
$insertQuery = "INSERT INTO ope_pass
|
||||
(fk_operation, fk_sector, fk_user, fk_adresse, numero, rue, rue_bis,
|
||||
ville, gps_lat, gps_lng, fk_type, encrypted_name, created_at, fk_user_creat, chk_active)
|
||||
ville, residence, appt, fk_habitat, gps_lat, gps_lng, fk_type, nb_passages, encrypted_name, created_at, fk_user_creat, chk_active)
|
||||
VALUES " . implode(',', $values);
|
||||
|
||||
try {
|
||||
@@ -1401,28 +1601,67 @@ class SectorController
|
||||
}
|
||||
}
|
||||
|
||||
// UPDATE MULTIPLE avec CASE WHEN
|
||||
// UPDATE MULTIPLE avec CASE WHEN (inclut GPS pour uniformisation)
|
||||
if (!empty($toUpdate)) {
|
||||
$updateIds = array_column($toUpdate, 'id');
|
||||
$placeholders = str_repeat('?,', count($updateIds) - 1) . '?';
|
||||
|
||||
$caseWhen = [];
|
||||
$caseWhenHabitat = [];
|
||||
$caseWhenResidence = [];
|
||||
$caseWhenGpsLat = [];
|
||||
$caseWhenGpsLng = [];
|
||||
$updateParams = [];
|
||||
|
||||
foreach ($toUpdate as $upd) {
|
||||
$caseWhen[] = "WHEN id = ? THEN ?";
|
||||
// fk_habitat est toujours présent
|
||||
$caseWhenHabitat[] = "WHEN id = ? THEN ?";
|
||||
$updateParams[] = $upd['id'];
|
||||
$updateParams[] = $upd['fk_adresse'];
|
||||
$updateParams[] = $upd['fk_habitat'];
|
||||
|
||||
// GPS : toujours présent maintenant (uniformisation)
|
||||
if (isset($upd['gps_lat']) && isset($upd['gps_lng'])) {
|
||||
$caseWhenGpsLat[] = "WHEN id = ? THEN ?";
|
||||
$updateParams[] = $upd['id'];
|
||||
$updateParams[] = $upd['gps_lat'];
|
||||
|
||||
$caseWhenGpsLng[] = "WHEN id = ? THEN ?";
|
||||
$updateParams[] = $upd['id'];
|
||||
$updateParams[] = $upd['gps_lng'];
|
||||
}
|
||||
|
||||
// residence est optionnel
|
||||
if (isset($upd['residence'])) {
|
||||
$caseWhenResidence[] = "WHEN id = ? THEN ?";
|
||||
$updateParams[] = $upd['id'];
|
||||
$updateParams[] = $upd['residence'];
|
||||
}
|
||||
}
|
||||
|
||||
$setClause = ["fk_habitat = CASE " . implode(' ', $caseWhenHabitat) . " ELSE fk_habitat END"];
|
||||
if (!empty($caseWhenGpsLat)) {
|
||||
$setClause[] = "gps_lat = CASE " . implode(' ', $caseWhenGpsLat) . " ELSE gps_lat END";
|
||||
}
|
||||
if (!empty($caseWhenGpsLng)) {
|
||||
$setClause[] = "gps_lng = CASE " . implode(' ', $caseWhenGpsLng) . " ELSE gps_lng END";
|
||||
}
|
||||
if (!empty($caseWhenResidence)) {
|
||||
$setClause[] = "residence = CASE " . implode(' ', $caseWhenResidence) . " ELSE residence END";
|
||||
}
|
||||
|
||||
$updateQuery = "UPDATE ope_pass
|
||||
SET fk_adresse = CASE " . implode(' ', $caseWhen) . " END
|
||||
SET " . implode(', ', $setClause) . "
|
||||
WHERE id IN ($placeholders)";
|
||||
|
||||
try {
|
||||
$updateStmt = $this->db->prepare($updateQuery);
|
||||
$updateStmt->execute(array_merge($updateParams, $updateIds));
|
||||
$counters['passages_updated'] = count($toUpdate);
|
||||
|
||||
// Log pour vérifier l'uniformisation GPS (surtout pour immeubles)
|
||||
$this->logService->info('[updatePassagesForSector] Passages mis à jour avec GPS uniformisés', [
|
||||
'nb_updated' => count($toUpdate),
|
||||
'sector_id' => $sectorId
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
$this->logService->error('Erreur lors de la mise à jour multiple des passages', [
|
||||
'sector_id' => $sectorId,
|
||||
@@ -1431,6 +1670,23 @@ class SectorController
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE MULTIPLE en une seule requête
|
||||
if (!empty($toDelete)) {
|
||||
$placeholders = str_repeat('?,', count($toDelete) - 1) . '?';
|
||||
$deleteQuery = "DELETE FROM ope_pass WHERE id IN ($placeholders)";
|
||||
|
||||
try {
|
||||
$deleteStmt = $this->db->prepare($deleteQuery);
|
||||
$deleteStmt->execute($toDelete);
|
||||
$counters['passages_deleted'] += count($toDelete);
|
||||
} catch (\Exception $e) {
|
||||
$this->logService->error('Erreur lors de la suppression multiple des passages', [
|
||||
'sector_id' => $sectorId,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
$this->logService->warning('[updatePassagesForSector] Pas de création de passages', [
|
||||
'reason' => !$firstUserId ? 'Pas d\'utilisateur affecté' : 'Pas d\'adresses',
|
||||
|
||||
Reference in New Issue
Block a user