FilterController.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340
  1. <?php
  2. namespace App\Http\Controllers;
  3. use App\Http\Requests\FilterRequest;
  4. use App\Models\CommonCatalogItem;
  5. use App\Models\SparePartsView;
  6. use App\Models\StockOrder;
  7. use Illuminate\Http\JsonResponse;
  8. use Illuminate\Support\Facades\DB;
  9. use Illuminate\Support\Facades\Schema;
  10. class FilterController extends Controller
  11. {
  12. const DB_TABLES = [
  13. 'orders' => 'orders_view',
  14. 'product_sku' => 'mafs_view',
  15. 'products' => 'products',
  16. 'reclamations' => 'reclamations_view',
  17. 'maf_order' => 'maf_orders_view',
  18. 'import' => 'imports',
  19. 'responsibles' => 'responsibles_view',
  20. 'users' => 'users',
  21. 'contracts' => 'contracts',
  22. 'spare_parts' => 'spare_parts_view',
  23. 'spare_part_orders' => 'spare_part_orders_view',
  24. 'notifications' => 'user_notifications',
  25. 'notification_logs' => 'notification_delivery_logs',
  26. 'common_catalog_items' => 'common_catalog_items',
  27. 'stock_availability' => 'common_catalog_items',
  28. 'stock_orders' => 'stock_orders',
  29. ];
  30. const SKIP_YEAR_FILTER = [
  31. 'reclamations',
  32. 'notifications',
  33. 'notification_logs',
  34. ];
  35. /**
  36. * Маппинг виртуальных столбцов (аксессоров Eloquent) на реальные столбцы БД.
  37. * Ключ — имя таблицы из DB_TABLES, значение — массив 'виртуальный_столбец' => 'реальный_столбец'.
  38. */
  39. const COLUMN_MAP = [
  40. 'spare_part_orders' => [
  41. 'status_name' => 'status',
  42. 'with_documents_text' => 'with_documents',
  43. ],
  44. 'spare_parts' => [
  45. 'customer_price_txt' => 'customer_price',
  46. 'expertise_price_txt' => 'expertise_price',
  47. 'purchase_price_txt' => 'purchase_price',
  48. ],
  49. 'products' => [
  50. 'product_price_txt' => 'product_price',
  51. 'installation_price_txt' => 'installation_price',
  52. 'total_price_txt' => 'total_price',
  53. ],
  54. 'responsibles' => [
  55. 'area-name' => 'area_name',
  56. ],
  57. 'common_catalog_items' => [
  58. 'calculator_enabled_txt' => 'calculator_enabled',
  59. 'builders_price_txt' => 'builders_price',
  60. 'wholesale_price_txt' => 'wholesale_price',
  61. 'recommended_price_txt' => 'recommended_price',
  62. 'retail_price_txt' => 'retail_price',
  63. 'project_price_txt' => 'project_price',
  64. 'project_with_installation_price_txt' => 'project_with_installation_price',
  65. 'pik_price_txt' => 'pik_price',
  66. 'recommended_plus_10_price_txt' => 'recommended_plus_10_price',
  67. ],
  68. 'stock_orders' => [
  69. 'status_name' => 'status',
  70. ],
  71. ];
  72. /**
  73. * Маппинг значений: реальное значение БД => отображаемое значение.
  74. * Ключ — имя таблицы, затем реальный столбец.
  75. */
  76. const VALUE_MAP = [
  77. 'users' => [
  78. 'role' => \App\Models\Role::NAMES,
  79. ],
  80. 'notifications' => [
  81. 'type' => [
  82. 'platform' => 'Площадки',
  83. 'reclamation' => 'Рекламации',
  84. 'schedule' => 'График монтажей',
  85. ],
  86. 'event' => [
  87. 'created' => 'Создание',
  88. 'status_changed' => 'Смена статуса',
  89. 'schedule_added' => 'Добавлено в график',
  90. ],
  91. ],
  92. 'spare_part_orders' => [
  93. 'with_documents' => [
  94. 0 => 'Нет',
  95. 1 => 'Да',
  96. ],
  97. 'status' => [
  98. 'ordered' => 'Заказано',
  99. 'in_stock' => 'На складе',
  100. 'shipped' => 'Отгружено',
  101. ],
  102. ],
  103. 'notification_logs' => [
  104. 'channel' => [
  105. 'in_app' => 'Браузер',
  106. 'browser' => 'Браузер',
  107. 'push' => 'Android/iOS',
  108. 'email' => 'Email',
  109. ],
  110. 'status' => [
  111. 'sent' => 'Отправлено',
  112. 'failed' => 'Ошибка',
  113. 'skipped' => 'Пропущено',
  114. 'dead_letter' => 'Dead letter',
  115. ],
  116. ],
  117. 'common_catalog_items' => [
  118. 'calculator_enabled' => [
  119. 0 => 'нет',
  120. 1 => 'да',
  121. ],
  122. ],
  123. 'stock_orders' => [
  124. 'status' => StockOrder::STATUS_NAMES,
  125. ],
  126. ];
  127. private const FIELD_ACCESS_MODULES = [
  128. 'common_catalog_items' => 'common-catalog',
  129. ];
  130. private const SESSION_KEYS = [
  131. 'common_catalog_items' => 'gp_common_catalog',
  132. ];
  133. public function getFilters(FilterRequest $request): JsonResponse
  134. {
  135. $table = $request->validated('table');
  136. $column = $request->validated('column');
  137. if (! array_key_exists($table, self::DB_TABLES)) {
  138. abort(400, 'Table not found');
  139. }
  140. $this->assertCanViewColumn($request, $table, $column);
  141. if ($table === 'stock_availability') {
  142. return $this->stockAvailabilityFilters($column);
  143. }
  144. if ($table === 'stock_orders') {
  145. return $this->stockOrderFilters($column);
  146. }
  147. $gp = session(self::SESSION_KEYS[$table] ?? 'gp_'.$table);
  148. if ($table === 'spare_parts' && $column === 'pricing_codes_list') {
  149. $result = DB::table('pricing_codes as pc')
  150. ->join('spare_part_pricing_code as sppc', 'sppc.pricing_code_id', '=', 'pc.id')
  151. ->select('pc.code')
  152. ->distinct()
  153. ->orderBy('pc.code')
  154. ->pluck('pc.code')
  155. ->toArray();
  156. $hasEmptyValue = SparePartsView::query()
  157. ->doesntHave('pricingCodes')
  158. ->exists();
  159. if ($hasEmptyValue) {
  160. array_unshift($result, '-пусто-');
  161. }
  162. return response()->json($result, 200, [], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
  163. }
  164. $dbTable = self::DB_TABLES[$table];
  165. // Определяем реальный столбец БД
  166. $dbColumn = self::resolveDbColumn($table, $dbTable, $column);
  167. if ($dbColumn && Schema::hasColumn($dbTable, $dbColumn)) {
  168. $normalizedColumn = self::normalizedSelectExpression($dbColumn);
  169. $q = DB::table($dbTable)->selectRaw($normalizedColumn.' as filter_value')->distinct();
  170. if (! in_array($table, self::SKIP_YEAR_FILTER) && Schema::hasColumn($dbTable, 'year')) {
  171. $q->where('year', year());
  172. }
  173. if (Schema::hasColumn($dbTable, 'deleted_at')) {
  174. $q->whereNull('deleted_at');
  175. }
  176. if (isset($gp['filters']) && is_array($gp['filters']) && count($gp['filters'])) {
  177. foreach ($gp['filters'] as $colName => $vals) {
  178. if ($colName === $column) {
  179. continue;
  180. }
  181. $filterDbColumn = self::resolveDbColumn($table, $dbTable, $colName);
  182. if (! $filterDbColumn || ! Schema::hasColumn($dbTable, $filterDbColumn)) {
  183. continue;
  184. }
  185. $q->where(function ($query) use ($filterDbColumn, $vals) {
  186. foreach (explode('||', $vals) as $val) {
  187. if ($val == '-пусто-') {
  188. self::applyEmptyFilterConditionForFilterQuery($query, $filterDbColumn);
  189. } else {
  190. $query->orWhere($filterDbColumn, '=', $val);
  191. }
  192. }
  193. });
  194. }
  195. }
  196. $result = $q->orderBy('filter_value')->get()->pluck('filter_value')->toArray();
  197. // Конвертация цен из копеек в рубли для отображения
  198. if (str_ends_with($dbColumn, '_price')) {
  199. $result = array_map(function ($val) {
  200. if ($val === null || $val === '-пусто-') {
  201. return $val;
  202. }
  203. return $val / 100;
  204. }, $result);
  205. }
  206. // Применяем маппинг значений, если есть
  207. if (isset(self::VALUE_MAP[$table][$dbColumn])) {
  208. $map = self::VALUE_MAP[$table][$dbColumn];
  209. $result = array_map(fn ($val) => $map[$val] ?? $val, $result);
  210. }
  211. } else {
  212. $result = [];
  213. }
  214. return response()->json($result, 200, [], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
  215. }
  216. private static function normalizedSelectExpression(string $column): string
  217. {
  218. return "CASE WHEN {$column} IS NULL OR TRIM(CAST({$column} AS CHAR)) = '' THEN '-пусто-' ELSE CAST({$column} AS CHAR) END";
  219. }
  220. private static function applyEmptyFilterConditionForFilterQuery($query, string $column): void
  221. {
  222. $query->orWhereNull($column)
  223. ->orWhereRaw("TRIM(CAST({$column} AS CHAR)) = ''");
  224. }
  225. private function assertCanViewColumn(FilterRequest $request, string $table, string $column): void
  226. {
  227. if ($table === 'stock_availability') {
  228. abort_unless($request->user()->hasPermission('stock.view'), 403);
  229. return;
  230. }
  231. if ($table === 'stock_orders') {
  232. abort_unless($request->user()->hasPermission('stock.orders.view'), 403);
  233. return;
  234. }
  235. $module = self::FIELD_ACCESS_MODULES[$table] ?? null;
  236. if ($module === null) {
  237. return;
  238. }
  239. $field = self::COLUMN_MAP[$table][$column] ?? $column;
  240. abort_unless($request->user()->canViewField($module, $field), 403);
  241. }
  242. private function stockAvailabilityFilters(string $column): JsonResponse
  243. {
  244. $values = match ($column) {
  245. 'article', 'calculator_name', 'kind', 'unit' => CommonCatalogItem::query()
  246. ->whereHas('stockOrders')
  247. ->pluck($column),
  248. 'latest_order_note' => CommonCatalogItem::query()
  249. ->whereHas('stockOrders')
  250. ->with('latestStockOrder:id,common_catalog_item_id,note')
  251. ->get()
  252. ->pluck('latestStockOrder.note'),
  253. default => [],
  254. };
  255. return $this->filterValuesResponse($values);
  256. }
  257. private function stockOrderFilters(string $column): JsonResponse
  258. {
  259. $values = match ($column) {
  260. 'order_number', 'note' => StockOrder::query()->pluck($column),
  261. 'item_article' => StockOrder::query()
  262. ->join('common_catalog_items', 'common_catalog_items.id', '=', 'stock_orders.common_catalog_item_id')
  263. ->pluck('common_catalog_items.article'),
  264. 'status_name' => StockOrder::query()
  265. ->pluck('status')
  266. ->map(fn (string $status): string => StockOrder::STATUS_NAMES[$status] ?? $status),
  267. default => [],
  268. };
  269. return $this->filterValuesResponse($values);
  270. }
  271. private function filterValuesResponse(iterable $values): JsonResponse
  272. {
  273. $normalized = collect($values)
  274. ->map(static fn (mixed $value): string => $value === null || trim((string) $value) === ''
  275. ? '-пусто-'
  276. : (string) $value)
  277. ->unique()
  278. ->sort(static fn (string $left, string $right): int => strnatcasecmp($left, $right))
  279. ->values()
  280. ->all();
  281. return response()->json($normalized, 200, [], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
  282. }
  283. /**
  284. * Определяет реальный столбец БД по имени столбца из заголовка.
  285. * Приоритет: прямое совпадение в БД → COLUMN_MAP для конкретной таблицы.
  286. */
  287. public static function resolveDbColumn(string $table, string $dbTable, string $column): ?string
  288. {
  289. // 1. Прямое совпадение — столбец существует в БД
  290. if (Schema::hasColumn($dbTable, $column)) {
  291. return $column;
  292. }
  293. // 2. Явный маппинг для конкретной таблицы
  294. if (isset(self::COLUMN_MAP[$table][$column])) {
  295. return self::COLUMN_MAP[$table][$column];
  296. }
  297. return null;
  298. }
  299. }