ProductionOrderDocumentService.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  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. $article = trim((string) $catalogItem->article);
  218. $archiveDirectory = 'Техдокументация/'.$this->safeFilename($article).'/';
  219. $legacyDirectory = 'public/images/tech-docs/'.$article;
  220. if ($article !== '' && basename($article) === $article) {
  221. foreach (Storage::disk('base')->allFiles($legacyDirectory) as $legacyDocumentPath) {
  222. $archiveName = $archiveDirectory.$this->safeFilename(basename($legacyDocumentPath));
  223. if ($this->addFileIfMissing(
  224. $zip,
  225. Storage::disk('base')->path($legacyDocumentPath),
  226. $archiveName,
  227. )) {
  228. $added++;
  229. }
  230. }
  231. }
  232. foreach ($catalogItem->documents as $document) {
  233. if (! $document->path || ! Storage::disk('public')->exists($document->path)) {
  234. continue;
  235. }
  236. $archiveName = $archiveDirectory.$this->safeFilename($document->original_name);
  237. if ($this->addFileIfMissing(
  238. $zip,
  239. Storage::disk('public')->path($document->path),
  240. $archiveName,
  241. )) {
  242. $added++;
  243. }
  244. }
  245. }
  246. if ($includeOrderDocuments) {
  247. foreach ($order->documents as $document) {
  248. if (! $document->path || ! Storage::disk('public')->exists($document->path)) {
  249. continue;
  250. }
  251. $archiveName = 'Документы заказа/'.$this->safeFilename($document->original_name);
  252. $zip->addFile(Storage::disk('public')->path($document->path), $this->uniqueArchiveName($zip, $archiveName));
  253. $added++;
  254. }
  255. }
  256. $zip->close();
  257. if ($added === 0) {
  258. Storage::disk('local')->delete($path);
  259. throw new RuntimeException('Для выбранных МАФ нет технической документации.');
  260. }
  261. return $this->registerFile($path, $filename, 'application/zip', $userId);
  262. }
  263. private function writeSpreadsheet(Spreadsheet $spreadsheet, string $filename, int $userId, object $writer): File
  264. {
  265. $path = $this->generatedPath($userId, $filename);
  266. Storage::disk('local')->makeDirectory(dirname($path));
  267. $writer->save(Storage::disk('local')->path($path));
  268. $mimeType = Str::endsWith($filename, '.xls')
  269. ? 'application/vnd.ms-excel'
  270. : 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
  271. return $this->registerFile($path, $filename, $mimeType, $userId);
  272. }
  273. private function registerFile(string $path, string $filename, string $mimeType, int $userId): File
  274. {
  275. $file = File::query()->create([
  276. 'link' => '',
  277. 'path' => $path,
  278. 'user_id' => $userId,
  279. 'original_name' => $filename,
  280. 'mime_type' => $mimeType,
  281. 'is_generated' => true,
  282. ]);
  283. $file->update(['link' => route('schedule.orders.files.download', $file)]);
  284. return $file;
  285. }
  286. private function equipmentSummary(Collection $items): string
  287. {
  288. return $items
  289. ->groupBy(static fn ($item): string => implode('|', [
  290. $item->catalogItem?->article,
  291. $item->catalogItem?->calculator_name ?: $item->catalogItem?->print_name,
  292. ]))
  293. ->map(static function (Collection $group): string {
  294. $item = $group->first();
  295. $name = $item->catalogItem?->calculator_name ?: $item->catalogItem?->print_name;
  296. return trim((string) $item->catalogItem?->article.' — '.$name).' — '.$group->count().' шт.';
  297. })
  298. ->implode("\n");
  299. }
  300. private function shortTime(mixed $value): string
  301. {
  302. return $value ? substr((string) $value, 0, 5) : '';
  303. }
  304. private function generatedPath(int $userId, string $filename): string
  305. {
  306. return "generated/production-orders/{$userId}/".Str::uuid()."/{$filename}";
  307. }
  308. private function temporaryDirectory(): string
  309. {
  310. return 'generated/production-orders/tmp/'.Str::uuid();
  311. }
  312. private function safeFilename(string $value): string
  313. {
  314. $value = preg_replace('/[^\pL\pN ._()-]+/u', '_', trim($value)) ?? '';
  315. return mb_substr($value === '' ? 'файл' : $value, 0, 180);
  316. }
  317. private function uniqueArchiveName(ZipArchive $zip, string $name): string
  318. {
  319. if ($zip->locateName($name) === false) {
  320. return $name;
  321. }
  322. $extension = pathinfo($name, PATHINFO_EXTENSION);
  323. $base = $extension === '' ? $name : substr($name, 0, -strlen($extension) - 1);
  324. for ($index = 2; $index < 1000; $index++) {
  325. $candidate = $base." ({$index})".($extension === '' ? '' : ".{$extension}");
  326. if ($zip->locateName($candidate) === false) {
  327. return $candidate;
  328. }
  329. }
  330. return $name;
  331. }
  332. private function addFileIfMissing(ZipArchive $zip, string $sourcePath, string $archiveName): bool
  333. {
  334. if ($zip->locateName($archiveName) !== false || ! is_file($sourcePath)) {
  335. return false;
  336. }
  337. return $zip->addFile($sourcePath, $archiveName);
  338. }
  339. }