Prechádzať zdrojové kódy

Импорт заказов МАФ, выгрузка тех документации площадки по всем маф

Alexander Musikhin 20 hodín pred
rodič
commit
5a713f8530

+ 2 - 2
app/Http/Controllers/ImportController.php

@@ -56,7 +56,7 @@ class ImportController extends Controller
     {
         // validate data
         $request->validate([
-            'type'        => 'required|in:orders,reclamations,mafs,catalog,spare_part_orders',
+            'type'        => 'required|in:orders,reclamations,mafs,catalog,spare_part_orders,maf_orders',
             'import_file' => 'required|file',
         ]);
 
@@ -66,7 +66,7 @@ class ImportController extends Controller
 
         $import = Import::query()->create([
             'type' => $request->type,
-            'year' => ($request->type === 'catalog') ? year() : null,
+            'year' => in_array($request->type, ['catalog', 'maf_orders']) ? year() : null,
             'status' => 'new',
             'filename' => $path,
         ]);

+ 57 - 0
app/Http/Controllers/OrderController.php

@@ -31,6 +31,8 @@ use App\Services\FileService;
 use Illuminate\Http\RedirectResponse;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Storage;
+use Symfony\Component\HttpFoundation\StreamedResponse;
+use ZipArchive;
 
 class OrderController extends Controller
 {
@@ -453,4 +455,59 @@ class OrderController extends Controller
 
     }
 
+    public function downloadTechDocs(Order $order): StreamedResponse
+    {
+        $techDocsPath = base_path('tech-docs');
+        $articles = $order->products_sku
+            ->pluck('product.article')
+            ->unique()
+            ->filter()
+            ->values();
+
+        if ($articles->isEmpty()) {
+            abort(404, 'Нет МАФов на площадке');
+        }
+
+        $archiveName = 'Тех.документация - ' . fileName($order->object_address) . '.zip';
+
+        return response()->streamDownload(function () use ($techDocsPath, $articles) {
+            $zip = new ZipArchive();
+            $tempFile = tempnam(storage_path('app/temp/'), 'tech_docs_');
+            $zip->open($tempFile, ZipArchive::CREATE | ZipArchive::OVERWRITE);
+
+            $filesAdded = 0;
+            foreach ($articles as $article) {
+                $articlePath = $techDocsPath . '/' . $article;
+                if (!is_dir($articlePath)) {
+                    continue;
+                }
+
+                $files = new \RecursiveIteratorIterator(
+                    new \RecursiveDirectoryIterator($articlePath, \RecursiveDirectoryIterator::SKIP_DOTS),
+                    \RecursiveIteratorIterator::LEAVES_ONLY
+                );
+
+                foreach ($files as $file) {
+                    if ($file->isFile()) {
+                        $relativePath = $article . '/' . $file->getFilename();
+                        $zip->addFile($file->getRealPath(), $relativePath);
+                        $filesAdded++;
+                    }
+                }
+            }
+
+            if(!$filesAdded) {
+                $zip->addFromString('readme.txt', 'Нет документации для этих МАФ!');
+            }
+
+            $zip->close();
+
+            readfile($tempFile);
+            unlink($tempFile);
+
+        }, $archiveName, [
+            'Content-Type' => 'application/zip',
+        ]);
+    }
+
 }

+ 19 - 0
app/Jobs/Import/ImportJob.php

@@ -4,6 +4,7 @@ namespace App\Jobs\Import;
 
 use App\Events\SendWebSocketMessageEvent;
 use App\Models\Import;
+use App\Services\Import\ImportMafOrdersService;
 use App\Services\Import\ImportSparePartOrdersService;
 use App\Services\ImportCatalogService;
 use Illuminate\Support\Facades\Storage;
@@ -50,6 +51,9 @@ class ImportJob implements ShouldQueue
                 case 'spare_part_orders':
                     $this->handleSparePartOrdersImport();
                     break;
+                case 'maf_orders':
+                    $this->handleMafOrdersImport();
+                    break;
             }
             Log::info('Import ' . $this->import->type. ' job done!');
             event(new SendWebSocketMessageEvent('Импорт завершён!', $this->userId, ['path' => $this->import->filename, 'type' => $this->import->type]));
@@ -73,4 +77,19 @@ class ImportJob implements ShouldQueue
         $this->import->status = $result['success'] ? 'DONE' : 'ERROR';
         $this->import->save();
     }
