ImportProductionOrdersService.php 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Services\Import;
  4. use App\Enums\ProductionOrderExecutionType;
  5. use App\Enums\ProductionOrderSource;
  6. use App\Enums\ProductionOrderStatus;
  7. use App\Models\CommonCatalogItem;
  8. use App\Models\Import;
  9. use App\Models\ProductionOrder;
  10. use App\Models\ProductionOrderItem;
  11. use App\Models\Role;
  12. use App\Models\User;
  13. use App\Services\Export\ExportProductionOrdersService;
  14. use App\Services\ProductionOrderService;
  15. use DateTimeImmutable;
  16. use Illuminate\Support\Facades\DB;
  17. use Illuminate\Support\Facades\Storage;
  18. use Illuminate\Support\Facades\Validator;
  19. use Illuminate\Validation\Rule;
  20. use PhpOffice\PhpSpreadsheet\Cell\Cell;
  21. use PhpOffice\PhpSpreadsheet\Reader\Xlsx;
  22. use PhpOffice\PhpSpreadsheet\Shared\Date as SpreadsheetDate;
  23. use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
  24. use RuntimeException;
  25. use Throwable;
  26. final class ImportProductionOrdersService
  27. {
  28. public function __construct(
  29. private readonly Import $import,
  30. private readonly int $userId,
  31. private readonly ProductionOrderService $orderService,
  32. ) {}
  33. public function handle(): bool
  34. {
  35. $spreadsheet = null;
  36. try {
  37. $actor = User::query()->findOrFail($this->userId);
  38. $spreadsheet = (new Xlsx)->load(
  39. Storage::disk('upload')->path((string) $this->import->filename),
  40. );
  41. $ordersSheet = $spreadsheet->getSheetByName(ExportProductionOrdersService::ORDERS_SHEET);
  42. $itemsSheet = $spreadsheet->getSheetByName(ExportProductionOrdersService::ITEMS_SHEET);
  43. if ($ordersSheet === null || $itemsSheet === null) {
  44. throw new RuntimeException('В файле должны быть листы «График заказов» и «МАФ».');
  45. }
  46. $this->assertHeaders($ordersSheet, $itemsSheet);
  47. $orders = $this->readOrders($ordersSheet);
  48. $this->readItems($itemsSheet, $orders);
  49. $created = 0;
  50. $updated = 0;
  51. DB::transaction(function () use ($orders, $actor, &$created, &$updated): void {
  52. foreach ($orders as $orderData) {
  53. $payload = $orderData['data'];
  54. $payload['items'] = $orderData['items'];
  55. if ($orderData['order'] instanceof ProductionOrder) {
  56. $this->orderService->update(
  57. $orderData['order'],
  58. $payload,
  59. $actor,
  60. true,
  61. );
  62. $updated++;
  63. } else {
  64. $this->orderService->create(
  65. $payload,
  66. $actor,
  67. ProductionOrderSource::Spreadsheet,
  68. );
  69. $created++;
  70. }
  71. }
  72. });
  73. $this->import->log("Создано заказов: {$created}; обновлено заказов: {$updated}.");
  74. $this->import->update(['status' => 'DONE']);
  75. return true;
  76. } catch (Throwable $exception) {
  77. $this->import->log($exception->getMessage(), 'ERROR');
  78. $this->import->update(['status' => 'ERROR']);
  79. throw $exception;
  80. } finally {
  81. $spreadsheet?->disconnectWorksheets();
  82. }
  83. }
  84. private function assertHeaders(Worksheet $ordersSheet, Worksheet $itemsSheet): void
  85. {
  86. $orderHeaders = $ordersSheet->rangeToArray('A1:R1', null, true, true, false)[0];
  87. $itemHeaders = $itemsSheet->rangeToArray('A1:H1', null, true, true, false)[0];
  88. if ($this->normalizedHeaders($orderHeaders) !== $this->normalizedHeaders(ExportProductionOrdersService::HEADERS)) {
  89. throw new RuntimeException('Некорректные заголовки листа «График заказов». Используйте экспорт графика заказов.');
  90. }
  91. if ($this->normalizedHeaders($itemHeaders) !== $this->normalizedHeaders(ExportProductionOrdersService::ITEM_HEADERS)) {
  92. throw new RuntimeException('Некорректные заголовки листа «МАФ». Используйте экспорт графика заказов.');
  93. }
  94. }
  95. /**
  96. * @return array<int, array{
  97. * order: ProductionOrder|null,
  98. * data: array<string, mixed>,
  99. * items: list<array<string, mixed>>
  100. * }>
  101. */
  102. private function readOrders(Worksheet $sheet): array
  103. {
  104. $orders = [];
  105. $seenIds = [];
  106. $seenNumbers = [];
  107. for ($row = 2; $row <= $sheet->getHighestDataRow(); $row++) {
  108. if (! $this->rowHasValues($sheet, $row, 'A', 'R')) {
  109. continue;
  110. }
  111. $id = $this->integerValue($sheet->getCell("A{$row}"), 'ID заказа', 1, true);
  112. if ($id !== null && isset($seenIds[$id])) {
  113. throw new RuntimeException("Лист «График заказов», строка {$row}: ID {$id} указан повторно.");
  114. }
  115. $order = $id === null ? null : ProductionOrder::query()->find($id);
  116. if ($id !== null && $order === null) {
  117. throw new RuntimeException("Лист «График заказов», строка {$row}: заказ с ID {$id} не найден.");
  118. }
  119. $orderNumber = $this->requiredString($sheet->getCell("B{$row}"), 'Номер заказа');
  120. $orderYear = $this->integerValue($sheet->getCell("C{$row}"), 'Год', 2000, false, 2100);
  121. $numberKey = $this->normalize($orderNumber).'|'.$orderYear;
  122. if (isset($seenNumbers[$numberKey])) {
  123. throw new RuntimeException(
  124. "Лист «График заказов», строка {$row}: заказ {$orderNumber} за {$orderYear} год указан повторно.",
  125. );
  126. }
  127. $data = [
  128. 'order_number' => $orderNumber,
  129. 'order_year' => $orderYear,
  130. 'customer_name' => $this->requiredString($sheet->getCell("D{$row}"), 'Заказчик'),
  131. 'object_address' => $this->requiredString($sheet->getCell("E{$row}"), 'Адрес объекта'),
  132. 'invoice_number' => $this->requiredString($sheet->getCell("F{$row}"), 'Номер счёта'),
  133. 'invoice_date' => $this->dateValue($sheet->getCell("G{$row}"), 'Дата счёта', false),
  134. 'contract_number' => $this->nullableString($sheet->getCell("H{$row}")),
  135. 'contract_date' => $this->dateValue($sheet->getCell("I{$row}"), 'Дата договора', false),
  136. 'payment_date' => $this->dateValue($sheet->getCell("J{$row}"), 'Дата оплаты', true),
  137. 'supply_working_days' => $this->integerValue(
  138. $sheet->getCell("K{$row}"),
  139. 'Срок поставки',
  140. 0,
  141. false,
  142. 2000,
  143. ),
  144. 'application_shipment_date' => $this->dateValue(
  145. $sheet->getCell("M{$row}"),
  146. 'Дата отгрузки по заявке',
  147. false,
  148. ),
  149. 'status' => $this->statusValue($sheet->getCell("N{$row}")),
  150. 'execution_type' => $this->executionTypeValue($sheet->getCell("O{$row}")),
  151. 'manager_id' => $this->managerId($sheet->getCell("P{$row}")),
  152. 'note' => $this->nullableString($sheet->getCell("R{$row}")),
  153. ];
  154. $validator = Validator::make($data, [
  155. 'order_number' => [
  156. 'required',
  157. 'string',
  158. 'max:100',
  159. Rule::unique('production_orders', 'order_number')
  160. ->where('order_year', $orderYear)
  161. ->ignore($order?->id),
  162. ],
  163. 'order_year' => ['required', 'integer', 'min:2000', 'max:2100'],
  164. 'customer_name' => ['required', 'string', 'max:500'],
  165. 'object_address' => ['required', 'string', 'max:5000'],
  166. 'invoice_number' => ['required', 'string', 'max:100'],
  167. 'invoice_date' => ['nullable', 'date'],
  168. 'contract_number' => ['nullable', 'string', 'max:100'],
  169. 'contract_date' => ['nullable', 'date'],
  170. 'payment_date' => ['required', 'date'],
  171. 'supply_working_days' => ['required', 'integer', 'min:0', 'max:2000'],
  172. 'application_shipment_date' => ['nullable', 'date'],
  173. 'status' => ['required', Rule::enum(ProductionOrderStatus::class)],
  174. 'execution_type' => ['required', Rule::enum(ProductionOrderExecutionType::class)],
  175. 'manager_id' => [
  176. 'required',
  177. 'integer',
  178. Rule::exists('users', 'id')->where('role', Role::MANAGER),
  179. ],
  180. 'note' => ['nullable', 'string', 'max:10000'],
  181. ]);
  182. if ($validator->fails()) {
  183. throw new RuntimeException(
  184. "Лист «График заказов», строка {$row}: ".implode(' ', $validator->errors()->all()),
  185. );
  186. }
  187. $orders[$row] = [
  188. 'order' => $order,
  189. 'data' => $validator->validated(),
  190. 'items' => [],
  191. ];
  192. if ($id !== null) {
  193. $seenIds[$id] = true;
  194. }
  195. $seenNumbers[$numberKey] = true;
  196. }
  197. if ($orders === []) {
  198. throw new RuntimeException('Лист «График заказов» не содержит заказов для импорта.');
  199. }
  200. return $orders;
  201. }
  202. /**
  203. * @param array<int, array{
  204. * order: ProductionOrder|null,
  205. * data: array<string, mixed>,
  206. * items: list<array<string, mixed>>
  207. * }> $orders
  208. */
  209. private function readItems(Worksheet $sheet, array &$orders): void
  210. {
  211. $seenIds = [];
  212. for ($row = 2; $row <= $sheet->getHighestDataRow(); $row++) {
  213. if (! $this->rowHasValues($sheet, $row, 'A', 'H')) {
  214. continue;
  215. }
  216. $orderRow = $this->integerValue($sheet->getCell("A{$row}"), 'Строка заказа', 2);
  217. if (! isset($orders[$orderRow])) {
  218. throw new RuntimeException(
  219. "Лист «МАФ», строка {$row}: строка заказа {$orderRow} отсутствует на листе «График заказов».",
  220. );
  221. }
  222. $itemId = $this->integerValue($sheet->getCell("B{$row}"), 'ID МАФ', 1, true);
  223. $existingItem = null;
  224. if ($itemId !== null) {
  225. if (isset($seenIds[$itemId])) {
  226. throw new RuntimeException("Лист «МАФ», строка {$row}: ID МАФ {$itemId} указан повторно.");
  227. }
  228. $existingItem = ProductionOrderItem::query()->find($itemId);
  229. if ($existingItem === null) {
  230. throw new RuntimeException("Лист «МАФ», строка {$row}: МАФ с ID {$itemId} не найден.");
  231. }
  232. $targetOrder = $orders[$orderRow]['order'];
  233. if ($targetOrder === null || $existingItem->production_order_id !== $targetOrder->id) {
  234. throw new RuntimeException(
  235. "Лист «МАФ», строка {$row}: МАФ с ID {$itemId} не принадлежит указанному заказу.",
  236. );
  237. }
  238. $seenIds[$itemId] = true;
  239. }
  240. $catalogItemId = $this->integerValue(
  241. $sheet->getCell("C{$row}"),
  242. 'ID позиции каталога',
  243. 1,
  244. );
  245. if (! CommonCatalogItem::query()->whereKey($catalogItemId)->exists()) {
  246. throw new RuntimeException(
  247. "Лист «МАФ», строка {$row}: позиция общего каталога с ID {$catalogItemId} не найдена.",
  248. );
  249. }
  250. $itemData = [
  251. 'id' => $existingItem?->id,
  252. 'common_catalog_item_id' => $catalogItemId,
  253. 'order_item_number' => $this->requiredString(
  254. $sheet->getCell("F{$row}"),
  255. 'Номер заказа МАФ',
  256. ),
  257. 'factory_number' => $this->nullableString($sheet->getCell("G{$row}")),
  258. 'manufacture_date' => $this->dateValue(
  259. $sheet->getCell("H{$row}"),
  260. 'Дата производства',
  261. false,
  262. ),
  263. ];
  264. $validator = Validator::make($itemData, [
  265. 'id' => ['nullable', 'integer'],
  266. 'common_catalog_item_id' => ['required', 'integer'],
  267. 'order_item_number' => ['required', 'string', 'max:100'],
  268. 'factory_number' => ['nullable', 'string', 'max:100'],
  269. 'manufacture_date' => ['nullable', 'date'],
  270. ]);
  271. if ($validator->fails()) {
  272. throw new RuntimeException(
  273. "Лист «МАФ», строка {$row}: ".implode(' ', $validator->errors()->all()),
  274. );
  275. }
  276. $orders[$orderRow]['items'][] = array_filter(
  277. $validator->validated(),
  278. static fn (mixed $value, string $key): bool => $key !== 'id' || $value !== null,
  279. ARRAY_FILTER_USE_BOTH,
  280. );
  281. }
  282. foreach ($orders as $row => $order) {
  283. if ($order['items'] === []) {
  284. throw new RuntimeException(
  285. "Лист «График заказов», строка {$row}: на листе «МАФ» не указано оборудование заказа.",
  286. );
  287. }
  288. }
  289. }
  290. /** @param list<mixed> $headers */
  291. private function normalizedHeaders(array $headers): array
  292. {
  293. return array_map($this->normalize(...), $headers);
  294. }
  295. private function normalize(mixed $value): string
  296. {
  297. return mb_strtolower(trim((string) preg_replace('/\s+/u', ' ', (string) $value)));
  298. }
  299. private function rowHasValues(Worksheet $sheet, int $row, string $from, string $to): bool
  300. {
  301. $values = $sheet->rangeToArray("{$from}{$row}:{$to}{$row}", null, true, true, false)[0];
  302. return collect($values)->contains(static fn (mixed $value): bool => trim((string) $value) !== '');
  303. }
  304. private function requiredString(Cell $cell, string $label): string
  305. {
  306. $value = $this->nullableString($cell);
  307. if ($value === null) {
  308. throw new RuntimeException("Не заполнено поле «{$label}».");
  309. }
  310. return $value;
  311. }
  312. private function nullableString(Cell $cell): ?string
  313. {
  314. $value = trim((string) $cell->getFormattedValue());
  315. return $value === '' ? null : $value;
  316. }
  317. private function integerValue(
  318. Cell $cell,
  319. string $label,
  320. int $minimum,
  321. bool $nullable = false,
  322. ?int $maximum = null,
  323. ): ?int {
  324. $raw = $cell->getCalculatedValue();
  325. if ($raw === null || trim((string) $raw) === '') {
  326. if ($nullable) {
  327. return null;
  328. }
  329. throw new RuntimeException("Не заполнено поле «{$label}».");
  330. }
  331. $value = filter_var($raw, FILTER_VALIDATE_INT);
  332. if ($value === false || $value < $minimum || ($maximum !== null && $value > $maximum)) {
  333. throw new RuntimeException("Поле «{$label}» содержит недопустимое целое число.");
  334. }
  335. return $value;
  336. }
  337. private function dateValue(Cell $cell, string $label, bool $required): ?string
  338. {
  339. $raw = $cell->getCalculatedValue();
  340. if ($raw === null || trim((string) $raw) === '') {
  341. if ($required) {
  342. throw new RuntimeException("Не заполнено поле «{$label}».");
  343. }
  344. return null;
  345. }
  346. if (is_numeric($raw)) {
  347. try {
  348. return SpreadsheetDate::excelToDateTimeObject((float) $raw)->format('Y-m-d');
  349. } catch (Throwable) {
  350. throw new RuntimeException("Поле «{$label}» содержит некорректную дату.");
  351. }
  352. }
  353. $value = trim((string) $raw);
  354. foreach (['d.m.Y', 'Y-m-d', 'd/m/Y'] as $format) {
  355. $date = DateTimeImmutable::createFromFormat('!'.$format, $value);
  356. $errors = DateTimeImmutable::getLastErrors();
  357. if ($date !== false && ($errors === false || ($errors['warning_count'] === 0 && $errors['error_count'] === 0))) {
  358. return $date->format('Y-m-d');
  359. }
  360. }
  361. throw new RuntimeException("Поле «{$label}» содержит некорректную дату.");
  362. }
  363. private function managerId(Cell $cell): int
  364. {
  365. $name = $this->requiredString($cell, 'Менеджер');
  366. $managers = User::query()
  367. ->where('role', Role::MANAGER)
  368. ->where('name', $name)
  369. ->limit(2)
  370. ->get(['id']);
  371. if ($managers->isEmpty()) {
  372. throw new RuntimeException("Менеджер «{$name}» не найден.");
  373. }
  374. if ($managers->count() > 1) {
  375. throw new RuntimeException("Имя менеджера «{$name}» неоднозначно.");
  376. }
  377. return (int) $managers->first()->id;
  378. }
  379. private function statusValue(Cell $cell): string
  380. {
  381. $value = $this->normalize($this->requiredString($cell, 'Статус'));
  382. foreach (ProductionOrderStatus::cases() as $status) {
  383. if (in_array($value, [$this->normalize($status->value), $this->normalize($status->label())], true)) {
  384. return $status->value;
  385. }
  386. }
  387. throw new RuntimeException("Неизвестный статус «{$cell->getFormattedValue()}».");
  388. }
  389. private function executionTypeValue(Cell $cell): string
  390. {
  391. $value = $this->normalize($this->requiredString($cell, 'Тип исполнения'));
  392. foreach (ProductionOrderExecutionType::cases() as $type) {
  393. if (in_array($value, [$this->normalize($type->value), $this->normalize($type->label())], true)) {
  394. return $type->value;
  395. }
  396. }
  397. throw new RuntimeException("Неизвестный тип исполнения «{$cell->getFormattedValue()}».");
  398. }
  399. }