StockController.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Http\Controllers;
  4. use App\Http\Requests\ReserveStockRequest;
  5. use App\Models\CommonCatalogItem;
  6. use App\Models\StockOrder;
  7. use App\Models\StockReservation;
  8. use App\Models\User;
  9. use App\Services\StockInventoryService;
  10. use DomainException;
  11. use Illuminate\Contracts\View\View;
  12. use Illuminate\Database\Eloquent\Builder;
  13. use Illuminate\Database\Query\Builder as QueryBuilder;
  14. use Illuminate\Http\RedirectResponse;
  15. use Illuminate\Http\Request;
  16. use Illuminate\Support\Facades\DB;
  17. class StockController extends Controller
  18. {
  19. private const SORT_COLUMNS = [
  20. 'article',
  21. 'calculator_name',
  22. 'kind',
  23. 'unit',
  24. 'physical_quantity',
  25. 'reserved_quantity',
  26. 'available_stock_quantity',
  27. 'ordered_stock_quantity',
  28. 'latest_order_note',
  29. ];
  30. public function __construct(private readonly StockInventoryService $inventoryService) {}
  31. public function index(Request $request): View
  32. {
  33. $search = trim((string) $request->query('search', ''));
  34. $kind = trim((string) $request->query('kind', ''));
  35. $status = trim((string) $request->query('status', ''));
  36. $perPage = $this->perPage($request);
  37. $sortBy = in_array($request->string('sortBy')->toString(), self::SORT_COLUMNS, true)
  38. ? $request->string('sortBy')->toString()
  39. : 'article';
  40. $orderBy = $request->query('order') === 'desc' ? 'desc' : 'asc';
  41. session(['gp_stock_availability' => $request->query()]);
  42. $physicalQuantity = $this->stockOrderQuantitySubquery(
  43. StockOrder::STATUS_IN_STOCK,
  44. 'available_quantity',
  45. );
  46. $reservedQuantity = $this->reservedQuantitySubquery();
  47. $orderedQuantity = $this->stockOrderQuantitySubquery(
  48. StockOrder::STATUS_ORDERED,
  49. 'ordered_quantity',
  50. );
  51. $availableQuantity = $this->availableQuantitySubquery();
  52. $query = CommonCatalogItem::query()
  53. ->select('common_catalog_items.*')
  54. ->whereHas('stockOrders')
  55. ->with('imageFile')
  56. ->addSelect([
  57. 'physical_quantity' => $physicalQuantity,
  58. 'reserved_quantity' => $reservedQuantity,
  59. 'available_stock_quantity' => $availableQuantity,
  60. 'ordered_stock_quantity' => $orderedQuantity,
  61. 'latest_order_note' => $this->latestOrderNoteSubquery(),
  62. ])
  63. ->when($search !== '', function (Builder $query) use ($search): void {
  64. $query->where(function (Builder $query) use ($search): void {
  65. $query->where('article', 'like', "%{$search}%")
  66. ->orWhere('calculator_name', 'like', "%{$search}%")
  67. ->orWhere('kind', 'like', "%{$search}%");
  68. });
  69. })
  70. ->when($kind !== '', fn (Builder $query) => $query->where('kind', $kind))
  71. ->when(array_key_exists($status, StockOrder::STATUS_NAMES), fn (Builder $query) => $query
  72. ->whereHas('stockOrders', fn (Builder $query) => $query->where('status', $status)));
  73. $this->applyColumnFilters($query, $request);
  74. $query->orderBy($sortBy, $orderBy)->orderBy('id');
  75. $items = $query
  76. ->paginate($perPage)
  77. ->withQueryString();
  78. return view('stock.index', [
  79. 'active' => 'stock',
  80. 'items' => $items,
  81. 'search' => $search,
  82. 'kind' => $kind,
  83. 'status' => $status,
  84. 'kinds' => CommonCatalogItem::query()
  85. ->whereHas('stockOrders')
  86. ->whereNotNull('kind')
  87. ->where('kind', '<>', '')
  88. ->distinct()
  89. ->orderBy('kind')
  90. ->pluck('kind'),
  91. 'statuses' => StockOrder::STATUS_NAMES,
  92. 'per_page' => $perPage,
  93. 'sortBy' => $sortBy,
  94. 'orderBy' => $orderBy,
  95. 'tableId' => 'stock_availability',
  96. 'columnFilters' => [
  97. 'article' => [],
  98. 'calculator_name' => [],
  99. 'kind' => [],
  100. 'unit' => [],
  101. 'physical_quantity' => ['type' => 'ranges'],
  102. 'reserved_quantity' => ['type' => 'ranges'],
  103. 'available_stock_quantity' => ['type' => 'ranges'],
  104. 'ordered_stock_quantity' => ['type' => 'ranges'],
  105. 'latest_order_note' => [],
  106. ],
  107. ]);
  108. }
  109. public function show(CommonCatalogItem $item): View
  110. {
  111. $item->load([
  112. 'imageFile',
  113. 'stockOrders' => fn ($query) => $query
  114. ->withSum(['reservations as active_reservations_sum_quantity' => fn ($query) => $query->active()], 'quantity')
  115. ->orderByDesc('created_at'),
  116. 'stockReservations' => fn ($query) => $query
  117. ->with(['manager', 'order'])
  118. ->orderByDesc('created_at'),
  119. 'stockMovements' => fn ($query) => $query
  120. ->with(['user', 'order'])
  121. ->orderByDesc('created_at'),
  122. ]);
  123. return view('stock.show', [
  124. 'active' => 'stock',
  125. 'item' => $item,
  126. 'summary' => $this->inventoryService->summary($item),
  127. 'managers' => User::query()->orderBy('name')->get(),
  128. ]);
  129. }
  130. public function reserve(ReserveStockRequest $request, CommonCatalogItem $item): RedirectResponse
  131. {
  132. /** @var User $actor */
  133. $actor = $request->user();
  134. $manager = User::query()->findOrFail((int) $request->validated('manager_id'));
  135. try {
  136. $this->inventoryService->reserve(
  137. $item,
  138. $manager,
  139. (int) $request->validated('quantity'),
  140. $request->validated('note'),
  141. $actor,
  142. );
  143. return back()->with('success', 'Позиция забронирована.');
  144. } catch (DomainException $exception) {
  145. return back()->withErrors(['quantity' => $exception->getMessage()])->withInput();
  146. }
  147. }
  148. public function issue(Request $request, StockReservation $reservation): RedirectResponse
  149. {
  150. /** @var User $actor */
  151. $actor = $request->user();
  152. try {
  153. $this->inventoryService->issue($reservation, $actor, $request->string('note')->toString());
  154. return back()->with('success', 'Бронь списана.');
  155. } catch (DomainException $exception) {
  156. return back()->with('error', $exception->getMessage());
  157. }
  158. }
  159. public function cancel(Request $request, StockReservation $reservation): RedirectResponse
  160. {
  161. /** @var User $actor */
  162. $actor = $request->user();
  163. try {
  164. $this->inventoryService->cancel($reservation, $actor, $request->string('note')->toString());
  165. return back()->with('success', 'Бронь отменена.');
  166. } catch (DomainException $exception) {
  167. return back()->with('error', $exception->getMessage());
  168. }
  169. }
  170. private function perPage(Request $request): int
  171. {
  172. $allowed = [50, 100, 200, 500, 2000];
  173. $perPage = (int) $request->query('per_page', session('per_page', 50));
  174. $perPage = in_array($perPage, $allowed, true) ? $perPage : 50;
  175. session(['per_page' => $perPage]);
  176. return $perPage;
  177. }
  178. private function applyColumnFilters(Builder $query, Request $request): void
  179. {
  180. foreach (['article', 'calculator_name', 'kind', 'unit'] as $column) {
  181. $values = $this->filterValues($request, $column);
  182. if ($values !== []) {
  183. $query->where(function (Builder $query) use ($column, $values): void {
  184. $this->applyValuesCondition($query, $column, $values);
  185. });
  186. }
  187. }
  188. $latestNotes = $this->filterValues($request, 'latest_order_note');
  189. if ($latestNotes !== []) {
  190. $nonEmpty = array_values(array_diff($latestNotes, ['-пусто-']));
  191. $includeEmpty = in_array('-пусто-', $latestNotes, true);
  192. $query->where(function (Builder $query) use ($nonEmpty, $includeEmpty): void {
  193. if ($nonEmpty !== []) {
  194. $query->whereHas('latestStockOrder', fn (Builder $query) => $query->whereIn('note', $nonEmpty));
  195. }
  196. if ($includeEmpty) {
  197. $method = $nonEmpty === [] ? 'where' : 'orWhere';
  198. $query->{$method}(function (Builder $query): void {
  199. $query->whereDoesntHave('latestStockOrder')
  200. ->orWhereHas('latestStockOrder', function (Builder $query): void {
  201. $query->whereNull('note')->orWhereRaw("TRIM(note) = ''");
  202. });
  203. });
  204. }
  205. });
  206. }
  207. $this->applySubqueryRange($query, $request, 'physical_quantity', $this->stockOrderQuantitySubquery(
  208. StockOrder::STATUS_IN_STOCK,
  209. 'available_quantity',
  210. ));
  211. $this->applySubqueryRange($query, $request, 'reserved_quantity', $this->reservedQuantitySubquery());
  212. $this->applySubqueryRange($query, $request, 'available_stock_quantity', $this->availableQuantitySubquery());
  213. $this->applySubqueryRange($query, $request, 'ordered_stock_quantity', $this->stockOrderQuantitySubquery(
  214. StockOrder::STATUS_ORDERED,
  215. 'ordered_quantity',
  216. ));
  217. }
  218. /** @return array<int, string> */
  219. private function filterValues(Request $request, string $column): array
  220. {
  221. $value = $request->input("filters.{$column}");
  222. return is_string($value) && $value !== '' ? explode('||', $value) : [];
  223. }
  224. /** @param array<int, string> $values */
  225. private function applyValuesCondition(Builder $query, string $column, array $values): void
  226. {
  227. $nonEmpty = array_values(array_diff($values, ['-пусто-']));
  228. if ($nonEmpty !== []) {
  229. $query->orWhereIn($column, $nonEmpty);
  230. }
  231. if (in_array('-пусто-', $values, true)) {
  232. $this->applyEmptyFilterCondition($query, $column);
  233. }
  234. }
  235. private function applySubqueryRange(
  236. Builder $query,
  237. Request $request,
  238. string $column,
  239. QueryBuilder $subquery,
  240. ): void {
  241. $from = $request->input("filters.{$column}_from");
  242. $to = $request->input("filters.{$column}_to");
  243. if (is_numeric($from)) {
  244. $query->where(clone $subquery, '>=', (float) $from);
  245. }
  246. if (is_numeric($to)) {
  247. $query->where(clone $subquery, '<=', (float) $to);
  248. }
  249. }
  250. private function stockOrderQuantitySubquery(string $status, string $column): QueryBuilder
  251. {
  252. return DB::table('stock_orders')
  253. ->selectRaw("COALESCE(SUM({$column}), 0)")
  254. ->whereColumn('stock_orders.common_catalog_item_id', 'common_catalog_items.id')
  255. ->where('stock_orders.status', $status)
  256. ->whereNull('stock_orders.deleted_at');
  257. }
  258. private function reservedQuantitySubquery(): QueryBuilder
  259. {
  260. return DB::table('stock_reservations')
  261. ->selectRaw('COALESCE(SUM(quantity), 0)')
  262. ->whereColumn('stock_reservations.common_catalog_item_id', 'common_catalog_items.id')
  263. ->where('stock_reservations.status', StockReservation::STATUS_ACTIVE);
  264. }
  265. private function availableQuantitySubquery(): QueryBuilder
  266. {
  267. $physical = $this->stockOrderQuantitySubquery(StockOrder::STATUS_IN_STOCK, 'available_quantity');
  268. $reserved = $this->reservedQuantitySubquery();
  269. return DB::query()->selectRaw(
  270. 'GREATEST(0, ('.$physical->toSql().') - ('.$reserved->toSql().'))',
  271. array_merge($physical->getBindings(), $reserved->getBindings()),
  272. );
  273. }
  274. private function latestOrderNoteSubquery(): QueryBuilder
  275. {
  276. return DB::table('stock_orders')
  277. ->select('note')
  278. ->whereColumn('stock_orders.common_catalog_item_id', 'common_catalog_items.id')
  279. ->whereNull('stock_orders.deleted_at')
  280. ->orderByDesc('stock_orders.id')
  281. ->limit(1);
  282. }
  283. }