+
+    private function handleMafOrdersImport(): void
+    {
+        $filePath = Storage::disk('upload')->path($this->import->filename);
+        $service = new ImportMafOrdersService($filePath, $this->userId, $this->import->year);
+        $result = $service->handle();
+
+        // Записываем логи в Import
+        foreach ($service->getLogs() as $logLine) {
+            $this->import->log($logLine);
+        }
+
+        $this->import->status = $result['success'] ? 'DONE' : 'ERROR';
+        $this->import->save();
+    }
 }

+ 158 - 0
app/Services/Import/ImportMafOrdersService.php

@@ -0,0 +1,158 @@
+<?php
+
+namespace App\Services\Import;
+
+use App\Models\MafOrder;
+use App\Models\Product;
+use Exception;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+use PhpOffice\PhpSpreadsheet\IOFactory;
+
+class ImportMafOrdersService
+{
+    private array $logs = [];
+    private array $productCache = [];
+
+    public function __construct(
+        private readonly string $filePath,
+        private readonly int $userId,
+        private readonly int $year,
+    ) {}
+
+    public function handle(): array
+    {
+        try {
+            $this->log("Начало импорта заказов МАФ (год: {$this->year})");
+
+            // Загружаем кэш товаров по артикулу для выбранного года
+            $this->loadProductCache();
+
+            DB::beginTransaction();
+
+            try {
+                $spreadsheet = IOFactory::load($this->filePath);
+                $sheet = $spreadsheet->getActiveSheet();
+
+                $highestRow = $sheet->getHighestRow();
+                $imported = 0;
+                $skipped = 0;
+                $errors = 0;
+
+                // Начинаем со 2-й строки (пропускаем заголовок)
+                // Колонки: A=Артикул МАФ, B=кол-во, C=Номер заказа, D=Статус
+                for ($row = 2; $row <= $highestRow; $row++) {
+                    $article = trim((string) $sheet->getCell('A' . $row)->getValue());
+                    $quantity = (int) $sheet->getCell('B' . $row)->getValue();
+                    $orderNumber = trim((string) $sheet->getCell('C' . $row)->getValue());
+                    $statusRaw = trim((string) $sheet->getCell('D' . $row)->getValue());
+
+                    // Пропускаем пустые строки
+                    if (empty($article)) {
+                        $skipped++;
+                        continue;
+                    }
+
+                    // Проверяем наличие товара в каталоге выбранного года
+                    if (!isset($this->productCache[$article])) {
+                        $this->log("Строка {$row}: МАФ с артикулом '{$article}' не найден в каталоге {$this->year} года", 'ERROR');
+                        $errors++;
+                        continue;
+                    }
+
+                    $productId = $this->productCache[$article];
+
+                    // Парсим статус
+                    $status = $this->parseStatus($statusRaw);
+
+                    if ($status === null) {
+                        $this->log("Строка {$row}: неизвестный статус '{$statusRaw}'", 'ERROR');
+                        $errors++;
+                        continue;
+                    }
+
+                    // Определяем in_stock в зависимости от статуса
+                    $inStock = $status === 'на складе' ? $quantity : 0;
+
+                    // Создаём заказ МАФ
+                    MafOrder::create([
+                        'year' => $this->year,
+                        'product_id' => $productId,
+                        'order_number' => $orderNumber ?: null,
+                        'status' => $status,
+                        'quantity' => $quantity,
+                        'in_stock' => $inStock,
+                        'user_id' => $this->userId,
+                    ]);
+
+                    $imported++;
+                }
+
+                DB::commit();
+
+                $this->log("=== РЕЗЮМЕ ИМПОРТА ===");
+                $this->log("Импортировано: {$imported}");
+                $this->log("Пропущено (пустые строки): {$skipped}");
+                $this->log("Ошибок: {$errors}");
+
+                return [
+                    'success' => $errors === 0,
+                    'imported' => $imported,
+                    'skipped' => $skipped,
+                    'errors' => $errors,
+                    'logs' => $this->logs,
+                ];
+            } catch (Exception $e) {
+                DB::rollBack();
+                throw $e;
+            }
+        } catch (Exception $e) {
+            $this->log("КРИТИЧЕСКАЯ ОШИБКА: " . $e->getMessage(), 'ERROR');
+            Log::error("Ошибка импорта заказов МАФ: " . $e->getMessage(), [
+                'trace' => $e->getTraceAsString(),
+            ]);
+
+            return [
+                'success' => false,
+                'error' => $e->getMessage(),
+                'logs' => $this->logs,
+            ];
+        }
+    }
+
+    private function loadProductCache(): void
+    {
+        $this->productCache = Product::withoutGlobalScopes()
+            ->where('year', $this->year)
+            ->pluck('id', 'article')
+            ->toArray();
+        $this->log("Загружено " . count($this->productCache) . " товаров в кэш для {$this->year} года");
+    }
+
+    private function parseStatus(string $value): ?string
+    {
+        $value = mb_strtolower(trim($value));
+
+        $statusMap = [
+            'на складе' => 'на складе',
+            'in_stock' => 'на складе',
+            'in stock' => 'на складе',
+            'заказан' => 'заказан',
+            'заказано' => 'заказан',
+            'ordered' => 'заказан',
+        ];
+
+        return $statusMap[$value] ?? null;
+    }
+
+    private function log(string $message, string $level = 'INFO'): void
+    {
+        $this->logs[] = '[' . date('H:i:s') . '] ' . $level . ': ' . $message;
+        Log::info($message);
+    }
+
+    public function getLogs(): array
+    {
+        return $this->logs;
+    }
+}

