Jelajahi Sumber

Добавлено поле "Номер заказа" (order_number) в заказы запчастей:

  1. Миграция 2026_02_02_230356_add_order_number_to_spare_part_orders.php:
    - Добавлено поле order_number (varchar, nullable)
    - Добавлен индекс для быстрого поиска
    - Обновлён database view
  2. Модель SparePartOrder.php:
    - Добавлено в $fillable
  3. Валидация StoreSparePartOrderRequest.php:
    - Добавлено правило order_number => nullable|string|max:255
  4. Контроллер SparePartOrderController.php:
    - Добавлена колонка в заголовок таблицы
    - Добавлено в поля поиска и фильтрации
  5. Форма edit.blade.php:
    - Добавлено поле ввода "Номер заказа"
  6. Импорт ImportSparePartOrdersService.php:
    - Колонка D теперь читается как "Номер заказа"
    - Порядок колонок: A=Артикул, B=кол-во, C=С документами?, D=Номер заказа, E=Статус
  7. Тесты обновлены и проходят.
Alexander Musikhin 22 jam lalu
induk
melakukan
950b22691f

+ 3 - 0
Makefile

@@ -146,6 +146,9 @@ queue-run-debug:
 queue-log: ## Лог приложения
 	$(compose) logs app-queue -f
 
+test: ##  Run tests
+	$(application) php artisan test
+
 websocket: ## Консоль приложения websocket
 	$(websocket) bash
 

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

