ProductionOrderDocumentService.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Services;
  4. use App\Enums\ProductionOrderExecutionType;
  5. use App\Models\File;
  6. use App\Models\ProductionOrder;
  7. use App\Models\ProductionOrderDelivery;
  8. use App\Models\ProductionOrderInstallation;
  9. use Illuminate\Support\Collection;
  10. use Illuminate\Support\Facades\Storage;
  11. use Illuminate\Support\Str;
  12. use PhpOffice\PhpSpreadsheet\Cell\DataType;
  13. use PhpOffice\PhpSpreadsheet\IOFactory;
  14. use PhpOffice\PhpSpreadsheet\Spreadsheet;
  15. use PhpOffice\PhpSpreadsheet\Style\Alignment;
  16. use PhpOffice\PhpSpreadsheet\Style\Border;
  17. use PhpOffice\PhpSpreadsheet\Writer\Xls;
  18. use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
  19. use RuntimeException;
  20. use ZipArchive;
  21. class ProductionOrderDocumentService
  22. {
  23. public const TYPE_ITEMS = 'items';
  24. public const TYPE_DELIVERY = 'delivery';
  25. public const TYPE_INSTALLATION = 'installation';
  26. public const TYPE_TECHNICAL_DOCUMENTS = 'technical_documents';
  27. public function generate(
  28. string $type,
  29. int $orderId,
  30. int $userId,
  31. ?int $relatedId = null,
  32. array $itemIds = [],
  33. ): File {
  34. $order = ProductionOrder::query()
  35. ->with([
  36. 'manager',
  37. 'items.catalogItem.documents',
  38. 'items.passportFile',
  39. 'documents',
  40. ])
  41. ->findOrFail($orderId);
  42. return match ($type) {
  43. self::TYPE_ITEMS => $this->exportItems($order, $userId),
  44. self::TYPE_DELIVERY => $this->deliveryRequest($order, (int) $relatedId, $userId),
  45. self::TYPE_INSTALLATION => $this->installationPack($order, (int) $relatedId, $userId),
  46. self::TYPE_TECHNICAL_DOCUMENTS => $this->technicalDocuments($order, $itemIds, $userId),
  47. default => throw new RuntimeException('Неизвестный тип документа заказа.'),
  48. };
  49. }
  50. private function exportItems(ProductionOrder $order, int $userId): File
  51. {
  52. $spreadsheet = new Spreadsheet;
  53. $sheet = $spreadsheet->getActiveSheet();
  54. $sheet->setTitle('МАФ');
  55. $sheet->mergeCells('A1:F1');
  56. $sheet->setCellValue('A1', 'Оборудование заказа №'.$order->order_number);
  57. $sheet->mergeCells('A2:F2');
  58. $sheet->setCellValue('A2', sprintf(
  59. 'Заказчик: %s; адрес: %s; счёт №%s',
  60. $order->customer_name,
  61. $order->object_address,
  62. $order->invoice_number,
  63. ));
  64. $sheet->fromArray([
  65. 'Артикул',
  66. 'Наименование',
  67. 'Номер заказа МАФ',
  68. 'Заводской номер',
  69. 'Дата производства',
  70. 'Паспорт',
  71. ], null, 'A4');
  72. $row = 5;
  73. foreach ($order->items as $item) {
  74. $sheet->setCellValueExplicit("A{$row}", (string) $item->catalogItem?->article, DataType::TYPE_STRING);
  75. $sheet->setCellValue("B{$row}", $item->catalogItem?->calculator_name ?: $item->catalogItem?->print_name);
  76. $sheet->setCellValueExplicit("C{$row}", (string) $item->order_item_number, DataType::TYPE_STRING);
  77. $sheet->setCellValueExplicit("D{$row}", (string) $item->factory_number, DataType::TYPE_STRING);
  78. $sheet->setCellValue("E{$row}", $item->manufacture_date?->format('d.m.Y'));
  79. $sheet->setCellValue("F{$row}", $item->passport_file_id ? 'Да' : 'Нет');
  80. $row++;
  81. }
  82. $lastRow = max(4, $row - 1);
  83. $sheet->getStyle("A4:F{$lastRow}")->getBorders()->getAllBorders()->setBorderStyle(Border::BORDER_THIN);
  84. $sheet->getStyle("A1:F{$lastRow}")->getAlignment()->setWrapText(true)->setVertical(Alignment::VERTICAL_TOP);
  85. foreach (['A' => 18, 'B' => 42, 'C' => 24, 'D' => 22, 'E' => 20, 'F' => 14] as $column => $width) {
  86. $sheet->getColumnDimension($column)->setWidth($width);
  87. }
  88. $filename = $this->safeFilename("МАФ_заказа_{$order->order_number}.xlsx");
  89. $file = $this->writeSpreadsheet($spreadsheet, $filename, $userId, new Xlsx($spreadsheet));
  90. $spreadsheet->disconnectWorksheets();
  91. return $file;
  92. }
  93. private function deliveryRequest(ProductionOrder $order, int $deliveryId, int $userId): File
  94. {
  95. $delivery = $order->deliveries()->with('driver')->findOrFail($deliveryId);
  96. $template = base_path('templates/DeliveryRequest.xls');
  97. if (! is_file($template)) {
  98. throw new RuntimeException('Шаблон заявки на доставку не найден.');
  99. }
  100. $spreadsheet = IOFactory::load($template);
  101. $sheet = $spreadsheet->getActiveSheet();
  102. $sheet->getPageSetup()->setPrintArea('B1:AG94');
  103. $sheet->setCellValue('B2', 'Заявка на доставку/самовывоз оборудования от '.now()->format('d.m.Y'));
  104. $sheet->setCellValue('G4', $order->invoice_number);
  105. $sheet->setCellValue('N4', $order->invoice_date?->format('d.m.Y'));
  106. $sheet->setCellValue('G5', $order->contract_number);
  107. $sheet->setCellValue('N5', $order->contract_date?->format('d.m.Y'));
  108. $sheet->setCellValue('G6', $order->order_number);
  109. $sheet->setCellValue('F8', $order->manager?->name);
  110. $sheet->setCellValue('V8', $order->manager?->phone);
  111. $sheet->setCellValue('B11', $delivery->carrier ?: $delivery->driver?->name);
  112. $sheet->setCellValue('O11', trim(implode(' / ', array_filter([
  113. $delivery->request_number,
  114. $delivery->request_date?->format('d.m.Y'),
  115. ]))));
  116. $sheet->setCellValue('S11', $this->shortTime($delivery->delivery_time));
  117. $sheet->setCellValue('W11', $delivery->carrier_note);
  118. $sheet->setCellValue('L12', $order->customer_name);
  119. $sheet->setCellValue('C13', $delivery->contact_position);
  120. $sheet->setCellValue('L13', $delivery->contact_name);
  121. $sheet->setCellValue('Y13', $delivery->contact_phone);
  122. $sheet->setCellValue('L16', $order->execution_type === ProductionOrderExecutionType::Pickup ? 'Самовывоз' : 'Доставка');
  123. $sheet->setCellValue('P16', $order->execution_type === ProductionOrderExecutionType::Pickup ? '' : 'Х');
  124. $sheet->setCellValue('Q16', $order->execution_type === ProductionOrderExecutionType::Pickup ? 'Х' : '');
  125. $sheet->setCellValue('L17', $order->object_address);
  126. $sheet->setCellValue('L18', $delivery->delivery_date->format('d.m.Y'));
  127. $sheet->setCellValue('L19', $this->shortTime($delivery->desired_time ?: $delivery->delivery_time));
  128. $sheet->setCellValue('L20', $delivery->access_system);
  129. $sheet->setCellValue('L21', $delivery->unloading_place);
  130. $sheet->setCellValue('L22', $delivery->advance_notice);
  131. $sheet->setCellValue('L23', $delivery->document_signing_method);
  132. $sheet->setCellValue('L24', $delivery->has_passports_and_certificates ? 'Да' : 'Нет');
  133. $sheet->setCellValue('G25', $this->equipmentSummary($order->items));
  134. $sheet->setCellValue('G26', $delivery->note);
  135. $sheet->setCellValue('B27', $order->manager?->name);
  136. $sheet->setCellValue('S28', now()->format('d.m.Y'));
  137. $filename = $this->safeFilename("Заявка_на_доставку_{$order->order_number}_{$delivery->delivery_date->format('Y-m-d')}.xls");
  138. $file = $this->writeSpreadsheet($spreadsheet, $filename, $userId, new Xls($spreadsheet));
  139. $spreadsheet->disconnectWorksheets();
  140. return $file;
  141. }
  142. private function installationPack(ProductionOrder $order, int $installationId, int $userId): File
  143. {
  144. $installation = $order->installations()->with('brigadier')->findOrFail($installationId);
  145. $template = base_path('templates/OrderForMount.xlsx');
  146. if (! is_file($template)) {
  147. throw new RuntimeException('Шаблон заявки на монтаж не найден.');
  148. }
  149. $temporaryDirectory = $this->temporaryDirectory();
  150. Storage::disk('local')->makeDirectory($temporaryDirectory);
  151. $requestFilename = $this->safeFilename("Заявка_на_монтаж_{$order->order_number}.xlsx");
  152. $requestPath = $temporaryDirectory.'/'.$requestFilename;
  153. $spreadsheet = IOFactory::load($template);
  154. $sheet = $spreadsheet->getActiveSheet();
  155. $sheet->setCellValue('F8', $order->manager?->name);
  156. $sheet->setCellValue('X8', $order->manager?->phone);
  157. $sheet->setCellValue('C12', $order->customer_name);
  158. $sheet->setCellValue('L14', $order->object_address);
  159. $sheet->setCellValue('L15', $installation->installation_date->format('d.m.Y'));
  160. $sheet->setCellValue('G33', $this->equipmentSummary($order->items));
  161. (new Xlsx($spreadsheet))->save(Storage::disk('local')->path($requestPath));
  162. $spreadsheet->disconnectWorksheets();
  163. $filename = $this->safeFilename("Монтажный_пакет_{$order->order_number}.zip");
  164. try {
  165. return $this->zipFiles(
  166. $order,
  167. $order->items,
  168. $userId,
  169. $filename,
  170. [$requestPath => $requestFilename],
  171. includeOrderDocuments: true,
  172. );
  173. } finally {
  174. Storage::disk('local')->deleteDirectory($temporaryDirectory);
  175. }
  176. }
  177. private function technicalDocuments(ProductionOrder $order, array $itemIds, int $userId): File
  178. {
  179. $ids = collect($itemIds)->map(static fn ($id): int => (int) $id)->filter()->unique();
  180. $items = $order->items->whereIn('id', $ids);
  181. if ($items->isEmpty() || $items->count() !== $ids->count()) {
  182. throw new RuntimeException('Выбранные МАФ не найдены в заказе.');
  183. }
  184. return $this->zipFiles(
  185. $order,
  186. $items,
  187. $userId,
  188. $this->safeFilename("Техдокументация_{$order->order_number}.zip"),
  189. );
  190. }
  191. /**
  192. * @param Collection<int, \App\Models\ProductionOrderItem> $items
  193. * @param array<string, string> $localFiles
  194. */
  195. private function zipFiles(
  196. ProductionOrder $order,
  197. Collection $items,
  198. int $userId,
  199. string $filename,
  200. array $localFiles = [],
  201. bool $includeOrderDocuments = false,
  202. ): File {
  203. $path = $this->generatedPath($userId, $filename);
  204. Storage::disk('local')->makeDirectory(dirname($path));
  205. $zip = new ZipArchive;
  206. if ($zip->open(Storage::disk('local')->path($path), ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
  207. throw new RuntimeException('Не удалось создать ZIP-архив.');
  208. }
  209. $added = 0;
  210. foreach ($localFiles as $filePath => $archiveName) {
  211. if (Storage::disk('local')->exists($filePath)) {
  212. $zip->addFile(Storage::disk('local')->path($filePath), $archiveName);
  213. $added++;
  214. }
  215. }
  216. foreach ($items->pluck('catalogItem')->filter()->unique('id') as $catalogItem) {
  217. foreach ($catalogItem->documents as $document) {
  218. if (! $document->path || ! Storage::disk('public')->exists($document->path)) {
  219. continue;
  220. }
  221. $archiveName = 'Техдокументация/'.$this->safeFilename((string) $catalogItem->article).'/'.$this->safeFilename($document->original_name);
  222. $zip->addFile(Storage::disk('public')->path($document->path), $this->uniqueArchiveName($zip, $archiveName));
  223. $added++;
  224. }
  225. }
  226. if ($includeOrderDocuments) {
  227. foreach ($order->documents as $document) {
  228. if (! $document->path || ! Storage::disk('public')->exists($document->path)) {
  229. continue;
  230. }
  231. $archiveName = 'Документы заказа/'.$this->safeFilename($document->original_name);
  232. $zip->addFile(Storage::disk('public')->path($document->path), $this->uniqueArchiveName($zip, $archiveName));
  233. $added++;
  234. }
  235. }
  236. $zip->close();
  237. if ($added === 0) {
  238. Storage::disk('local')->delete($path);
  239. throw new RuntimeException('Для выбранных МАФ нет технической документации.');
  240. }
  241. return $this->registerFile($path, $filename, 'application/zip', $userId);
  242. }
  243. private function writeSpreadsheet(Spreadsheet $spreadsheet, string $filename, int $userId, object $writer): File
  244. {
  245. $path = $this->generatedPath($userId, $filename);
  246. Storage::disk('local')->makeDirectory(dirname($path));
  247. $writer->save(Storage::disk('local')->path($path));
  248. $mimeType = Str::endsWith($filename, '.xls')
  249. ? 'application/vnd.ms-excel'
  250. : 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
  251. return $this->registerFile($path, $filename, $mimeType, $userId);
  252. }
  253. private function registerFile(string $path, string $filename, string $mimeType, int $userId): File
  254. {
  255. $file = File::query()->create([
  256. 'link' => '',
  257. 'path' => $path,
  258. 'user_id' => $userId,
  259. 'original_name' => $filename,
  260. 'mime_type' => $mimeType,
  261. 'is_generated' => true,
  262. ]);
  263. $file->update(['link' => route('schedule.orders.files.download', $file)]);
  264. return $file;
  265. }
  266. private function equipmentSummary(Collection $items): string
  267. {
  268. return $items
  269. ->groupBy(static fn ($item): string => implode('|', [
  270. $item->catalogItem?->article,
  271. $item->catalogItem?->calculator_name ?: $item->catalogItem?->print_name,
  272. ]))
  273. ->map(static function (Collection $group): string {
  274. $item = $group->first();
  275. $name = $item->catalogItem?->calculator_name ?: $item->catalogItem?->print_name;
  276. return trim((string) $item->catalogItem?->article.' — '.$name).' — '.$group->count().' шт.';
  277. })
  278. ->implode("\n");
  279. }
  280. private function shortTime(mixed $value): string
  281. {
  282. return $value ? substr((string) $value, 0, 5) : '';
  283. }
  284. private function generatedPath(int $userId, string $filename): string
  285. {
  286. return "generated/production-orders/{$userId}/".Str::uuid()."/{$filename}";
  287. }
  288. private function temporaryDirectory(): string
  289. {
  290. return 'generated/production-orders/tmp/'.Str::uuid();
  291. }
  292. private function safeFilename(string $value): string
  293. {
  294. $value = preg_replace('/[^\pL\pN ._()-]+/u', '_', trim($value)) ?? '';
  295. return mb_substr($value === '' ? 'файл' : $value, 0, 180);
  296. }
  297. private function uniqueArchiveName(ZipArchive $zip, string $name): string
  298. {
  299. if ($zip->locateName($name) === false) {
  300. return $name;
  301. }
  302. $extension = pathinfo($name, PATHINFO_EXTENSION);
  303. $base = $extension === '' ? $name : substr($name, 0, -strlen($extension) - 1);
  304. for ($index = 2; $index < 1000; $index++) {
  305. $candidate = $base." ({$index})".($extension === '' ? '' : ".{$extension}");
  306. if ($zip->locateName($candidate) === false) {
  307. return $candidate;
  308. }
  309. }
  310. return $name;
  311. }
  312. }