BIN
resources/ImportMafOrdersSample.xlsx


+ 1 - 1
resources/views/import/index.blade.php

@@ -36,7 +36,7 @@
                 <div class="modal-body">
                     <form action="{{ route('import.create') }}" method="post" enctype="multipart/form-data">
                         @csrf
-                        @include('partials.select', ['title' => 'Вкладка', 'name' => 'type', 'options' => ['orders' => 'Площадки', 'reclamations' => 'Рекламации', 'mafs' => 'МАФы', 'catalog' => 'Каталог', 'spare_part_orders' => 'Заказы запчастей']])
+                        @include('partials.select', ['title' => 'Вкладка', 'name' => 'type', 'options' => ['orders' => 'Площадки', 'reclamations' => 'Рекламации', 'mafs' => 'МАФы', 'catalog' => 'Каталог', 'spare_part_orders' => 'Заказы запчастей', 'maf_orders' => 'Заказы МАФ']])
                         @include('partials.input', ['title' => 'XLSX файл', 'name' => 'import_file', 'type' => 'file', 'required' => true])
                         @include('partials.submit', ['name' => 'Импорт'])
                     </form>

+ 5 - 0
resources/views/orders/show.blade.php

@@ -329,6 +329,11 @@
                                 <a href="#" class="btn btn-primary btn-sm mb-1" id="ttnBtn">ТН</a>
                             @endif
                         </div>
+                        <div class="mt-2">
+                            <a href="{{ route('order.download-tech-docs', $order) }}" class="btn btn-sm btn-outline-primary">
+                                <i class="bi bi-download"></i> Скачать тех. документацию
+                            </a>
+                        </div>
                     @endif
                 </div>
 

+ 1 - 0
routes/web.php