@@ -56,7 +56,7 @@ class ImportController extends Controller
     {
         // validate data
         $request->validate([
-            'type'        => 'required|in:orders,reclamations,mafs,catalog',
+            'type'        => 'required|in:orders,reclamations,mafs,catalog,spare_part_orders',
             'import_file' => 'required|file',
         ]);
 

+ 3 - 1
app/Http/Controllers/SparePartOrderController.php

@@ -19,6 +19,7 @@ class SparePartOrderController extends Controller
         'id' => 'spare_part_orders',
         'header' => [
             'id' => 'ID',
+            'order_number' => 'Номер заказа',
             'article' => 'Артикул',
             'source_text' => 'Источник заказа',
             'status_name' => 'Статус',
@@ -30,6 +31,7 @@ class SparePartOrderController extends Controller
             'created_at' => 'Дата создания',
         ],
         'searchFields' => [
+            'order_number',
             'article',
             'source_text',
             'note',
@@ -48,7 +50,7 @@ class SparePartOrderController extends Controller
         $model = new SparePartOrdersView();
 
         // Фильтры
-        $this->createFilters($model, 'article');
+        $this->createFilters($model, 'article', 'order_number');
         $this->createRangeFilters($model, 'ordered_quantity', 'available_qty');
         $this->createDateFilters($model, 'created_at');
 

+ 1 - 0
app/Http/Requests/StoreSparePartOrderRequest.php

@@ -23,6 +23,7 @@ class StoreSparePartOrderRequest extends FormRequest
     {
         return [
             'spare_part_id' => 'required|exists:spare_parts,id',
+            'order_number' => 'nullable|string|max:255',
             'source_text' => 'nullable|string|max:255',
             'sourceable_id' => 'nullable|integer',
             'sourceable_type' => 'nullable|string',

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

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

+ 1 - 0
app/Models/SparePartOrder.php

@@ -38,6 +38,7 @@ class SparePartOrder extends Model
 
     protected $fillable = [
         'spare_part_id',
+        'order_number',
         'source_text',
         'sourceable_id',
         'sourceable_type',

+ 162 - 0
app/Services/Import/ImportSparePartOrdersService.php

@@ -0,0 +1,162 @@
+<?php
+
+namespace App\Services\Import;
+
+use App\Models\SparePart;
+use App\Models\SparePartOrder;
+use Exception;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+use PhpOffice\PhpSpreadsheet\IOFactory;
+
+class ImportSparePartOrdersService
+{
+    private array $logs = [];
+    private array $sparePartCache = [];
+
+    public function __construct(
+        private readonly string $filePath,
+        private readonly int $userId,
+    ) {}
+
+    public function handle(): array
+    {
+        try {
+            $this->log("Начало импорта заказов запчастей");
+
+            // Загружаем кэш запчастей по артикулу
+            $this->loadSparePartCache();
+
+            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=Номер заказа, E=Статус
+                for ($row = 2; $row <= $highestRow; $row++) {
+                    $article = trim($sheet->getCell('A' . $row)->getValue());
+                    $quantity = (int) $sheet->getCell('B' . $row)->getValue();
+                    $withDocsRaw = trim($sheet->getCell('C' . $row)->getValue());
+                    $orderNumber = trim($sheet->getCell('D' . $row)->getValue());
+                    $statusRaw = trim($sheet->getCell('E' . $row)->getValue());
+
+                    // Пропускаем пустые строки
+                    if (empty($article)) {
+                        $skipped++;
+                        continue;
+                    }
+
+                    // Проверяем наличие запчасти в каталоге
+                    if (!isset($this->sparePartCache[$article])) {
+                        $this->log("Строка {$row}: запчасть с артикулом '{$article}' не найдена в каталоге", 'ERROR');
+                        $errors++;
+                        continue;
+                    }
+
+                    $sparePartId = $this->sparePartCache[$article];
+
+                    // Парсим "С документами?"
+                    $withDocuments = $this->parseWithDocuments($withDocsRaw);
+
+                    // Парсим статус
+                    $status = $this->parseStatus($statusRaw);
+
+                    if ($status === null) {
+                        $this->log("Строка {$row}: неизвестный статус '{$statusRaw}'", 'ERROR');
+                        $errors++;
+                        continue;
+                    }
+
+                    // Создаём заказ запчасти
+                    SparePartOrder::create([
+                        'spare_part_id' => $sparePartId,
+                        'order_number' => $orderNumber ?: null,
+                        'status' => $status,
+                        'ordered_quantity' => $quantity,
+                        'available_qty' => $quantity,
+                        'with_documents' => $withDocuments,
+                        '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 loadSparePartCache(): void
+    {
+        $this->sparePartCache = SparePart::pluck('id', 'article')->toArray();
+        $this->log("Загружено " . count($this->sparePartCache) . " запчастей в кэш");
+    }
+
+    private function parseWithDocuments(string $value): bool
+    {
+        $value = mb_strtolower(trim($value));
+        return in_array($value, ['да', 'yes', '1', 'true']);
+    }
+
+    private function parseStatus(string $value): ?string
+    {
+        $value = mb_strtolower(trim($value));
+
+        $statusMap = [
+            'на складе' => SparePartOrder::STATUS_IN_STOCK,
+            'in_stock' => SparePartOrder::STATUS_IN_STOCK,
+            'in stock' => SparePartOrder::STATUS_IN_STOCK,
+            'заказано' => SparePartOrder::STATUS_ORDERED,
+            'ordered' => SparePartOrder::STATUS_ORDERED,
+            'отгружено' => SparePartOrder::STATUS_SHIPPED,
+            'shipped' => SparePartOrder::STATUS_SHIPPED,
+        ];
+
+        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;
+    }
+}

+ 90 - 0
database/migrations/2026_02_02_230356_add_order_number_to_spare_part_orders.php

@@ -0,0 +1,90 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::table('spare_part_orders', function (Blueprint $table) {
+            $table->string('order_number')->nullable()->after('spare_part_id');
+            $table->index('order_number');
+        });
+
+        // Обновляем view для включения нового поля
+        DB::statement("DROP VIEW IF EXISTS spare_part_orders_view");
+
+        DB::statement("
+            CREATE VIEW spare_part_orders_view AS
+            SELECT
+                spo.id,
+                spo.year,
+                spo.spare_part_id,
+                spo.order_number,
+                spo.source_text,
+                spo.sourceable_id,
+                spo.sourceable_type,
+                spo.status,
+                spo.ordered_quantity,
+                spo.available_qty,
+                spo.with_documents,
+                spo.note,
+                spo.user_id,
+                spo.deleted_at,
+                spo.created_at,
+                spo.updated_at,
+                sp.article,
+                u.name as user_name
+            FROM spare_part_orders spo
+            LEFT JOIN spare_parts sp ON sp.id = spo.spare_part_id
+            LEFT JOIN users u ON u.id = spo.user_id
+            WHERE spo.deleted_at IS NULL
+        ");
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        // Восстанавливаем view без поля order_number
+        DB::statement("DROP VIEW IF EXISTS spare_part_orders_view");
+
+        DB::statement("
+            CREATE VIEW spare_part_orders_view AS
+            SELECT
+                spo.id,
+                spo.year,
+                spo.spare_part_id,
+                spo.source_text,
+                spo.sourceable_id,
+                spo.sourceable_type,
+                spo.status,
+                spo.ordered_quantity,
+                spo.available_qty,
+                spo.with_documents,
+                spo.note,
+                spo.user_id,
+                spo.deleted_at,
+                spo.created_at,
+                spo.updated_at,
+                sp.article,
+                u.name as user_name
+            FROM spare_part_orders spo
+            LEFT JOIN spare_parts sp ON sp.id = spo.spare_part_id
+            LEFT JOIN users u ON u.id = spo.user_id
+            WHERE spo.deleted_at IS NULL
+        ");
+
+        Schema::table('spare_part_orders', function (Blueprint $table) {
+            $table->dropIndex(['order_number']);
+            $table->dropColumn('order_number');
+        });
+    }
+};

TEMPAT SAMPAH
resources/ImportSparePartsSample.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' => 'Каталог']])
+                        @include('partials.select', ['title' => 'Вкладка', 'name' => 'type', 'options' => ['orders' => 'Площадки', 'reclamations' => 'Рекламации', 'mafs' => 'МАФы', 'catalog' => 'Каталог', 'spare_part_orders' => 'Заказы запчастей']])
                         @include('partials.input', ['title' => 'XLSX файл', 'name' => 'import_file', 'type' => 'file', 'required' => true])
                         @include('partials.submit', ['name' => 'Импорт'])
                     </form>

+ 6 - 0
resources/views/spare_part_orders/edit.blade.php

@@ -40,6 +40,12 @@
                     @endif
                 </div>
 
+                <div class="mb-3">
+                    <label for="order_number" class="form-label">Номер заказа</label>
+                    <input type="text" class="form-control" id="order_number" name="order_number"
+                           value="{{ old('order_number', $spare_part_order->order_number ?? '') }}">
+                </div>
+
                 <div class="mb-3">
                     <label for="source_text" class="form-label">Источник заказа</label>
                     <input type="text" class="form-control" id="source_text" name="source_text"

+ 246 - 0
tests/Unit/Services/Import/ImportSparePartOrdersServiceTest.php

@@ -0,0 +1,246 @@
+<?php
+
+namespace Tests\Unit\Services\Import;
+
+use App\Models\SparePart;
+use App\Models\SparePartOrder;
+use App\Services\Import\ImportSparePartOrdersService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use PhpOffice\PhpSpreadsheet\Spreadsheet;
+use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
+use Tests\TestCase;
+
+class ImportSparePartOrdersServiceTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected $seed = true;
+
+    private string $tempFilePath;
+    private SparePart $sparePart1;
+    private SparePart $sparePart2;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        $this->sparePart1 = SparePart::factory()->create(['article' => 'TEST.0001']);
+        $this->sparePart2 = SparePart::factory()->create(['article' => 'TEST.0002']);
+    }
+
+    protected function tearDown(): void
+    {
+        if (isset($this->tempFilePath) && file_exists($this->tempFilePath)) {
+            unlink($this->tempFilePath);
+        }
+        parent::tearDown();
+    }
+
+    public function test_import_creates_spare_part_orders(): void
+    {
+        $this->createTestFile([
+            ['TEST.0001', 10, 'Да', '55/5224', 'На складе'],
+            ['TEST.0002', 5, 'Нет', '1554-55453', 'Заказано'],
+        ]);
+
+        $service = new ImportSparePartOrdersService($this->tempFilePath, 1);
+        $result = $service->handle();
+
+        $this->assertTrue($result['success']);
+        $this->assertEquals(2, $result['imported']);
+        $this->assertEquals(0, $result['errors']);
+
+        $this->assertDatabaseHas('spare_part_orders', [
+            'spare_part_id' => $this->sparePart1->id,
+            'ordered_quantity' => 10,
+            'available_qty' => 10,
+            'with_documents' => true,
+            'order_number' => '55/5224',
+            'status' => SparePartOrder::STATUS_IN_STOCK,
+        ]);
+
+        $this->assertDatabaseHas('spare_part_orders', [
+            'spare_part_id' => $this->sparePart2->id,
+            'ordered_quantity' => 5,
+            'available_qty' => 5,
+            'with_documents' => false,
+            'order_number' => '1554-55453',
+            'status' => SparePartOrder::STATUS_ORDERED,
+        ]);
+    }
+
+    public function test_import_reports_error_for_unknown_article(): void
+    {
+        $this->createTestFile([
+            ['UNKNOWN.ARTICLE', 10, 'Да', '55/5224', 'На складе'],
+            ['TEST.0001', 5, 'Да', '55/5224', 'На складе'],
+        ]);
+
+        $service = new ImportSparePartOrdersService($this->tempFilePath, 1);
+        $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([
+            ['TEST.0001', 10, 'Да', '55/5224', 'Неизвестный статус'],
+        ]);
+
+        $service = new ImportSparePartOrdersService($this->tempFilePath, 1);
+        $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([
+            ['', '', '', '', ''],
+            ['TEST.0001', 10, 'Да', '55/5224', 'На складе'],
+        ]);
+
+        $service = new ImportSparePartOrdersService($this->tempFilePath, 1);
+        $result = $service->handle();
+
+        $this->assertTrue($result['success']);
+        $this->assertEquals(1, $result['imported']);
+        $this->assertEquals(1, $result['skipped']);
+    }
+
+    public function test_import_parses_with_documents_correctly(): void
+    {
+        $sparePart3 = SparePart::factory()->create(['article' => 'TEST.0003']);
+        $sparePart4 = SparePart::factory()->create(['article' => 'TEST.0004']);
+
+        $this->createTestFile([
+            ['TEST.0001', 1, 'Да', 'order1', 'На складе'],
+            ['TEST.0002', 1, 'да', 'order2', 'На складе'],
+            ['TEST.0003', 1, 'Нет', 'order3', 'На складе'],
+            ['TEST.0004', 1, 'нет', 'order4', 'На складе'],
+        ]);
+
+        $service = new ImportSparePartOrdersService($this->tempFilePath, 1);
+        $result = $service->handle();
+
+        $this->assertTrue($result['success']);
+
+        $this->assertDatabaseHas('spare_part_orders', [
+            'spare_part_id' => $this->sparePart1->id,
+            'with_documents' => true,
+        ]);
+        $this->assertDatabaseHas('spare_part_orders', [
+            'spare_part_id' => $this->sparePart2->id,
+            'with_documents' => true,
+        ]);
+        $this->assertDatabaseHas('spare_part_orders', [
+            'spare_part_id' => $sparePart3->id,
+            'with_documents' => false,
+        ]);
+        $this->assertDatabaseHas('spare_part_orders', [
+            'spare_part_id' => $sparePart4->id,
+            'with_documents' => false,
+        ]);
+    }
+
+    public function test_import_parses_status_correctly(): void
+    {
+        $sparePart3 = SparePart::factory()->create(['article' => 'TEST.0003']);
+
+        $this->createTestFile([
+            ['TEST.0001', 1, 'Да', 'order1', 'На складе'],
+            ['TEST.0002', 1, 'Да', 'order2', 'Заказано'],
+            ['TEST.0003', 1, 'Да', 'order3', 'Отгружено'],
+        ]);
+
+        $service = new ImportSparePartOrdersService($this->tempFilePath, 1);
+        $result = $service->handle();
+
+        $this->assertTrue($result['success']);
+
+        $this->assertDatabaseHas('spare_part_orders', [
+            'spare_part_id' => $this->sparePart1->id,
+            'status' => SparePartOrder::STATUS_IN_STOCK,
+        ]);
+        $this->assertDatabaseHas('spare_part_orders', [
+            'spare_part_id' => $this->sparePart2->id,
+            'status' => SparePartOrder::STATUS_ORDERED,
+        ]);
+        $this->assertDatabaseHas('spare_part_orders', [
+            'spare_part_id' => $sparePart3->id,
+            'status' => SparePartOrder::STATUS_SHIPPED,
+        ]);
+    }
+
+    public function test_import_returns_summary(): void
+    {
+        $this->createTestFile([
+            ['TEST.0001', 10, 'Да', '55/5224', 'На складе'],
+            ['UNKNOWN', 5, 'Да', '55/5224', 'На складе'],
+            [' ', '', '', '', ''], // пробел в артикуле, после trim станет пустым
+        ]);
+
+        $service = new ImportSparePartOrdersService($this->tempFilePath, 1);
+        $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']); // UNKNOWN артикул
+
+        // Check summary in logs
+        $hasSummary = false;
+        foreach ($result['logs'] as $log) {
+            if (str_contains($log, 'РЕЗЮМЕ')) {
+                $hasSummary = true;
+                break;
+            }
+        }
+        $this->assertTrue($hasSummary, 'Should include summary in logs');
+    }
+
+    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', 'Номер заказа');
+        $sheet->setCellValue('E1', 'Статус');
+
+        // 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] ?? '');
+            $sheet->setCellValue('E' . $rowNum, $row[4] ?? '');
+            $rowNum++;
+        }
+
+        $this->tempFilePath = sys_get_temp_dir() . '/test_spare_part_orders_' . uniqid() . '.xlsx';
+        $writer = new Xlsx($spreadsheet);
+        $writer->save($this->tempFilePath);
+    }
+}