Преглед на файлове

export filtered production order schedule

Alexander Musikhin преди 2 седмици
родител
ревизия
b31a7faa26

+ 53 - 0
app/Http/Controllers/ProductionOrderController.php

@@ -8,6 +8,7 @@ use App\Enums\ProductionOrderExecutionType;
 use App\Enums\ProductionOrderNotificationEvent;
 use App\Enums\ProductionOrderStatus;
 use App\Http\Requests\SaveProductionOrderRequest;
+use App\Jobs\Export\ExportProductionOrdersJob;
 use App\Models\CommonCatalogItem;
 use App\Models\ProductionOrder;
 use App\Models\Role;
@@ -130,6 +131,58 @@ class ProductionOrderController extends Controller
         return $this->orderView($request, $fieldAccess);
     }
 
+    public function export(Request $request): RedirectResponse
+    {
+        $validated = $request->validate([
+            'filters' => ['nullable', 'array'],
+            'filters.*' => ['nullable', 'string', 'max:1000'],
+            's' => ['nullable', 'string', 'max:500'],
+        ]);
+        $request->merge([
+            'filters' => Arr::only($validated['filters'] ?? [], [
+                'id',
+                'order_number',
+                'customer_name',
+                'object_address',
+                'invoice_number',
+                'payment_date',
+                'payment_date_from',
+                'payment_date_to',
+                'supply_working_days',
+                'supply_working_days_from',
+                'supply_working_days_to',
+                'contract_shipment_date',
+                'contract_shipment_date_from',
+                'contract_shipment_date_to',
+                'application_shipment_date',
+                'application_shipment_date_from',
+                'application_shipment_date_to',
+                'status_name',
+                'execution_type_name',
+                'manager_id',
+                'note',
+            ]),
+            's' => $validated['s'] ?? null,
+        ]);
+        $this->data['dates'] = array_fill_keys([
+            'payment_date',
+            'contract_shipment_date',
+            'application_shipment_date',
+        ], true);
+        $this->data['ranges'] = ['supply_working_days' => true];
+
+        $query = ProductionOrder::query();
+        $this->acceptFilters($query, $request);
+        $this->acceptSearch($query, $request);
+        $orderIds = $query->orderByDesc('created_at')->orderByDesc('id')->pluck('id')->all();
+
+        ExportProductionOrdersJob::dispatch($orderIds, (int) $request->user()->id);
+
+        return redirect()
+            ->route('schedule.orders', session('gp_schedule_orders'))
+            ->with('success', 'Задача экспорта графика заказов создана.');
+    }
+
     public function show(
         Request $request,
         ProductionOrder $productionOrder,

+ 54 - 0
app/Jobs/Export/ExportProductionOrdersJob.php

@@ -0,0 +1,54 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Jobs\Export;
+
+use App\Events\SendWebSocketMessageEvent;
+use App\Services\Export\ExportProductionOrdersService;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Queue\Queueable;
+use Illuminate\Support\Facades\Log;
+use Throwable;
+
+class ExportProductionOrdersJob implements ShouldQueue
+{
+    use Queueable;
+
+    public int $timeout = 600;
+
+    public int $tries = 2;
+
+    /** @param list<int> $orderIds */
+    public function __construct(
+        public readonly array $orderIds,
+        public readonly int $userId,
+    ) {}
+
+    public function handle(ExportProductionOrdersService $service): void
+    {
+        try {
+            $file = $service->handle($this->orderIds, $this->userId);
+
+            event(new SendWebSocketMessageEvent(
+                'Экспорт графика заказов завершён!',
+                $this->userId,
+                ['link' => $file->link],
+            ));
+        } catch (Throwable $exception) {
+            Log::error('Production orders export failed.', [
+                'user_id' => $this->userId,
+                'orders_count' => count($this->orderIds),
+                'error' => $exception->getMessage(),
+            ]);
+
+            event(new SendWebSocketMessageEvent(
+                'Ошибка экспорта графика заказов: '.$exception->getMessage(),
+                $this->userId,
+                ['error' => $exception->getMessage()],
+            ));
+
+            throw $exception;
+        }
+    }
+}

+ 129 - 0
app/Services/Export/ExportProductionOrdersService.php

@@ -0,0 +1,129 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Services\Export;
+
+use App\Models\File;
+use App\Models\ProductionOrder;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Str;
+use PhpOffice\PhpSpreadsheet\Cell\DataType;
+use PhpOffice\PhpSpreadsheet\Spreadsheet;
+use PhpOffice\PhpSpreadsheet\Style\Alignment;
+use PhpOffice\PhpSpreadsheet\Style\Border;
+use PhpOffice\PhpSpreadsheet\Style\Fill;
+use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
+
+class ExportProductionOrdersService
+{
+    public const HEADERS = [
+        'ID',
+        'Номер заказа',
+        'Год',
+        'Заказчик',
+        'Адрес объекта',
+        'Номер счёта',
+        'Дата счёта',
+        'Номер договора',
+        'Дата договора',
+        'Дата оплаты',
+        'Срок поставки, раб. дней',
+        'Дата отгрузки по договору',
+        'Дата отгрузки по заявке',
+        'Статус',
+        'Тип исполнения',
+        'Менеджер',
+        'Оборудование',
+        'Примечание',
+    ];
+
+    /** @param list<int> $orderIds */
+    public function handle(array $orderIds, int $userId): File
+    {
+        $spreadsheet = new Spreadsheet;
+        $sheet = $spreadsheet->getActiveSheet();
+        $sheet->setTitle('График заказов');
+        $sheet->fromArray(self::HEADERS, null, 'A1');
+        $sheet->getStyle('A1:R1')->getFont()->setBold(true);
+        $sheet->getStyle('A1:R1')->getFill()
+            ->setFillType(Fill::FILL_SOLID)
+            ->getStartColor()->setARGB('FFD9EAF7');
+
+        $row = 2;
+        ProductionOrder::query()
+            ->with(['manager', 'items.catalogItem'])
+            ->whereKey($orderIds)
+            ->orderByDesc('created_at')
+            ->orderByDesc('id')
+            ->chunk(200, function ($orders) use ($sheet, &$row): void {
+                foreach ($orders as $order) {
+                    $sheet->setCellValue("A{$row}", $order->id);
+                    $sheet->setCellValueExplicit("B{$row}", $order->order_number, DataType::TYPE_STRING);
+                    $sheet->setCellValue("C{$row}", $order->order_year);
+                    $sheet->setCellValue("D{$row}", $order->customer_name);
+                    $sheet->setCellValue("E{$row}", $order->object_address);
+                    $sheet->setCellValueExplicit("F{$row}", $order->invoice_number, DataType::TYPE_STRING);
+                    $sheet->setCellValue("G{$row}", $order->invoice_date?->format('d.m.Y'));
+                    $sheet->setCellValueExplicit("H{$row}", (string) $order->contract_number, DataType::TYPE_STRING);
+                    $sheet->setCellValue("I{$row}", $order->contract_date?->format('d.m.Y'));
+                    $sheet->setCellValue("J{$row}", $order->payment_date?->format('d.m.Y'));
+                    $sheet->setCellValue("K{$row}", $order->supply_working_days);
+                    $sheet->setCellValue("L{$row}", $order->contract_shipment_date?->format('d.m.Y'));
+                    $sheet->setCellValue("M{$row}", $order->application_shipment_date?->format('d.m.Y'));
+                    $sheet->setCellValue("N{$row}", $order->statusLabel());
+                    $sheet->setCellValue("O{$row}", $order->executionTypeLabel());
+                    $sheet->setCellValue("P{$row}", $order->manager?->name);
+                    $sheet->setCellValue("Q{$row}", $this->equipmentSummary($order));
+                    $sheet->setCellValue("R{$row}", $order->note);
+                    $row++;
+                }
+            });
+
+        $lastRow = max(1, $row - 1);
+        $sheet->getStyle("A1:R{$lastRow}")->getBorders()->getAllBorders()->setBorderStyle(Border::BORDER_THIN);
+        $sheet->getStyle("A1:R{$lastRow}")->getAlignment()->setWrapText(true)->setVertical(Alignment::VERTICAL_TOP);
+        $sheet->freezePane('A2');
+        $sheet->setAutoFilter("A1:R{$lastRow}");
+        foreach (range('A', 'R') as $column) {
+            $sheet->getColumnDimension($column)->setWidth(in_array($column, ['D', 'E', 'Q', 'R'], true) ? 36 : 18);
+        }
+
+        $filename = 'График_заказов_'.now()->format('Y-m-d_H-i-s').'.xlsx';
+        $path = 'generated/production-orders/'.$userId.'/'.Str::uuid().'/'.$filename;
+        Storage::disk('local')->makeDirectory(dirname($path));
+        (new Xlsx($spreadsheet))->save(Storage::disk('local')->path($path));
+        $spreadsheet->disconnectWorksheets();
+
+        $file = File::query()->create([
+            'user_id' => $userId,
+            'original_name' => $filename,
+            'mime_type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+            'path' => $path,
+            'link' => '',
+            'is_generated' => true,
+        ]);
+        $file->update(['link' => route('schedule.orders.files.download', $file)]);
+
+        return $file;
+    }
+
+    private function equipmentSummary(ProductionOrder $order): string
+    {
+        return $order->items
+            ->groupBy(static fn ($item): string => implode('|', [
+                $item->catalogItem?->article,
+                $item->catalogItem?->calculator_name ?: $item->catalogItem?->print_name,
+            ]))
+            ->map(static function ($items): string {
+                $item = $items->first();
+                $name = $item->catalogItem?->calculator_name ?: $item->catalogItem?->print_name;
+                $label = collect([$item->catalogItem?->article, $name])
+                    ->filter(static fn ($value): bool => filled($value))
+                    ->implode(' — ');
+
+                return ($label !== '' ? $label.' — ' : '').$items->count().' шт.';
+            })
+            ->implode("\n");
+    }
+}

+ 1 - 0
config/access_routes.php

@@ -97,6 +97,7 @@ return [
             'create' => 'schedule-orders.create',
             'store' => 'schedule-orders.create',
             'catalog-search' => 'schedule-orders.items.manage',
+            'export' => 'schedule-orders.export',
             'show' => 'schedule-orders.view',
             'update' => 'schedule-orders.update',
             'destroy' => 'schedule-orders.delete',

+ 3 - 2
docs/refactor/plan.md

@@ -271,7 +271,7 @@
 6. **9.6. Обмен данными** — экспорт списка, XLS-импорт после уточнения формата, API 1С с журналом загрузок и отдельная миграция старых данных.
 7. **9.7. Рекламации и приёмка** — связь с этапом 10, полные автотесты и пользовательская проверка.
 
-Текущий подэтап: **9.6. Обмен данными**; экспорт списка готов к реализации, XLS-импорт и 1С ожидают согласования контракта.
+Текущий подэтап: **9.6. Обмен данными**; фоновый экспорт списка с активными фильтрами реализован, XLS-импорт и 1С ожидают согласования контракта.
 
 - [x] **9.1. Основа данных и права:** добавлены нормализованные таблицы и модели, enum статусов/типов, производственный календарь, роль `driver`, действия и permissions отдельных полей.
 - [x] **9.2. Ручной заказ:** реализованы список с фильтрами, цветовой статус, карточка, создание/редактирование, расчёт договорной даты отгрузки и отдельные экземпляры МАФ из общего каталога.
@@ -303,7 +303,8 @@
 - [x] Добавить карточку заказа с выбором одного или нескольких МАФ.
 - [x] Добавить ручное создание заказа, файлы, фотографии, чат, паспорт каждого МАФ и журнал согласованных действий.
 - [x] Добавить статусы, типы исполнения, цветовую индикацию и настраиваемые уведомления.
-- [ ] Реализовать импорт/экспорт списка заказов и экспорт МАФ одного заказа.
+- [x] Реализовать экспорт списка заказов с активными фильтрами и экспорт МАФ одного заказа.
+- [ ] Реализовать обновление заказов ручным XLS-импортом после согласования состава строк отдельных экземпляров МАФ.
 - [x] Реализовать пакеты документов для монтажа, доставки и технической документации.
 - [ ] Добавить создание рекламации из заказа через текущую форму.
 - [x] Получить базовые требования и шаблон заявки для `Графика доставок`.

+ 15 - 0
resources/views/production_orders/index.blade.php

@@ -6,6 +6,21 @@
             <h3>График заказов</h3>
         </div>
         <div class="col-12 col-md-6 text-md-end page-header-actions">
+            @if(hasPermission('schedule-orders.export'))
+                <form action="{{ route('schedule.orders.export') }}" method="POST" class="d-inline">
+                    @csrf
+                    @foreach((array) request()->input('filters', []) as $filter => $value)
+                        <input type="hidden" name="filters[{{ $filter }}]" value="{{ $value }}">
+                    @endforeach
+                    @if(request()->filled('s'))
+                        <input type="hidden" name="s" value="{{ request()->input('s') }}">
+                    @endif
+                    <button type="submit" class="btn btn-sm btn-outline-primary page-action-btn">
+                        <i class="bi bi-file-earmark-spreadsheet page-action-btn__icon"></i>
+                        <span class="page-action-btn__label">Экспорт</span>
+                    </button>
+                </form>
+            @endif
             @if(hasPermission('schedule-orders.create'))
                 <a href="{{ route('schedule.orders.create', ['nav' => $nav ?? null]) }}"
                    class="btn btn-sm btn-primary page-action-btn">

+ 1 - 0
routes/web.php

@@ -186,6 +186,7 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
         Route::get('orders/create', [ProductionOrderController::class, 'create'])->name('orders.create');
         Route::get('orders/catalog-search', [ProductionOrderController::class, 'searchCatalogItems'])
             ->name('orders.catalog-search');
+        Route::post('orders/export', [ProductionOrderController::class, 'export'])->name('orders.export');
         Route::post('orders', [ProductionOrderController::class, 'store'])->name('orders.store');
         Route::get('orders/{productionOrder}', [ProductionOrderController::class, 'show'])->name('orders.show');
         Route::put('orders/{productionOrder}', [ProductionOrderController::class, 'update'])->name('orders.update');

+ 50 - 0
tests/Feature/ProductionOrderControllerTest.php

@@ -6,12 +6,14 @@ namespace Tests\Feature;
 
 use App\Enums\ProductionOrderExecutionType;
 use App\Enums\ProductionOrderStatus;
+use App\Jobs\Export\ExportProductionOrdersJob;
 use App\Models\CommonCatalogItem;
 use App\Models\ProductionCalendarDay;
 use App\Models\ProductionOrder;
 use App\Models\ProductionOrderItem;
 use App\Models\User;
 use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Bus;
 use Tests\TestCase;
 
 class ProductionOrderControllerTest extends TestCase
@@ -250,6 +252,54 @@ class ProductionOrderControllerTest extends TestCase
             ->assertDontSee('ГЗ-РАЗМЕЩЕН');
     }
 
+    public function test_admin_can_export_only_orders_matching_active_filters_and_search(): void
+    {
+        Bus::fake([ExportProductionOrdersJob::class]);
+        $matching = ProductionOrder::factory()->create([
+            'order_number' => 'ЭКСПОРТ-НУЖНЫЙ',
+            'customer_name' => 'Целевой заказчик',
+            'status' => ProductionOrderStatus::InStock,
+            'manager_id' => $this->manager->id,
+        ]);
+        ProductionOrder::factory()->create([
+            'order_number' => 'ЭКСПОРТ-ДРУГОЙ-СТАТУС',
+            'customer_name' => 'Целевой заказчик',
+            'status' => ProductionOrderStatus::Placed,
+            'manager_id' => $this->manager->id,
+        ]);
+        ProductionOrder::factory()->create([
+            'order_number' => 'ЭКСПОРТ-ДРУГОЙ-ПОИСК',
+            'customer_name' => 'Другой заказчик',
+            'status' => ProductionOrderStatus::InStock,
+            'manager_id' => $this->manager->id,
+        ]);
+
+        $this->actingAs($this->admin)
+            ->post(route('schedule.orders.export'), [
+                'filters' => ['status_name' => ProductionOrderStatus::InStock->label()],
+                's' => 'Целевой',
+            ])
+            ->assertRedirect(route('schedule.orders'))
+            ->assertSessionHas('success');
+
+        Bus::assertDispatched(
+            ExportProductionOrdersJob::class,
+            static fn (ExportProductionOrdersJob $job): bool => $job->orderIds === [$matching->id]
+                && $job->userId > 0,
+        );
+    }
+
+    public function test_production_order_export_requires_export_permission(): void
+    {
+        Bus::fake([ExportProductionOrdersJob::class]);
+
+        $this->actingAs($this->manager)
+            ->post(route('schedule.orders.export'))
+            ->assertForbidden();
+
+        Bus::assertNotDispatched(ExportProductionOrdersJob::class);
+    }
+
     public function test_admin_can_search_common_catalog_when_adding_order_items(): void
     {
         $matchingByName = CommonCatalogItem::factory()->create([

+ 108 - 0
tests/Unit/Services/Export/ExportProductionOrdersServiceTest.php

@@ -0,0 +1,108 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tests\Unit\Services\Export;
+
+use App\Enums\ProductionOrderExecutionType;
+use App\Enums\ProductionOrderStatus;
+use App\Models\CommonCatalogItem;
+use App\Models\ProductionOrder;
+use App\Models\ProductionOrderItem;
+use App\Models\User;
+use App\Services\Export\ExportProductionOrdersService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Storage;
+use PhpOffice\PhpSpreadsheet\IOFactory;
+use Tests\TestCase;
+
+class ExportProductionOrdersServiceTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected bool $seed = true;
+
+    public function test_export_contains_all_order_fields_and_aggregated_equipment(): void
+    {
+        Storage::fake('local');
+        $user = User::factory()->admin()->create();
+        $manager = User::factory()->manager()->create(['name' => 'Менеджер экспорта']);
+        $playground = CommonCatalogItem::factory()->create([
+            'article' => '0007',
+            'calculator_name' => 'Игровой комплекс',
+        ]);
+        $swing = CommonCatalogItem::factory()->create([
+            'article' => '0011',
+            'calculator_name' => 'Качели',
+        ]);
+        $order = ProductionOrder::factory()->create([
+            'order_number' => '0000123',
+            'order_year' => 2026,
+            'customer_name' => 'ООО Заказчик',
+            'object_address' => 'г. Москва, ул. Строителей, 1',
+            'invoice_number' => '0000456',
+            'invoice_date' => '2026-08-30',
+            'contract_number' => '0000789',
+            'contract_date' => '2026-08-31',
+            'payment_date' => '2026-09-01',
+            'supply_working_days' => 10,
+            'contract_shipment_date' => '2026-09-15',
+            'application_shipment_date' => '2026-09-20',
+            'status' => ProductionOrderStatus::InStock,
+            'execution_type' => ProductionOrderExecutionType::DeliveryAndInstallation,
+            'manager_id' => $manager->id,
+            'note' => 'Примечание к заказу',
+        ]);
+        ProductionOrderItem::factory()->count(2)->create([
+            'production_order_id' => $order->id,
+            'common_catalog_item_id' => $playground->id,
+        ]);
+        ProductionOrderItem::factory()->create([
+            'production_order_id' => $order->id,
+            'common_catalog_item_id' => $swing->id,
+        ]);
+        $excluded = ProductionOrder::factory()->create(['manager_id' => $manager->id]);
+
+        $file = app(ExportProductionOrdersService::class)->handle([$order->id], $user->id);
+
+        Storage::disk('local')->assertExists($file->path);
+        $this->assertTrue($file->is_generated);
+        $this->assertSame($user->id, $file->user_id);
+        $this->assertStringContainsString('/schedules/orders/files/', $file->link);
+
+        $sheet = IOFactory::load(Storage::disk('local')->path($file->path))->getActiveSheet();
+        $this->assertSame(
+            ExportProductionOrdersService::HEADERS,
+            $sheet->rangeToArray('A1:R1')[0],
+        );
+        $this->assertSame($order->id, $sheet->getCell('A2')->getValue());
+        $this->assertSame('0000123', $sheet->getCell('B2')->getValue());
+        $this->assertSame('0000456', $sheet->getCell('F2')->getValue());
+        $this->assertSame('0000789', $sheet->getCell('H2')->getValue());
+        $this->assertSame('01.09.2026', $sheet->getCell('J2')->getValue());
+        $this->assertSame(ProductionOrderStatus::InStock->label(), $sheet->getCell('N2')->getValue());
+        $this->assertSame(
+            ProductionOrderExecutionType::DeliveryAndInstallation->label(),
+            $sheet->getCell('O2')->getValue(),
+        );
+        $this->assertSame('Менеджер экспорта', $sheet->getCell('P2')->getValue());
+        $this->assertStringContainsString('0007 — Игровой комплекс — 2 шт.', $sheet->getCell('Q2')->getValue());
+        $this->assertStringContainsString('0011 — Качели — 1 шт.', $sheet->getCell('Q2')->getValue());
+        $this->assertSame('Примечание к заказу', $sheet->getCell('R2')->getValue());
+        $this->assertNull($sheet->getCell('A3')->getValue());
+        $this->assertNotSame($excluded->id, $sheet->getCell('A2')->getValue());
+    }
+
+    public function test_export_can_create_an_empty_workbook(): void
+    {
+        Storage::fake('local');
+        $user = User::factory()->admin()->create();
+
+        $file = app(ExportProductionOrdersService::class)->handle([], $user->id);
+
+        Storage::disk('local')->assertExists($file->path);
+        $sheet = IOFactory::load(Storage::disk('local')->path($file->path))->getActiveSheet();
+        $this->assertSame(ExportProductionOrdersService::HEADERS, $sheet->rangeToArray('A1:R1')[0]);
+        $this->assertNull($sheet->getCell('A2')->getValue());
+    }
+}