@@ -180,6 +180,7 @@ Route::middleware('auth:web')->group(function () {
     Route::get('order/show/{order}', [OrderController::class, 'show'])->name('order.show');
     Route::post('order/{order}/upload-photo', [OrderController::class, 'uploadPhoto'])->name('order.upload-photo');
     Route::get('order/generate-photos-pack/{order}', [OrderController::class, 'generatePhotosPack'])->name('order.generate-photos-pack');
+    Route::get('order/download-tech-docs/{order}', [OrderController::class, 'downloadTechDocs'])->name('order.download-tech-docs');
 
     Route::middleware('role:' . Role::ADMIN)->group(function () {
         Route::get('catalog/create', [ProductController::class, 'create'])->name('catalog.create');

+ 257 - 0
tests/Unit/Services/Import/ImportMafOrdersServiceTest.php

@@ -0,0 +1,257 @@
+<?php
+
+namespace Tests\Unit\Services\Import;
+
+use App\Models\MafOrder;
+use App\Models\Product;
+use App\Services\Import\ImportMafOrdersService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use PhpOffice\PhpSpreadsheet\Spreadsheet;
+use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
+use Tests\TestCase;
+
+class ImportMafOrdersServiceTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected $seed = true;
+
+    private string $tempFilePath;
+    private Product $product1;
+    private Product $product2;
+    private int $testYear = 2025;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        $this->product1 = Product::factory()->create(['article' => 'MAF.0001', 'year' => $this->testYear]);
+        $this->product2 = Product::factory()->create(['article' => 'MAF.0002', 'year' => $this->testYear]);
+    }
+
+    protected function tearDown(): void
+    {
+        if (isset($this->tempFilePath) && file_exists($this->tempFilePath)) {
+            unlink($this->tempFilePath);
+        }
+        parent::tearDown();
+    }
+
+    public function test_import_creates_maf_orders(): void
+    {
+        $this->createTestFile([
+            ['MAF.0001', 10, '55/5224', 'На складе'],
+            ['MAF.0002', 5, '1554-55453', 'Заказан'],
+        ]);
+
+        $service = new ImportMafOrdersService($this->tempFilePath, 1, $this->testYear);
+        $result = $service->handle();
+
+        $this->assertTrue($result['success']);
+        $this->assertEquals(2, $result['imported']);
+        $this->assertEquals(0, $result['errors']);
+
+        $this->assertDatabaseHas('maf_orders', [
+            'product_id' => $this->product1->id,
+            'quantity' => 10,
+            'in_stock' => 10,
+            'order_number' => '55/5224',
+            'status' => 'на складе',
+            'year' => $this->testYear,
+        ]);
+
+        $this->assertDatabaseHas('maf_orders', [
+            'product_id' => $this->product2->id,
+            'quantity' => 5,
+            'in_stock' => 0,
+            'order_number' => '1554-55453',
+            'status' => 'заказан',
+            'year' => $this->testYear,
+        ]);
+    }
+
+    public function test_import_reports_error_for_unknown_article(): void
+    {
+        $this->createTestFile([
+            ['UNKNOWN.ARTICLE', 10, '55/5224', 'На складе'],
+            ['MAF.0001', 5, '55/5224', 'На складе'],
+        ]);
+
+        $service = new ImportMafOrdersService($this->tempFilePath, 1, $this->testYear);
+        $result = $service->handle();
+
+        $this->assertFalse($result['success']);
+        $this->assertEquals(1, $result['imported']);
+        $this->assertEquals(1, $result['errors']);
+
+        $hasErrorLog = false;
+        foreach ($result['logs'] as $log) {
+            if (str_contains($log, 'UNKNOWN.ARTICLE') && str_contains($log, 'не найден')) {
+                $hasErrorLog = true;
+                break;
+            }
+        }
+        $this->assertTrue($hasErrorLog, 'Should log error for unknown article');
+    }
+
+    public function test_import_reports_error_for_unknown_status(): void
+    {
+        $this->createTestFile([
+            ['MAF.0001', 10, '55/5224', 'Неизвестный статус'],
+        ]);
+
+        $service = new ImportMafOrdersService($this->tempFilePath, 1, $this->testYear);
+        $result = $service->handle();
+
+        $this->assertFalse($result['success']);
+        $this->assertEquals(0, $result['imported']);
+        $this->assertEquals(1, $result['errors']);
+    }
+
+    public function test_import_skips_empty_rows(): void
+    {
+        $this->createTestFile([
+            ['', '', '', ''],
+            ['MAF.0001', 10, '55/5224', 'На складе'],
+        ]);
+
+        $service = new ImportMafOrdersService($this->tempFilePath, 1, $this->testYear);
+        $result = $service->handle();
+
+        $this->assertTrue($result['success']);
+        $this->assertEquals(1, $result['imported']);
+        $this->assertEquals(1, $result['skipped']);
+    }
+
+    public function test_import_parses_status_correctly(): void
+    {
+        $this->createTestFile([
+            ['MAF.0001', 1, 'order1', 'На складе'],
+            ['MAF.0002', 1, 'order2', 'Заказан'],
+        ]);
+
+        $service = new ImportMafOrdersService($this->tempFilePath, 1, $this->testYear);
+        $result = $service->handle();
+
+        $this->assertTrue($result['success']);
+
+        $this->assertDatabaseHas('maf_orders', [
+            'product_id' => $this->product1->id,
+            'status' => 'на складе',
+            'in_stock' => 1,
+        ]);
+        $this->assertDatabaseHas('maf_orders', [
+            'product_id' => $this->product2->id,
+            'status' => 'заказан',
+            'in_stock' => 0,
+        ]);
+    }
+
+    public function test_import_only_uses_products_from_specified_year(): void
+    {
+        // Создаём продукт с другим годом
+        Product::factory()->create(['article' => 'MAF.OTHER', 'year' => 2020]);
+
+        $this->createTestFile([
+            ['MAF.OTHER', 10, '55/5224', 'На складе'],
+        ]);
+
+        $service = new ImportMafOrdersService($this->tempFilePath, 1, $this->testYear);
+        $result = $service->handle();
+
+        $this->assertFalse($result['success']);
+        $this->assertEquals(0, $result['imported']);
+        $this->assertEquals(1, $result['errors']);
+
+        $hasErrorLog = false;
+        foreach ($result['logs'] as $log) {
+            if (str_contains($log, 'MAF.OTHER') && str_contains($log, $this->testYear . ' года')) {
+                $hasErrorLog = true;
+                break;
+            }
+        }
+        $this->assertTrue($hasErrorLog, 'Should log error mentioning the year');
+    }
+
+    public function test_import_returns_summary(): void
+    {
+        $this->createTestFile([
+            ['MAF.0001', 10, '55/5224', 'На складе'],
+            ['UNKNOWN', 5, '55/5224', 'На складе'],
+            [' ', '', '', ''],
+        ]);
+
+        $service = new ImportMafOrdersService($this->tempFilePath, 1, $this->testYear);
+        $result = $service->handle();
+
+        $this->assertArrayHasKey('imported', $result);
+        $this->assertArrayHasKey('skipped', $result);
+        $this->assertArrayHasKey('errors', $result);
+        $this->assertArrayHasKey('logs', $result);
+
+        $this->assertEquals(1, $result['imported']);
+        $this->assertEquals(1, $result['skipped']);
+        $this->assertEquals(1, $result['errors']);
+
+        $hasSummary = false;
+        foreach ($result['logs'] as $log) {
+            if (str_contains($log, 'РЕЗЮМЕ')) {
+                $hasSummary = true;
+                break;
+            }
+        }
+        $this->assertTrue($hasSummary, 'Should include summary in logs');
+    }
+
+    public function test_import_sets_in_stock_based_on_status(): void
+    {
+        $this->createTestFile([
+            ['MAF.0001', 15, 'order1', 'На складе'],
+            ['MAF.0002', 20, 'order2', 'Заказан'],
+        ]);
+
+        $service = new ImportMafOrdersService($this->tempFilePath, 1, $this->testYear);
+        $result = $service->handle();
+
+        $this->assertTrue($result['success']);
+
+        // На складе - in_stock = quantity
+        $this->assertDatabaseHas('maf_orders', [
+            'product_id' => $this->product1->id,
+            'quantity' => 15,
+            'in_stock' => 15,
+        ]);
+
+        // Заказан - in_stock = 0
+        $this->assertDatabaseHas('maf_orders', [
+            'product_id' => $this->product2->id,
+            'quantity' => 20,
+            'in_stock' => 0,
+        ]);
+    }
+
+    private function createTestFile(array $rows): void
+    {
+        $spreadsheet = new Spreadsheet();
+        $sheet = $spreadsheet->getActiveSheet();
+
+        // Headers
+        $sheet->setCellValue('A1', 'Артикул МАФ');
+        $sheet->setCellValue('B1', 'кол-во');
+        $sheet->setCellValue('C1', 'Номер заказа');
+        $sheet->setCellValue('D1', 'Статус');
+
+        // Data rows
+        $rowNum = 2;
+        foreach ($rows as $row) {
+            $sheet->setCellValue('A' . $rowNum, $row[0] ?? '');
+            $sheet->setCellValue('B' . $rowNum, $row[1] ?? '');
+            $sheet->setCellValue('C' . $rowNum, $row[2] ?? '');
+            $sheet->setCellValue('D' . $rowNum, $row[3] ?? '');
+            $rowNum++;
+        }
+
+        $this->tempFilePath = sys_get_temp_dir() . '/test_maf_orders_' . uniqid() . '.xlsx';
+        $writer = new Xlsx($spreadsheet);
+        $writer->save($this->tempFilePath);
+    }
+}