|
@@ -0,0 +1,254 @@
|
|
|
|
|
+<?php
|
|
|
|
|
+
|
|
|
|
|
+namespace Tests\Unit\Services\Import;
|
|
|
|
|
+
|
|
|
|
|
+use App\Models\Import;
|
|
|
|
|
+use App\Models\Product;
|
|
|
|
|
+use App\Services\ImportCatalogService;
|
|
|
|
|
+use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
|
|
|
+use Illuminate\Support\Facades\Storage;
|
|
|
|
|
+use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
|
|
|
|
+use PhpOffice\PhpSpreadsheet\Writer\Xlsx as XlsxWriter;
|
|
|
|
|
+use Tests\TestCase;
|
|
|
|
|
+
|
|
|
|
|
+class ImportCatalogServiceTest extends TestCase
|
|
|
|
|
+{
|
|
|
|
|
+ use RefreshDatabase;
|
|
|
|
|
+
|
|
|
|
|
+ protected $seed = true;
|
|
|
|
|
+
|
|
|
|
|
+ private int $testYear = 2025;
|
|
|
|
|
+ private array $tempFiles = [];
|
|
|
|
|
+
|
|
|
|
|
+ protected function tearDown(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ foreach ($this->tempFiles as $path) {
|
|
|
|
|
+ if (file_exists($path)) {
|
|
|
|
|
+ unlink($path);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ parent::tearDown();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // -------------------------------------------------------------------------
|
|
|
|
|
+ // Helpers
|
|
|
|
|
+ // -------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Build an xlsx file with catalog headers and optional data rows, upload it
|
|
|
|
|
+ * to Storage::disk('upload') and return the Import model.
|
|
|
|
|
+ */
|
|
|
|
|
+ private function createImportWithFile(array $dataRows = [], ?array $customHeaders = null): Import
|
|
|
|
|
+ {
|
|
|
|
|
+ $import = Import::factory()->create([
|
|
|
|
|
+ 'type' => 'catalog',
|
|
|
|
|
+ 'filename' => 'test_catalog_' . uniqid() . '.xlsx',
|
|
|
|
|
+ 'status' => 'new',
|
|
|
|
|
+ 'year' => $this->testYear,
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ $tempPath = $this->buildXlsxFile(
|
|
|
|
|
+ $customHeaders ?? array_keys(ImportCatalogService::HEADERS),
|
|
|
|
|
+ $dataRows
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ Storage::fake('upload');
|
|
|
|
|
+ Storage::disk('upload')->put($import->filename, file_get_contents($tempPath));
|
|
|
|
|
+
|
|
|
|
|
+ return $import;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function buildXlsxFile(array $headers, array $dataRows): string
|
|
|
|
|
+ {
|
|
|
|
|
+ $spreadsheet = new Spreadsheet();
|
|
|
|
|
+ $sheet = $spreadsheet->getActiveSheet();
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($headers as $colIdx => $header) {
|
|
|
|
|
+ $sheet->setCellValue(chr(65 + $colIdx) . '1', $header);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($dataRows as $rowIdx => $row) {
|
|
|
|
|
+ foreach ($row as $colIdx => $value) {
|
|
|
|
|
+ $sheet->setCellValue(chr(65 + $colIdx) . ($rowIdx + 2), $value);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $path = sys_get_temp_dir() . '/test_catalog_' . uniqid() . '.xlsx';
|
|
|
|
|
+ (new XlsxWriter($spreadsheet))->save($path);
|
|
|
|
|
+
|
|
|
|
|
+ $this->tempFiles[] = $path;
|
|
|
|
|
+
|
|
|
|
|
+ return $path;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Build a full 24-column data row matching the catalog headers.
|
|
|
|
|
+ * Only nomenclature_number is mandatory for the service logic; all other
|
|
|
|
|
+ * fields are filled with neutral defaults.
|
|
|
|
|
+ */
|
|
|
|
|
+ private function makeDataRow(
|
|
|
|
|
+ string $nomenclatureNumber,
|
|
|
|
|
+ string $article = 'ART-001',
|
|
|
|
|
+ string $nameTz = 'Горка тестовая'
|
|
|
|
|
+ ): array {
|
|
|
|
|
+ return [
|
|
|
|
|
+ '', // Фото
|
|
|
|
|
+ $article, // Артикул образца
|
|
|
|
|
+ $nameTz, // Наименование по ТЗ
|
|
|
|
|
+ 'Горка', // Тип по ТЗ
|
|
|
|
|
+ $nomenclatureNumber, // № по номенкл.
|
|
|
|
|
+ '1000x500x800', // Габаритные размеры
|
|
|
|
|
+ 'ООО Завод', // Производитель
|
|
|
|
|
+ 'шт', // ед. изм.
|
|
|
|
|
+ 'standard', // Тип оборудования
|
|
|
|
|
+ 1000.00, // Цена поставки
|
|
|
|
|
+ 200.00, // Цена установки
|
|
|
|
|
+ 1200.00, // Итого цена
|
|
|
|
|
+ 'Завод Тест', // Наименование производителя
|
|
|
|
|
+ '', // Примечание
|
|
|
|
|
+ 'Горка', // Наименование по паспорту
|
|
|
|
|
+ 'Горка', // Наименование в ведомости
|
|
|
|
|
+ 10, // Срок службы
|
|
|
|
|
+ 'CERT-001', // Номер сертификата
|
|
|
|
|
+ 0, // Дата сертификата (0 = no date)
|
|
|
|
|
+ 'Орган', // Орган сертификации
|
|
|
|
|
+ 'ГОСТ', // Вид сертификата
|
|
|
|
|
+ 50.5, // Вес
|
|
|
|
|
+ 1.2, // Объем
|
|
|
|
|
+ 2, // Мест
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // -------------------------------------------------------------------------
|
|
|
|
|
+ // Tests
|
|
|
|
|
+ // -------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * When the upload file does not exist, prepare() must fail and handle()
|
|
|
|
|
+ * must return false.
|
|
|
|
|
+ */
|
|
|
|
|
+ public function test_handle_returns_false_when_file_not_found(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ Storage::fake('upload');
|
|
|
|
|
+
|
|
|
|
|
+ $import = Import::factory()->create([
|
|
|
|
|
+ 'type' => 'catalog',
|
|
|
|
|
+ 'filename' => 'nonexistent_catalog_' . uniqid() . '.xlsx',
|
|
|
|
|
+ 'status' => 'new',
|
|
|
|
|
+ 'year' => $this->testYear,
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ $service = new ImportCatalogService($import, $this->testYear);
|
|
|
|
|
+
|
|
|
|
|
+ $result = $service->handle();
|
|
|
|
|
+
|
|
|
|
|
+ $this->assertFalse($result);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * A file with wrong column headers fails the header check and returns false.
|
|
|
|
|
+ */
|
|
|
|
|
+ public function test_handle_returns_false_with_wrong_headers(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $import = $this->createImportWithFile([], ['Неверный', 'Заголовок', 'Здесь']);
|
|
|
|
|
+
|
|
|
|
|
+ $service = new ImportCatalogService($import, $this->testYear);
|
|
|
|
|
+
|
|
|
|
|
+ $result = $service->handle();
|
|
|
|
|
+
|
|
|
|
|
+ $this->assertFalse($result);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * A valid file with one data row causes a new Product to be created.
|
|
|
|
|
+ */
|
|
|
|
|
+ public function test_handle_creates_product_for_new_nomenclature_number(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $import = $this->createImportWithFile([
|
|
|
|
|
+ $this->makeDataRow('TEST.001', 'ART-TEST-001', 'Горка синяя'),
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ $service = new ImportCatalogService($import, $this->testYear);
|
|
|
|
|
+ $result = $service->handle();
|
|
|
|
|
+
|
|
|
|
|
+ $this->assertTrue($result);
|
|
|
|
|
+
|
|
|
|
|
+ $this->assertDatabaseHas('products', [
|
|
|
|
|
+ 'nomenclature_number' => 'TEST.001',
|
|
|
|
|
+ 'year' => $this->testYear,
|
|
|
|
|
+ ]);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * When a Product with the same nomenclature_number + year already exists,
|
|
|
|
|
+ * the service updates it rather than creating a duplicate.
|
|
|
|
|
+ */
|
|
|
|
|
+ public function test_handle_updates_existing_product(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ // Pre-create a product that the importer should find and update.
|
|
|
|
|
+ Product::factory()->create([
|
|
|
|
|
+ 'nomenclature_number' => 'UPD.001',
|
|
|
|
|
+ 'year' => $this->testYear,
|
|
|
|
|
+ 'name_tz' => 'Старое название',
|
|
|
|
|
+ 'article' => 'OLD-ART',
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ $import = $this->createImportWithFile([
|
|
|
|
|
+ $this->makeDataRow('UPD.001', 'NEW-ART', 'Новое название'),
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ $service = new ImportCatalogService($import, $this->testYear);
|
|
|
|
|
+ $result = $service->handle();
|
|
|
|
|
+
|
|
|
|
|
+ $this->assertTrue($result);
|
|
|
|
|
+
|
|
|
|
|
+ // Only one product with this nomenclature/year should exist
|
|
|
|
|
+ $this->assertSame(
|
|
|
|
|
+ 1,
|
|
|
|
|
+ Product::withoutGlobalScopes()
|
|
|
|
|
+ ->where('nomenclature_number', 'UPD.001')
|
|
|
|
|
+ ->where('year', $this->testYear)
|
|
|
|
|
+ ->count()
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ $this->assertDatabaseHas('products', [
|
|
|
|
|
+ 'nomenclature_number' => 'UPD.001',
|
|
|
|
|
+ 'year' => $this->testYear,
|
|
|
|
|
+ 'name_tz' => 'Новое название',
|
|
|
|
|
+ ]);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Rows whose nomenclature_number is empty must be silently skipped; the
|
|
|
|
|
+ * overall handle() should still return true.
|
|
|
|
|
+ */
|
|
|
|
|
+ public function test_handle_skips_empty_rows(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $emptyRow = $this->makeDataRow('');
|
|
|
|
|
+ $emptyRow[4] = ''; // explicitly clear nomenclature_number (column index 4)
|
|
|
|
|
+
|
|
|
|
|
+ $import = $this->createImportWithFile([$emptyRow]);
|
|
|
|
|
+
|
|
|
|
|
+ $service = new ImportCatalogService($import, $this->testYear);
|
|
|
|
|
+ $result = $service->handle();
|
|
|
|
|
+
|
|
|
|
|
+ $this->assertTrue($result);
|
|
|
|
|
+
|
|
|
|
|
+ // No product should have been created for an empty nomenclature
|
|
|
|
|
+ $this->assertSame(0, Product::withoutGlobalScopes()->where('year', $this->testYear)->count());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * A file that has only the header row and no data rows should be processed
|
|
|
|
|
+ * without errors and return true.
|
|
|
|
|
+ */
|
|
|
|
|
+ public function test_handle_returns_true_with_headers_only(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $import = $this->createImportWithFile([]);
|
|
|
|
|
+
|
|
|
|
|
+ $service = new ImportCatalogService($import, $this->testYear);
|
|
|
|
|
+ $result = $service->handle();
|
|
|
|
|
+
|
|
|
|
|
+ $this->assertTrue($result);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|