admin = User::factory()->admin()->create(); $this->manager = User::factory()->manager()->create(['phone' => '+7 900 123-45-67']); $this->driver = User::factory()->driver()->create(); $this->brigadier = User::factory()->brigadier()->create(); $this->order = ProductionOrder::factory()->create([ 'order_number' => 'ДОК-001', 'customer_name' => 'ООО Заказчик', 'object_address' => 'г. Москва, ул. Доставки, 1', 'invoice_number' => 'СЧ-55', 'invoice_date' => '2026-09-01', 'contract_number' => 'Д-77', 'contract_date' => '2026-08-20', 'manager_id' => $this->manager->id, ]); $catalogItem = CommonCatalogItem::factory()->create([ 'article' => 'ART-100', 'calculator_name' => 'Качели', ]); $this->item = ProductionOrderItem::factory()->create([ 'production_order_id' => $this->order->id, 'common_catalog_item_id' => $catalogItem->id, 'order_item_number' => 'МАФ-100', 'factory_number' => 'ЗН-100', 'manufacture_date' => '2026-09-02', ]); ProductionOrderItem::factory()->create([ 'production_order_id' => $this->order->id, 'common_catalog_item_id' => $catalogItem->id, 'order_item_number' => 'МАФ-101', ]); } public function test_export_items_contains_order_header_and_each_item(): void { $file = app(ProductionOrderDocumentService::class)->generate( ProductionOrderDocumentService::TYPE_ITEMS, $this->order->id, $this->admin->id, ); Storage::disk('local')->assertExists($file->path); $spreadsheet = IOFactory::load(Storage::disk('local')->path($file->path)); $sheet = $spreadsheet->getActiveSheet(); $this->assertSame('Оборудование заказа №ДОК-001', $sheet->getCell('A1')->getValue()); $this->assertStringContainsString('ООО Заказчик', (string) $sheet->getCell('A2')->getValue()); $this->assertSame('ART-100', $sheet->getCell('A5')->getValue()); $this->assertSame('МАФ-101', $sheet->getCell('C6')->getValue()); } public function test_delivery_request_fills_provided_template_and_preserves_print_layout(): void { $delivery = ProductionOrderDelivery::factory()->create([ 'production_order_id' => $this->order->id, 'driver_id' => $this->driver->id, 'delivery_date' => '2026-09-15', 'request_number' => 'ЗД-15', 'request_date' => '2026-09-10', 'contact_name' => 'Иван Иванов', 'contact_phone' => '+7 999 000-00-00', 'has_passports_and_certificates' => true, 'note' => 'Позвонить за час', ]); $file = app(ProductionOrderDocumentService::class)->generate( ProductionOrderDocumentService::TYPE_DELIVERY, $this->order->id, $this->admin->id, $delivery->id, ); $spreadsheet = IOFactory::load(Storage::disk('local')->path($file->path)); $sheet = $spreadsheet->getActiveSheet(); $this->assertSame('B1:AG94', $sheet->getPageSetup()->getPrintArea()); $this->assertCount(2, $sheet->getDrawingCollection()); $this->assertSame('СЧ-55', $sheet->getCell('G4')->getValue()); $this->assertSame('ДОК-001', $sheet->getCell('G6')->getValue()); $this->assertSame('Иван Иванов', $sheet->getCell('L13')->getValue()); $this->assertStringContainsString('ART-100 — Качели — 2 шт.', (string) $sheet->getCell('G25')->getValue()); } public function test_installation_and_technical_archives_include_catalog_documents(): void { Storage::disk('base')->put('public/images/tech-docs/ART-100/manual.pdf', 'legacy-manual'); Storage::disk('base')->put('public/images/tech-docs/ART-100/assembly.pdf', 'legacy-assembly'); $duplicateDocument = File::factory()->pdf()->create([ 'user_id' => $this->admin->id, 'path' => 'catalog/ART-100/manual.pdf', 'original_name' => 'manual.pdf', ]); $additionalDocument = File::factory()->pdf()->create([ 'user_id' => $this->admin->id, 'path' => 'catalog/ART-100/additional.pdf', 'original_name' => 'additional.pdf', ]); Storage::disk('public')->put($duplicateDocument->path, 'common-catalog-manual'); Storage::disk('public')->put($additionalDocument->path, 'common-catalog-additional'); $this->item->catalogItem->documents()->attach([ $duplicateDocument->id, $additionalDocument->id, ]); $installation = ProductionOrderInstallation::factory()->create([ 'production_order_id' => $this->order->id, 'brigadier_id' => $this->brigadier->id, 'installation_date' => '2026-09-20', ]); $technicalFile = app(ProductionOrderDocumentService::class)->generate( ProductionOrderDocumentService::TYPE_TECHNICAL_DOCUMENTS, $this->order->id, $this->admin->id, itemIds: [$this->item->id], ); $installationFile = app(ProductionOrderDocumentService::class)->generate( ProductionOrderDocumentService::TYPE_INSTALLATION, $this->order->id, $this->admin->id, $installation->id, ); $technicalEntries = $this->zipEntries(Storage::disk('local')->path($technicalFile->path)); $installationEntries = $this->zipEntries(Storage::disk('local')->path($installationFile->path)); $this->assertContains('Техдокументация/ART-100/manual.pdf', $technicalEntries); $this->assertContains('Техдокументация/ART-100/assembly.pdf', $technicalEntries); $this->assertContains('Техдокументация/ART-100/additional.pdf', $technicalEntries); $this->assertSame(1, count(array_filter( $technicalEntries, static fn (string $name): bool => $name === 'Техдокументация/ART-100/manual.pdf', ))); $this->assertContains('Техдокументация/ART-100/manual.pdf', $installationEntries); $this->assertContains('Техдокументация/ART-100/assembly.pdf', $installationEntries); $this->assertContains('Техдокументация/ART-100/additional.pdf', $installationEntries); $this->assertSame(1, count(array_filter( $installationEntries, static fn (string $name): bool => $name === 'Техдокументация/ART-100/manual.pdf', ))); $this->assertTrue(collect($installationEntries)->contains(fn (string $name): bool => str_ends_with($name, '.xlsx'))); } public function test_document_actions_are_queued_and_related_records_are_scoped_to_order(): void { Bus::fake(); $delivery = ProductionOrderDelivery::factory()->create([ 'production_order_id' => $this->order->id, 'driver_id' => $this->driver->id, ]); $otherOrder = ProductionOrder::factory()->create(['manager_id' => $this->manager->id]); $otherDelivery = ProductionOrderDelivery::factory()->create([ 'production_order_id' => $otherOrder->id, 'driver_id' => $this->driver->id, ]); $this->actingAs($this->admin) ->post(route('schedule.orders.export-items', $this->order)) ->assertRedirect(); $this->actingAs($this->admin) ->post(route('schedule.orders.deliveries.document', [$this->order, $delivery])) ->assertRedirect(); $this->actingAs($this->admin) ->post(route('schedule.orders.technical-documents', $this->order), ['item_ids' => [$this->item->id]]) ->assertRedirect(); Bus::assertDispatchedTimes(GenerateProductionOrderFileJob::class, 3); $this->actingAs($this->admin) ->post(route('schedule.orders.deliveries.document', [$this->order, $otherDelivery])) ->assertNotFound(); $this->actingAs($this->manager) ->post(route('schedule.orders.export-items', $this->order)) ->assertForbidden(); } public function test_generated_file_is_private_to_owner_and_admin(): void { $otherManager = User::factory()->manager()->create(); $file = app(ProductionOrderDocumentService::class)->generate( ProductionOrderDocumentService::TYPE_ITEMS, $this->order->id, $this->manager->id, ); $this->actingAs($otherManager) ->get(route('schedule.orders.files.download', $file)) ->assertForbidden(); $this->actingAs($this->manager) ->get(route('schedule.orders.files.download', $file)) ->assertOk() ->assertHeader('content-disposition'); $this->actingAs($this->admin) ->get(route('schedule.orders.files.download', $file)) ->assertOk() ->assertHeader('content-disposition'); } /** @return list */ private function zipEntries(string $path): array { $zip = new ZipArchive; $this->assertTrue($zip->open($path) === true); $entries = []; for ($index = 0; $index < $zip->numFiles; $index++) { $entries[] = $zip->getNameIndex($index); } $zip->close(); return $entries; } }