ProductionOrderController.php 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Http\Controllers;
  4. use App\Enums\ProductionOrderExecutionType;
  5. use App\Enums\ProductionOrderNotificationEvent;
  6. use App\Enums\ProductionOrderStatus;
  7. use App\Http\Requests\SaveProductionOrderRequest;
  8. use App\Jobs\Export\ExportProductionOrdersJob;
  9. use App\Jobs\Import\ImportJob;
  10. use App\Models\CommonCatalogItem;
  11. use App\Models\Import;
  12. use App\Models\ProductionOrder;
  13. use App\Models\Role;
  14. use App\Models\User;
  15. use App\Services\Access\FieldAccessService;
  16. use App\Services\NotificationService;
  17. use App\Services\ProductionOrderService;
  18. use Illuminate\Contracts\View\View;
  19. use Illuminate\Database\Eloquent\Builder;
  20. use Illuminate\Http\JsonResponse;
  21. use Illuminate\Http\RedirectResponse;
  22. use Illuminate\Http\Request;
  23. use Illuminate\Http\UploadedFile;
  24. use Illuminate\Support\Arr;
  25. use Illuminate\Support\Facades\Storage;
  26. use Illuminate\Support\Str;
  27. class ProductionOrderController extends Controller
  28. {
  29. private const INDEX_HEADER = [
  30. 'id' => 'ID',
  31. 'order_number' => 'Номер заказа',
  32. 'customer_name' => 'Заказчик',
  33. 'object_address' => 'Адрес объекта',
  34. 'invoice_number' => '№ счёта',
  35. 'payment_date' => 'Дата оплаты',
  36. 'supply_working_days' => 'Срок поставки',
  37. 'contract_shipment_date' => 'Дата отгрузки по договору',
  38. 'application_shipment_date' => 'Дата отгрузки по заявке',
  39. 'status_name' => 'Статус',
  40. 'delivery_dates' => 'Дата доставки',
  41. 'installation_dates' => 'Дата монтажа',
  42. 'manager_id' => 'Менеджер',
  43. 'note' => 'Примечание',
  44. ];
  45. private const SEARCH_FIELDS = [
  46. 'order_number',
  47. 'customer_name',
  48. 'object_address',
  49. 'invoice_number',
  50. 'contract_number',
  51. 'note',
  52. ];
  53. private const FIELD_MAP = [
  54. 'status_name' => 'status',
  55. 'execution_type_name' => 'execution_type',
  56. 'items_count' => 'id',
  57. 'delivery_dates' => 'delivery_date',
  58. 'installation_dates' => 'installation_date',
  59. ];
  60. protected array $data = [
  61. 'active' => 'schedule_orders',
  62. 'title' => 'График заказов',
  63. 'id' => 'schedule_orders',
  64. 'header' => self::INDEX_HEADER,
  65. 'searchFields' => self::SEARCH_FIELDS,
  66. ];
  67. public function index(Request $request): View
  68. {
  69. $this->data['header'] = self::INDEX_HEADER;
  70. $this->data['searchFields'] = self::SEARCH_FIELDS;
  71. unset($this->data['dates'], $this->data['ranges'], $this->data['filters']);
  72. session(['gp_schedule_orders' => $request->query()]);
  73. $nav = $this->startNavigationContext($request);
  74. $model = new ProductionOrder;
  75. $this->data['header'] = array_filter(
  76. $this->data['header'],
  77. fn (string $field): bool => $request->user()->canViewField(
  78. 'schedule-orders',
  79. self::FIELD_MAP[$field] ?? $field,
  80. ),
  81. ARRAY_FILTER_USE_KEY,
  82. );
  83. $this->data['searchFields'] = array_values(array_filter(
  84. $this->data['searchFields'],
  85. fn (string $field): bool => $request->user()->canViewField('schedule-orders', $field),
  86. ));
  87. $this->createDateFilters(
  88. $model,
  89. ...array_values(array_intersect([
  90. 'payment_date',
  91. 'contract_shipment_date',
  92. 'application_shipment_date',
  93. ], array_keys($this->data['header']))),
  94. );
  95. if (isset($this->data['header']['supply_working_days'])) {
  96. $this->createRangeFilters($model, 'supply_working_days');
  97. }
  98. foreach (['status_name', 'execution_type_name'] as $field) {
  99. if (isset($this->data['header'][$field])) {
  100. $this->data['filters'][$field] = [
  101. 'title' => $this->data['header'][$field],
  102. 'values' => [],
  103. ];
  104. }
  105. }
  106. $query = ProductionOrder::query()
  107. ->with([
  108. 'manager',
  109. 'deliveries:id,production_order_id,delivery_date',
  110. 'installations:id,production_order_id,installation_date',
  111. ])
  112. ->withCount('items')
  113. ->withMin('deliveries', 'delivery_date')
  114. ->withMin('installations', 'installation_date');
  115. $this->acceptOrderFilters($query, $request);
  116. $this->acceptSearch($query, $request);
  117. $requestedSort = $request->string('sortBy')->toString();
  118. $sortMap = [
  119. 'status_name' => 'status',
  120. 'execution_type_name' => 'execution_type',
  121. 'items_count' => 'items_count',
  122. 'delivery_dates' => 'deliveries_min_delivery_date',
  123. 'installation_dates' => 'installations_min_installation_date',
  124. ];
  125. if (isset($sortMap[$requestedSort])) {
  126. $request->merge(['sortBy' => $sortMap[$requestedSort]]);
  127. }
  128. $this->setSortAndOrderBy($model, $request);
  129. if ($requestedSort !== '' && isset($sortMap[$requestedSort])) {
  130. $this->data['sortBy'] = $requestedSort;
  131. $query->orderBy($sortMap[$requestedSort], $this->data['orderBy']);
  132. $query->orderBy('id', $this->data['orderBy']);
  133. } else {
  134. $this->applyStableSorting($query);
  135. }
  136. $this->data['orders'] = $query->paginate($this->data['per_page'])->withQueryString();
  137. $this->data['nav'] = $nav;
  138. return view('production_orders.index', $this->data);
  139. }
  140. public function create(Request $request, FieldAccessService $fieldAccess): View
  141. {
  142. return $this->orderView($request, $fieldAccess);
  143. }
  144. public function export(Request $request): RedirectResponse
  145. {
  146. $validated = $request->validate([
  147. 'filters' => ['nullable', 'array'],
  148. 'filters.*' => ['nullable', 'string', 'max:1000'],
  149. 's' => ['nullable', 'string', 'max:500'],
  150. ]);
  151. $request->merge([
  152. 'filters' => Arr::only($validated['filters'] ?? [], [
  153. 'id',
  154. 'order_number',
  155. 'customer_name',
  156. 'object_address',
  157. 'invoice_number',
  158. 'payment_date',
  159. 'payment_date_from',
  160. 'payment_date_to',
  161. 'supply_working_days',
  162. 'supply_working_days_from',
  163. 'supply_working_days_to',
  164. 'contract_shipment_date',
  165. 'contract_shipment_date_from',
  166. 'contract_shipment_date_to',
  167. 'application_shipment_date',
  168. 'application_shipment_date_from',
  169. 'application_shipment_date_to',
  170. 'status_name',
  171. 'execution_type_name',
  172. 'manager_id',
  173. 'delivery_dates',
  174. 'installation_dates',
  175. 'note',
  176. ]),
  177. 's' => $validated['s'] ?? null,
  178. ]);
  179. $this->data['dates'] = array_fill_keys([
  180. 'payment_date',
  181. 'contract_shipment_date',
  182. 'application_shipment_date',
  183. ], true);
  184. $this->data['ranges'] = ['supply_working_days' => true];
  185. $query = ProductionOrder::query();
  186. $this->acceptOrderFilters($query, $request);
  187. $this->acceptSearch($query, $request);
  188. $orderIds = $query->orderByDesc('created_at')->orderByDesc('id')->pluck('id')->all();
  189. ExportProductionOrdersJob::dispatch($orderIds, (int) $request->user()->id);
  190. return redirect()
  191. ->route('schedule.orders', session('gp_schedule_orders'))
  192. ->with('success', 'Задача экспорта графика заказов создана.');
  193. }
  194. public function import(Request $request): RedirectResponse
  195. {
  196. $validated = $request->validate([
  197. 'import_file' => ['required', 'file', 'mimes:xlsx', 'max:20480'],
  198. ]);
  199. /** @var UploadedFile $uploadedFile */
  200. $uploadedFile = $validated['import_file'];
  201. $path = 'production_orders/'.Str::uuid().'.xlsx';
  202. Storage::disk('upload')->put($path, $uploadedFile->getContent());
  203. $import = Import::query()->create([
  204. 'type' => 'production_orders',
  205. 'year' => null,
  206. 'filename' => $path,
  207. 'status' => 'new',
  208. 'user_id' => $request->user()->getKey(),
  209. 'file_path' => $path,
  210. 'original_filename' => $uploadedFile->getClientOriginalName(),
  211. ]);
  212. ImportJob::dispatch($import, (int) $request->user()->getKey());
  213. return redirect()
  214. ->route('schedule.orders', session('gp_schedule_orders'))
  215. ->with('success', 'Задача импорта графика заказов создана.');
  216. }
  217. public function show(
  218. Request $request,
  219. ProductionOrder $productionOrder,
  220. FieldAccessService $fieldAccess,
  221. ): View {
  222. return $this->orderView(
  223. $request,
  224. $fieldAccess,
  225. $productionOrder->load([
  226. 'manager',
  227. 'items.catalogItem.imageFile',
  228. 'items.passportFile',
  229. 'deliveries.driver',
  230. 'installations.brigadier',
  231. 'activities.user',
  232. 'documents',
  233. 'photos',
  234. 'chatMessages.user',
  235. 'chatMessages.targetUser',
  236. 'chatMessages.notifiedUsers',
  237. 'chatMessages.files',
  238. ]),
  239. );
  240. }
  241. public function store(
  242. SaveProductionOrderRequest $request,
  243. FieldAccessService $fieldAccess,
  244. ProductionOrderService $service,
  245. NotificationService $notificationService,
  246. ): RedirectResponse {
  247. $validated = $request->validated();
  248. $items = $validated['items'];
  249. $fields = array_keys(config('access.schedule-orders.fields', []));
  250. $payload = $fieldAccess->filterValidatedPayload(
  251. $request->user(),
  252. 'schedule-orders',
  253. Arr::only($validated, $fields),
  254. );
  255. $payload['items'] = $items;
  256. $order = $service->create($payload, $request->user());
  257. $notificationService->notifyProductionOrderEvent(
  258. $order,
  259. ProductionOrderNotificationEvent::Created,
  260. $request->user(),
  261. );
  262. return redirect()->route('schedule.orders.show', $this->withNav(
  263. ['productionOrder' => $order],
  264. $this->resolveNavToken($request),
  265. ))->with('success', 'Заказ создан.');
  266. }
  267. public function update(
  268. SaveProductionOrderRequest $request,
  269. ProductionOrder $productionOrder,
  270. FieldAccessService $fieldAccess,
  271. ProductionOrderService $service,
  272. NotificationService $notificationService,
  273. ): RedirectResponse {
  274. $validated = $request->validated();
  275. $fields = array_keys(config('access.schedule-orders.fields', []));
  276. $payload = $fieldAccess->filterValidatedPayload(
  277. $request->user(),
  278. 'schedule-orders',
  279. Arr::only($validated, $fields),
  280. );
  281. $canManageItems = $request->user()->hasPermission('schedule-orders.items.manage');
  282. $editableItemFields = array_values(array_filter(
  283. ['factory_number', 'manufacture_date'],
  284. fn (string $field): bool => $request->user()->canUpdateField('schedule-orders', $field),
  285. ));
  286. if (($canManageItems || $editableItemFields !== []) && isset($validated['items'])) {
  287. $payload['items'] = $validated['items'];
  288. }
  289. $previousStatus = $productionOrder->status;
  290. $updatedOrder = $service->update(
  291. $productionOrder,
  292. $payload,
  293. $request->user(),
  294. $canManageItems,
  295. $editableItemFields,
  296. );
  297. if ($updatedOrder->status !== $previousStatus) {
  298. $notificationService->notifyProductionOrderEvent(
  299. $updatedOrder,
  300. ProductionOrderNotificationEvent::StatusChanged,
  301. $request->user(),
  302. );
  303. }
  304. return redirect()->route('schedule.orders.show', $this->withNav(
  305. ['productionOrder' => $productionOrder],
  306. $this->resolveNavToken($request),
  307. ))->with('success', 'Заказ обновлён.');
  308. }
  309. public function destroy(Request $request, ProductionOrder $productionOrder): RedirectResponse
  310. {
  311. $productionOrder->delete();
  312. return redirect()
  313. ->route('schedule.orders', session('gp_schedule_orders'))
  314. ->with('success', 'Заказ удалён.');
  315. }
  316. public function searchCatalogItems(Request $request): JsonResponse
  317. {
  318. $validated = $request->validate([
  319. 'q' => ['nullable', 'string', 'max:200'],
  320. ]);
  321. $search = trim((string) ($validated['q'] ?? ''));
  322. if (mb_strlen($search) < 2) {
  323. return response()->json([]);
  324. }
  325. $items = CommonCatalogItem::query()
  326. ->with('imageFile')
  327. ->where(function ($query) use ($search): void {
  328. $query
  329. ->where('article', 'like', "%{$search}%")
  330. ->orWhere('calculator_name', 'like', "%{$search}%")
  331. ->orWhere('print_name', 'like', "%{$search}%");
  332. })
  333. ->orderBy('article')
  334. ->limit(50)
  335. ->get(['id', 'image_file_id', 'article', 'calculator_name', 'print_name'])
  336. ->map(static fn (CommonCatalogItem $item): array => [
  337. 'id' => $item->id,
  338. 'label' => $item->article.' — '.($item->calculator_name ?: $item->print_name ?: 'Без наименования'),
  339. 'image' => $item->imageFile?->thumbnail_link,
  340. 'image_full' => $item->imageFile?->link,
  341. ])
  342. ->values();
  343. return response()->json($items);
  344. }
  345. private function orderView(
  346. Request $request,
  347. FieldAccessService $fieldAccess,
  348. ?ProductionOrder $order = null,
  349. ): View {
  350. $nav = $this->resolveNavToken($request);
  351. $this->rememberNavigation($request, $nav);
  352. $fields = array_keys(config('access.schedule-orders.fields', []));
  353. $catalogItemIds = collect($order?->items ?? [])
  354. ->pluck('common_catalog_item_id')
  355. ->merge(collect($request->old('items', []))->pluck('common_catalog_item_id'))
  356. ->filter()
  357. ->unique()
  358. ->values();
  359. $chatUsers = User::query()->orderBy('name')->get(['id', 'name', 'role']);
  360. $responsibleUserIds = User::query()
  361. ->withPermission('schedule-orders.create')
  362. ->pluck('id')
  363. ->map(static fn ($id): int => (int) $id)
  364. ->merge([$order?->manager_id])
  365. ->merge($order?->deliveries->pluck('driver_id') ?? [])
  366. ->merge($order?->installations->pluck('brigadier_id') ?? [])
  367. ->filter()
  368. ->unique()
  369. ->values()
  370. ->all();
  371. return view('production_orders.edit', [
  372. 'active' => 'schedule_orders',
  373. 'title' => $order ? 'Заказ '.$order->order_number : 'Новый заказ',
  374. 'order' => $order,
  375. 'nav' => $nav,
  376. 'back_url' => $this->navigationBackUrl(
  377. $request,
  378. $nav,
  379. route('schedule.orders', session('gp_schedule_orders')),
  380. ),
  381. 'readableFields' => array_fill_keys(
  382. $fieldAccess->filterReadableFields($request->user(), 'schedule-orders', $fields),
  383. true,
  384. ),
  385. 'writableFields' => collect($fields)->mapWithKeys(fn (string $field): array => [
  386. $field => $request->user()->canUpdateField('schedule-orders', $field),
  387. ])->all(),
  388. 'canManageItems' => $request->user()->hasPermission('schedule-orders.items.manage'),
  389. 'canManageDeliveries' => $request->user()->hasPermission('schedule-orders.deliveries.manage'),
  390. 'canManageInstallations' => $request->user()->hasPermission('schedule-orders.installations.manage'),
  391. 'canManageDocuments' => $request->user()->hasPermission('schedule-orders.documents.manage'),
  392. 'canManagePhotos' => $request->user()->hasPermission('schedule-orders.photos.manage'),
  393. 'canManagePassports' => $request->user()->hasPermission('schedule-orders.passports.manage'),
  394. 'canCreateChatMessages' => $request->user()->hasPermission('schedule-orders.chat.create'),
  395. 'canDeleteChatMessages' => $request->user()->hasPermission('schedule-orders.chat.delete'),
  396. 'canUpdateFactoryNumber' => $request->user()->canUpdateField('schedule-orders', 'factory_number'),
  397. 'canUpdateManufactureDate' => $request->user()->canUpdateField('schedule-orders', 'manufacture_date'),
  398. 'managers' => User::query()
  399. ->where('role', Role::MANAGER)
  400. ->orderBy('name')
  401. ->pluck('name', 'id'),
  402. 'drivers' => User::query()
  403. ->whereIn('role', [Role::DRIVER, Role::BRIGADIER])
  404. ->orderBy('name')
  405. ->pluck('name', 'id'),
  406. 'brigadiers' => User::query()
  407. ->where('role', Role::BRIGADIER)
  408. ->orderBy('name')
  409. ->pluck('name', 'id'),
  410. 'catalogItems' => CommonCatalogItem::query()
  411. ->with('imageFile')
  412. ->whereKey($catalogItemIds)
  413. ->get(['id', 'image_file_id', 'article', 'calculator_name', 'print_name'])
  414. ->keyBy('id'),
  415. 'statuses' => ProductionOrderStatus::options(),
  416. 'executionTypes' => ProductionOrderExecutionType::options(),
  417. 'chatUsers' => $chatUsers->map(static fn (User $user): array => [
  418. 'id' => $user->id,
  419. 'name' => $user->name,
  420. 'role' => $user->role,
  421. ])->keyBy('id'),
  422. 'chatResponsibleUserIds' => $responsibleUserIds,
  423. ]);
  424. }
  425. private function acceptOrderFilters(Builder $query, Request $request): void
  426. {
  427. $filters = (array) $request->input('filters', []);
  428. $managerFilter = trim((string) ($filters['manager_id'] ?? ''));
  429. if ($managerFilter !== '') {
  430. $values = explode('||', $managerFilter);
  431. $managerIds = array_values(array_filter(
  432. $values,
  433. static fn (string $value): bool => ctype_digit($value),
  434. ));
  435. $managerNames = array_values(array_filter(
  436. $values,
  437. static fn (string $value): bool => $value !== '-пусто-' && ! ctype_digit($value),
  438. ));
  439. $includeEmpty = in_array('-пусто-', $values, true);
  440. $query->where(function (Builder $managerQuery) use ($managerIds, $managerNames, $includeEmpty): void {
  441. if ($managerIds !== []) {
  442. $managerQuery->whereIn('manager_id', $managerIds);
  443. }
  444. if ($managerNames !== []) {
  445. $method = $managerIds !== [] ? 'orWhereHas' : 'whereHas';
  446. $managerQuery->{$method}(
  447. 'manager',
  448. static fn (Builder $userQuery): Builder => $userQuery->whereIn('name', $managerNames),
  449. );
  450. }
  451. if ($includeEmpty) {
  452. ($managerIds !== [] || $managerNames !== [])
  453. ? $managerQuery->orWhereNull('manager_id')
  454. : $managerQuery->whereNull('manager_id');
  455. }
  456. });
  457. }
  458. $relationFilters = [
  459. 'delivery_dates' => ['deliveries', 'delivery_date'],
  460. 'installation_dates' => ['installations', 'installation_date'],
  461. ];
  462. foreach ($relationFilters as $filterName => [$relation, $column]) {
  463. $value = trim((string) ($filters[$filterName] ?? ''));
  464. if ($value === '') {
  465. continue;
  466. }
  467. $values = explode('||', $value);
  468. $dates = array_values(array_filter(
  469. $values,
  470. static fn (string $date): bool => $date !== '-пусто-',
  471. ));
  472. $includeEmpty = in_array('-пусто-', $values, true);
  473. $query->where(function ($relationQuery) use ($relation, $column, $dates, $includeEmpty): void {
  474. if ($dates !== []) {
  475. $relationQuery->whereHas(
  476. $relation,
  477. static fn ($dateQuery) => $dateQuery->whereIn($column, $dates),
  478. );
  479. }
  480. if ($includeEmpty) {
  481. $dates !== []
  482. ? $relationQuery->orWhereDoesntHave($relation)
  483. : $relationQuery->whereDoesntHave($relation);
  484. }
  485. });
  486. }
  487. $request->merge(['filters' => Arr::except($filters, [
  488. 'manager_id',
  489. ...array_keys($relationFilters),
  490. ])]);
  491. $this->acceptFilters($query, $request);
  492. $request->merge(['filters' => $filters]);
  493. }
  494. }