From 0e9fb83cc21fd139c72b2d62e4ac9e65387aca0a Mon Sep 17 00:00:00 2001 From: mac Date: Sat, 20 Jun 2026 11:19:54 +0300 Subject: [PATCH] Add is_deal icon --- engine/classes/Requisitions.php | 443 +++++++++++++++++++++++++++++++- requisitions.php | 5 + 2 files changed, 437 insertions(+), 11 deletions(-) diff --git a/engine/classes/Requisitions.php b/engine/classes/Requisitions.php index 8621534..cc5fcbd 100644 --- a/engine/classes/Requisitions.php +++ b/engine/classes/Requisitions.php @@ -543,7 +543,7 @@ } else { if ($mstr['agency']) { - $master_work = "агентство $mstr[agency_name]"; + $master_work = trim($mstr['last_name'] . ' ' . $mstr['first_name'] . ' ' . $mstr['middle_name']) ?: "агентство {$mstr['agency_name']}"; } elseif ($mstr['manager']) { @@ -602,7 +602,7 @@ $master_delete = trim($mstr['last_name'] . ' ' . $mstr['first_name'] . ' ' . $mstr['middle_name']); } else { if ($mstr['agency']) { - $master_delete = "агентство $mstr[agency_name]"; + $master_delete = trim($mstr['last_name'] . ' ' . $mstr['first_name'] . ' ' . $mstr['middle_name']) ?: "агентство {$mstr['agency_name']}"; } elseif ($mstr['manager']) { @@ -642,9 +642,8 @@ //Общее по одной заявке для канбана private function get_req_details_kanban($r, $kanban = false, $usersToUse = [], $departments = [], $typesReq = array(), $events_res = array()){ - $dateAdd = date("d.m.Y",strtotime($r['created_at'])); - list($d, $m, $y) = explode(".", $dateAdd); - $date_add = $d." ".getRusMonth($m)." ".$y; + // Унифицировано с форматом даты задачи: DD.MM.YYYY. + $date_add = date("d.m.Y", strtotime($r['created_at'])); if(!isset($r['funnel_type'])){ $r['funnel_type'] = 'main'; } @@ -848,7 +847,8 @@ ]; $type = $row['type']; - $scheduleDate = date('d-m-Y H:i', strtotime($row['schedule_date'])); + // Унифицировано с датой заявки: DD.MM.YYYY HH:MM. + $scheduleDate = date('d.m.Y H:i', strtotime($row['schedule_date'])); $taskName = $row['name']; $bgColor = '#e2e9f5'; $textColor = '#76a7fa'; @@ -870,6 +870,7 @@ } } + $r['is_deal'] = !empty($r['deal_id']) && $r['deal_id'] > 0 ? 1 : 0; $result = $r; return $result; } @@ -935,6 +936,8 @@ $r['no_conf_doer'] = $noConfDoer; $r['can_see'] =$canSee; + $r['is_deal'] = !empty($r['deal_id']) && $r['deal_id'] > 0 ? 1 : 0; + $r['can_see_other'] = false; if ($r['can_edit'] && ((int)$r['who_work'] == (int)$this->user->id || (int)$r['user_id'] == (int)$this->user->id)) $r['can_see_other'] = true; @@ -1118,7 +1121,7 @@ $r['no_can_edit_hot'] = 0; $r['see_modal_client'] = 1; if ($r_m['agency']) { - $master = "Агентство $r_m[agency_name]"; + $master = trim($r_m['last_name'] . ' ' . $r_m['first_name'] . ' ' . $r_m['middle_name']) ?: "Агентство {$r_m['agency_name']}"; } else if ($r_m['manager']) { $master = "Менеджер " . trim($r_m['last_name'] . ' ' . $r_m['first_name'] . ' ' . $r_m['middle_name']); if ($r_m['role_id'] > 0) { @@ -1536,7 +1539,7 @@ req.stage, req.deleted, req.cancel, req.priority, req.doer_clients, req.hot, req.reason, req.denial, req.summa, req.expected_commission, req.activities, req.asfilter_id, req.source, req.doers, req.doers_confirm, req.employee_id, req.property_type_id, - req.integration, req.call_id, req.friend_client_id, req.confirm_recommendation, req.id as reqid, + req.integration, req.call_id, req.friend_client_id, req.confirm_recommendation, req.deal_id, req.id as reqid, 'main' as funnel_type, req.complex_room_id $priceSelect FROM requisitions as req $priceJoin $propertyTypeJoin $join WHERE funnel_id in ($funnelId)"; @@ -1547,7 +1550,7 @@ req.stage, req.deleted, req.cancel, req.priority, req.doer_clients, req.hot, req.reason, req.denial, req.summa, req.expected_commission, req.activities, req.asfilter_id, req.source, req.doers, req.doers_confirm, req.employee_id, req.property_type_id, - req.integration, req.call_id, 0 as friend_client_id, 0 as confirm_recommendation, req.id as reqid, + req.integration, req.call_id, 0 as friend_client_id, 0 as confirm_recommendation, req.deal_id, req.id as reqid, 'adjacent' as funnel_type, req.complex_room_id $priceSelect FROM adjacent_funnels as a @@ -2709,7 +2712,7 @@ req.stage, req.deleted, req.cancel, req.priority, req.doer_clients, req.hot, req.reason, req.denial, req.summa, req.expected_commission, req.activities, req.asfilter_id, req.source, req.doers, req.doers_confirm, req.employee_id, req.property_type_id, - req.integration, req.call_id, req.friend_client_id, req.confirm_recommendation, req.id as reqid, 'main' as funnel_type $priceSelect FROM requisitions as req $priceJoin $propertyTypeJoin $join WHERE req.funnel_id=$funnelId $wherePart1 and req.step_id = $step_id"; + req.integration, req.call_id, req.friend_client_id, req.confirm_recommendation, req.deal_id, req.id as reqid, 'main' as funnel_type $priceSelect FROM requisitions as req $priceJoin $propertyTypeJoin $join WHERE req.funnel_id=$funnelId $wherePart1 and req.step_id = $step_id"; $sql_2 .= " UNION "; $sql_2 .= "SELECT req.id, req.name, req.user_id, req.created_at, req.client_id, req.object_id, req.type_id, a.funnel_id, a.step_id_active as step_id, @@ -2717,7 +2720,7 @@ req.stage, req.deleted, req.cancel, req.priority, req.doer_clients, req.hot, req.reason, req.denial, req.summa, req.expected_commission, req.activities, req.asfilter_id, req.source, req.doers, req.doers_confirm, req.employee_id, req.property_type_id, - req.integration, req.call_id, 0 as friend_client_id, 0 as confirm_recommendation, req.id as reqid, 'adjacent' as funnel_type $priceSelect + req.integration, req.call_id, 0 as friend_client_id, 0 as confirm_recommendation, req.deal_id, req.id as reqid, 'adjacent' as funnel_type $priceSelect FROM adjacent_funnels as a LEFT JOIN requisitions as req on a.req_id = req.id $priceJoin $propertyTypeJoin $join WHERE a.funnel_id=$funnelId $wherePart2 and a.step_id_active = $step_id"; $sql_2 .= " ORDER BY created_at $order $limitSql"; @@ -4316,6 +4319,362 @@ } } + // Копирование заявки в одной транзакции. Возвращает [success, id, error, code]. + public function copy($srcId, $funnelId, $whoWorkId, $currentUserId) { + $srcId = (int)$srcId; + $funnelId = (int)$funnelId; + $whoWorkId = (int)$whoWorkId; + $currentUserId = (int)$currentUserId; + + $qSrc = $this->db->query("SELECT * FROM requisitions WHERE id = {$srcId} LIMIT 1"); + $src = $this->db->fetch_assoc($qSrc); + if (!$src) { + return array( + 'success' => false, + 'error' => 'Исходная заявка не найдена', + 'code' => 'src_not_found', + 'id' => null, + ); + } + if ((int)$src['deleted'] === 1) { + return array( + 'success' => false, + 'error' => 'Нельзя копировать закрытую заявку', + 'code' => 'src_deleted', + 'id' => null, + ); + } + + $srcTypeId = (int)$src['type_id']; + $qType = $this->db->query("SELECT id, heir FROM requisitions_type WHERE id = {$srcTypeId} LIMIT 1"); + $typeRow = $this->db->fetch_assoc($qType); + $baseTypeId = $typeRow ? requisition_base_type_id($typeRow) : 0; + + // funnel_id=0 — дефолтная воронка без funnel_steps; step_id=1 («Новый»). + if ($funnelId === 0) { + $newStepId = 1; + } else { + $qStep = $this->db->query( + "SELECT id FROM funnel_steps WHERE funnel_id = {$funnelId} AND deleted = 0 ORDER BY order_number, branch LIMIT 1" + ); + $firstStep = $this->db->fetch_assoc($qStep); + if (!$firstStep) { + return array( + 'success' => false, + 'error' => 'В выбранной воронке нет этапов', + 'code' => 'no_steps', + 'id' => null, + ); + } + $newStepId = (int)$firstStep['id']; + } + + $srcName = isset($src['name']) ? (string)$src['name'] : ''; + $newName = (trim($srcName) === '') ? '' : 'Копия: ' . $srcName; + + $copyObject = requisition_should_copy_object($baseTypeId); + $newObjectId = $copyObject ? (int)$src['object_id'] : 0; + + $copyFilters = requisition_should_copy_search_filters($baseTypeId); + + // ВАЖНО: нативный PDO begin/commit (query("BEGIN") глотает PDOException). Даты через NOW() — TZ-смещение. + try { + $this->db->beginTransaction(); + $now = date('Y-m-d H:i:s'); + + $nameSql = mysql_real_escape_string($newName); + $sourceVal = (int)$src['source']; + $hot = (int)$src['hot']; + $priority = (int)$src['priority']; + $clientId = (int)$src['client_id']; + $descriptionSql = mysql_real_escape_string((string)$src['description']); + + // Теги читаются из requisitions_activities (junction-таблица), а не из колонки activities. + $insSql = "INSERT INTO requisitions + (user_id, who_work, funnel_id, step_id, stage, name, type_id, source, + priority, hot, client_id, object_id, description, created_at, confirm, deleted, cancel) + VALUES + ({$currentUserId}, {$whoWorkId}, {$funnelId}, {$newStepId}, 1, + '{$nameSql}', {$srcTypeId}, {$sourceVal}, + {$priority}, {$hot}, {$clientId}, {$newObjectId}, '{$descriptionSql}', NOW(), 1, 0, 0)"; + + if (!$this->db->query($insSql)) { + throw new Exception('INSERT requisitions завершился ошибкой'); + } + $newId = (int)$this->db->insert_id(); + if ($newId <= 0) { + throw new Exception('INSERT requisitions не вернул id'); + } + + // autosearch_filters — копируем для всех типов кроме «Подбор объекта» (base=1). + $srcAsfilterId = isset($src['asfilter_id']) ? (int)$src['asfilter_id'] : 0; + if ($copyFilters && $srcAsfilterId > 0) { + $qFilter = $this->db->query("SELECT * FROM autosearch_filters WHERE id = {$srcAsfilterId} LIMIT 1"); + $filterRow = $this->db->fetch_assoc($qFilter); + if ($filterRow) { + unset($filterRow['id']); + if (array_key_exists('deleted', $filterRow)) $filterRow['deleted'] = 0; + if (array_key_exists('archived', $filterRow)) $filterRow['archived'] = 0; + $filterRow['req_id'] = $newId; + $filterRow['created_at'] = $now; + $filterRow['created_by'] = $currentUserId; + $filterRow['updated_at'] = $now; + $filterRow['updated_by'] = $currentUserId; + + $cols = array(); + $vals = array(); + foreach ($filterRow as $col => $val) { + $cols[] = "`{$col}`"; + if ($col === 'created_at' || $col === 'updated_at') { + $vals[] = 'NOW()'; + } elseif ($val === null) { + $vals[] = 'NULL'; + } else { + $vals[] = "'" . mysql_real_escape_string((string)$val) . "'"; + } + } + $filterInsSql = "INSERT INTO autosearch_filters (" . implode(',', $cols) . ")" + . " VALUES (" . implode(',', $vals) . ")"; + if (!$this->db->query($filterInsSql)) { + throw new Exception('INSERT autosearch_filters завершился ошибкой'); + } + $newAsfilterId = (int)$this->db->insert_id(); + if ($newAsfilterId <= 0) { + throw new Exception('INSERT autosearch_filters не вернул id'); + } + if (!$this->db->query("UPDATE requisitions SET asfilter_id = {$newAsfilterId} WHERE id = {$newId}")) { + throw new Exception('UPDATE requisitions.asfilter_id завершился ошибкой'); + } + } + } + + $qTags = $this->db->query("SELECT activity_id FROM requisitions_activities WHERE req_id = {$srcId}"); + while ($tag = $this->db->fetch_assoc($qTags)) { + $aid = (int)$tag['activity_id']; + if (!$this->db->query("INSERT INTO requisitions_activities SET req_id = {$newId}, activity_id = {$aid}")) { + throw new Exception('INSERT requisitions_activities завершился ошибкой'); + } + } + + // contracts_req: date_start/date_end — unix-timestamp (int), не DATETIME. + $hasSaleContract = false; + $qContracts = $this->db->query("SELECT * FROM contracts_req WHERE req_id = {$srcId}"); + while ($c = $this->db->fetch_assoc($qContracts)) { + $cType = (int)$c['type']; + $cNumber = mysql_real_escape_string((string)$c['number']); + $cStart = (int)$c['date_start']; + $cEnd = (int)$c['date_end']; + $cDays = (int)$c['days']; + $cSend = (int)$c['send']; + $cAgency = (int)$c['agency_id']; + + $contractInsSql = "INSERT INTO contracts_req + (req_id, type, number, date_start, date_end, days, send, agency_id) + VALUES + ({$newId}, {$cType}, '{$cNumber}', {$cStart}, {$cEnd}, {$cDays}, {$cSend}, {$cAgency})"; + if (!$this->db->query($contractInsSql)) { + throw new Exception('INSERT contracts_req завершился ошибкой'); + } + if ($cType === 2) { + $hasSaleContract = true; + } + } + + // Cross-link contracts_object — только если есть договор type=2 (продажа). + if ($copyObject && $newObjectId > 0 && $hasSaleContract) { + $this->contractInObject($newId, $newObjectId); + } + + // copy_created: историческое событие в исходнике (без уведомления). dop_text = id новой заявки. + $sqlEvSrc = "INSERT INTO events_clients + (req_id, event, dop_text, user_id, from_id, date, send_telegramm, send_max, `read`) + VALUES + ({$srcId}, 'copy_created', '{$newId}', {$currentUserId}, 0, NOW(), 1, 1, 1)"; + if (!$this->db->query($sqlEvSrc)) { + throw new Exception('INSERT events_clients (copy_created) завершился ошибкой'); + } + + // copy_from: событие в копии. read=1 всегда — копия не передача, поллер не должен показывать модалку «Передача заявки». + // send_telegramm/send_max=0 при копировании на другого сотрудника — cron отдельно шлёт уведомление в TG/MAX. + $skipTgMax = ($whoWorkId === $currentUserId) ? 1 : 0; + $sqlEvNew = "INSERT INTO events_clients + (req_id, event, dop_text, user_id, from_id, date, send_telegramm, send_max, `read`) + VALUES + ({$newId}, 'copy_from', '{$srcId}', {$currentUserId}, {$whoWorkId}, NOW(), {$skipTgMax}, {$skipTgMax}, 1)"; + if (!$this->db->query($sqlEvNew)) { + throw new Exception('INSERT events_clients (copy_from) завершился ошибкой'); + } + + $this->db->commit(); + return array( + 'success' => true, + 'id' => $newId, + 'error' => null, + 'code' => null, + ); + + } catch (Exception $e) { + // inTransaction() — защита от двойного rollback (DDL-ошибки откатывают сами). + if ($this->db->inTransaction()) { + $this->db->rollBack(); + } + return array( + 'success' => false, + 'error' => 'Ошибка при создании копии: ' . $e->getMessage(), + 'code' => 'exception', + 'id' => null, + ); + } + } + + // Право на редактирование/копирование заявки. Без $_SESSION (для cron). TODO: унифицировать с дубликатами в этом классе. + public function user_can_edit_req($srcRow, $userId) { + $userId = (int)$userId; + if ($userId <= 0) { + return false; + } + + if (isset($srcRow['who_work']) && (int)$srcRow['who_work'] === $userId) { + return true; + } + + $sql = "SELECT u.`agency`, u.`users_admin`, p.`menu_all_submissions_edit` + FROM `users` u + LEFT JOIN `user_permissions` p ON p.`user_id` = u.`id` + WHERE u.`id` = {$userId} + LIMIT 1"; + $result = mysql_query($sql); + if (!$result) { + return false; + } + $u = mysql_fetch_assoc($result); + if (!$u) { + return false; + } + + if ((int)$u['agency'] === 1 + || (int)$u['users_admin'] === 1 + || (int)$u['menu_all_submissions_edit'] === 1) { + return true; + } + + // doers (соисполнители): doers_confirm=0 И userId — ключ в JSON со значением 1. + $doersConfirm = isset($srcRow['doers_confirm']) ? (int)$srcRow['doers_confirm'] : 0; + $doersRaw = isset($srcRow['doers']) ? $srcRow['doers'] : ''; + if ($doersConfirm === 0 && $doersRaw !== '') { + $doers = json_decode(html_entity_decode((string)$doersRaw), true); + if (is_array($doers) && isset($doers[$userId]) && (int)$doers[$userId] === 1) { + return true; + } + } + + // admin_department / manager_office своего отдела (по users.department_id ответственного). + $whoWorkId = isset($srcRow['who_work']) ? (int)$srcRow['who_work'] : 0; + if ($whoWorkId <= 0) { + return false; + } + $depHelper = new Department(); + $callerDep = $depHelper->getDepartment($userId); + if (!is_array($callerDep) || !isset($callerDep['role'])) { + return false; + } + $callerRole = $callerDep['role']; + + if ($callerRole === 'admin_department' || $callerRole === 'manager_office') { + $q = mysql_query( + "SELECT u_caller.`department_id` AS caller_dep, + u_responsible.`department_id` AS resp_dep + FROM `users` u_caller + LEFT JOIN `users` u_responsible ON u_responsible.`id` = {$whoWorkId} + WHERE u_caller.`id` = {$userId} + LIMIT 1" + ); + $deps = $q ? mysql_fetch_assoc($q) : null; + if (!$deps) { + return false; + } + $callerDepId = (int)$deps['caller_dep']; + $respDepId = (int)$deps['resp_dep']; + if ($callerDepId > 0 && $callerDepId === $respDepId) { + return true; + } + return false; + } + + // manager / manager_office_menager в цепочке id_manager ответственного. + if ($callerRole === 'manager' || $callerRole === 'manager_office_menager') { + $qm = mysql_query( + "SELECT u_caller.`id_manager` AS caller_mgr, + u_responsible.`id_manager` AS resp_mgr + FROM `users` u_caller + LEFT JOIN `users` u_responsible ON u_responsible.`id` = {$whoWorkId} + WHERE u_caller.`id` = {$userId} + LIMIT 1" + ); + $mgrs = $qm ? mysql_fetch_assoc($qm) : null; + if (!$mgrs) { + return false; + } + $callerMgr = (int)$mgrs['caller_mgr']; + $respMgr = (int)$mgrs['resp_mgr']; + + if ($respMgr > 0 && $respMgr === $userId) { + return true; + } + if ($callerMgr > 0 && $callerMgr === $respMgr) { + return true; + } + if ($callerMgr > 0 && $callerMgr === $whoWorkId) { + return true; + } + } + + return false; + } + + // Может ли $userId быть ответственным: agency-tree + blocked=0 + НЕ в funnel.users_no_see. + public function user_can_be_responsible($userId, $agencyId, $funnelId) { + $userId = (int)$userId; + $agencyId = (int)$agencyId; + $funnelId = (int)$funnelId; + + if ($userId <= 0 || $agencyId <= 0 || $funnelId <= 0) { + return false; + } + + if ((int)User::getUserAgencyID($userId) !== $agencyId) { + return false; + } + + $blockedQ = mysql_query("SELECT `blocked` FROM `users` WHERE `id` = {$userId} LIMIT 1"); + $blockedRow = mysql_fetch_assoc($blockedQ); + if (!$blockedRow || (int)$blockedRow['blocked'] === 1) { + return false; + } + + $sql = "SELECT `users_no_see` FROM `funnel` WHERE `id` = {$funnelId} LIMIT 1"; + $result = mysql_query($sql); + if (!$result) { + return false; + } + $funnelRow = mysql_fetch_assoc($result); + if (!$funnelRow) { + return false; + } + + if (!empty($funnelRow['users_no_see'])) { + $hidden = json_decode($funnelRow['users_no_see'], true); + if (is_array($hidden)) { + $hiddenInt = array_map('intval', $hidden); + if (in_array($userId, $hiddenInt, true)) { + return false; + } + } + } + + return true; + } + //Изменение воронки public function updateFunnel($funnel_id, $req_id) { @@ -6525,5 +6884,67 @@ error_reporting(E_ALL & ~E_DEPRECATED & ~E_STRICT & ~E_NOTICE);*/ } } + + public function getRequisitionsByStage($stageId, $funnelId = 0, $search = '', $period = '', $responsible = [], $offset = 0, $limit = 20) { + $sql = "SELECT r.* FROM requisitions r + WHERE r.stage = ? AND r.deleted = 0"; + + if ($funnelId > 0) { + $sql .= " AND r.funnel_id = ?"; + } + if (!empty($search)) { + $sql .= " AND (r.fio LIKE ? OR r.phone LIKE ?)"; + } + if (!empty($period)) { + // добавьте свою логику по периоду + } + if (!empty($responsible)) { + $placeholders = str_repeat('?,', count($responsible) - 1) . '?'; + $sql .= " AND r.users_id IN ($placeholders)"; + } + + $sql .= " ORDER BY r.id DESC LIMIT ? OFFSET ?"; + $params = [$stageId]; + + if ($funnelId > 0) $params[] = $funnelId; + if (!empty($search)) { + $like = '%' . $search . '%'; + $params[] = $like; + $params[] = $like; + } + if (!empty($responsible)) { + foreach ($responsible as $uid) { + $params[] = (int)$uid; + } + } + $params[] = $limit; + $params[] = $offset; + + $stmt = $this->pdo->prepare($sql); + $stmt->execute($params); + + return [ + 'items' => $stmt->fetchAll(PDO::FETCH_ASSOC) + ]; + } } + +// Базовый тип: 1=Подбор, 2=Реализация, 3=Другие услуги, 4=Спрос. heir != 0 — кастомный наследник. +function requisition_base_type_id($typeRow) { + $heir = isset($typeRow['heir']) ? (int)$typeRow['heir'] : 0; + if ($heir !== 0) { + return $heir; + } + return (int)$typeRow['id']; +} + +// object_id копируется только для типов 3 и 4. +function requisition_should_copy_object($baseTypeId) { + return in_array((int)$baseTypeId, [3, 4], true); +} + +// Фильтры поиска копируются для всех типов кроме 1 (Подбор). +function requisition_should_copy_search_filters($baseTypeId) { + return (int)$baseTypeId !== 1; +} ?> diff --git a/requisitions.php b/requisitions.php index f62f7c6..c15f7d6 100644 --- a/requisitions.php +++ b/requisitions.php @@ -1331,6 +1331,11 @@ $t = $endtime - $starttime;
+ + + +