admin = User::factory()->admin()->create(); $this->manager = User::factory()->manager()->create(); $this->assistant = User::factory()->assistantHead()->create(); $this->catalogItem = CommonCatalogItem::factory()->create([ 'article' => 'ГЗ-001', 'calculator_name' => 'Игровой комплекс', ]); } public function test_authorized_roles_can_view_year_independent_order_schedule(): void { $visible = ProductionOrder::factory()->create([ 'order_number' => 'ГЗ-2026', 'order_year' => 2026, 'manager_id' => $this->manager->id, ]); ProductionOrder::factory()->create([ 'order_number' => 'ГЗ-2024', 'order_year' => 2024, 'manager_id' => $this->manager->id, ]); $this->get(route('schedule.orders'))->assertRedirect(route('login')); $this->actingAs($this->manager) ->withSession(['year' => 2020]) ->get(route('schedule.orders')) ->assertOk() ->assertViewIs('production_orders.index') ->assertSee($visible->order_number) ->assertSee('ГЗ-2024'); $driver = User::factory()->driver()->create(); $this->actingAs($driver)->get(route('schedule.orders'))->assertOk(); } public function test_admin_can_create_order_with_items_and_calendar_shipment_date(): void { ProductionCalendarDay::factory()->create([ 'date' => '2026-09-07', 'is_working_day' => false, 'name' => 'Перенесённый выходной', ]); $secondCatalogItem = CommonCatalogItem::factory()->create([ 'article' => 'ГЗ-002', 'calculator_name' => 'Качели', ]); $response = $this->actingAs($this->admin)->post( route('schedule.orders.store'), $this->validData([ 'order_number' => 'ЗАК-100', 'payment_date' => '2026-09-04', 'supply_working_days' => 1, 'items' => [ [ 'common_catalog_item_id' => $this->catalogItem->id, 'order_item_number' => 'МАФ-100/1', ], [ 'common_catalog_item_id' => $secondCatalogItem->id, 'order_item_number' => 'МАФ-100/2', 'factory_number' => 'ЗН-002', 'manufacture_date' => '2026-09-03', ], ], ]), ); $order = ProductionOrder::query()->where('order_number', 'ЗАК-100')->firstOrFail(); $response->assertRedirect(); $this->assertSame('2026-09-08', $order->contract_shipment_date->toDateString()); $this->assertSame($this->admin->id, $order->created_by); $this->assertSame(2, $order->items()->count()); $this->assertDatabaseHas('production_order_items', [ 'production_order_id' => $order->id, 'factory_number' => 'ЗН-002', 'manufacture_date' => '2026-09-03', ]); } public function test_order_number_is_unique_within_year(): void { ProductionOrder::factory()->create([ 'order_number' => 'ДУБЛЬ-1', 'order_year' => 2026, 'manager_id' => $this->manager->id, ]); $this->actingAs($this->admin) ->post(route('schedule.orders.store'), $this->validData([ 'order_number' => 'ДУБЛЬ-1', 'order_year' => 2026, ])) ->assertSessionHasErrors('order_number'); $this->actingAs($this->admin) ->post(route('schedule.orders.store'), $this->validData([ 'order_number' => 'ДУБЛЬ-1', 'order_year' => 2027, ])) ->assertSessionHasNoErrors(); $this->assertDatabaseHas('production_orders', [ 'order_number' => 'ДУБЛЬ-1', 'order_year' => 2027, ]); } public function test_manager_cannot_create_or_update_production_order(): void { $order = ProductionOrder::factory()->create(['manager_id' => $this->manager->id]); $this->actingAs($this->manager) ->post(route('schedule.orders.store'), $this->validData()) ->assertForbidden(); $this->actingAs($this->manager) ->put(route('schedule.orders.update', $order), ['note' => 'Подмена']) ->assertForbidden(); } public function test_assistant_can_update_only_allowed_order_and_item_fields(): void { $otherCatalogItem = CommonCatalogItem::factory()->create(); $order = ProductionOrder::factory()->create([ 'order_number' => 'ЗАК-OLD', 'customer_name' => 'Исходный заказчик', 'object_address' => 'Старый адрес', 'status' => ProductionOrderStatus::Placed, 'manager_id' => $this->manager->id, ]); $item = ProductionOrderItem::factory()->create([ 'production_order_id' => $order->id, 'common_catalog_item_id' => $this->catalogItem->id, 'order_item_number' => 'МАФ-OLD', ]); $this->actingAs($this->assistant) ->put(route('schedule.orders.update', $order), [ 'order_number' => 'ЗАК-FORGED', 'customer_name' => 'Подменённый заказчик', 'object_address' => 'Новый адрес', 'status' => ProductionOrderStatus::Closed->value, 'note' => 'Уточнённое примечание', 'items' => [[ 'id' => $item->id, 'common_catalog_item_id' => $otherCatalogItem->id, 'order_item_number' => 'МАФ-FORGED', 'factory_number' => 'ЗН-NEW', 'manufacture_date' => '2026-09-02', ]], ]) ->assertRedirect(); $order->refresh(); $item->refresh(); $this->assertSame('ЗАК-OLD', $order->order_number); $this->assertSame('Исходный заказчик', $order->customer_name); $this->assertSame('Новый адрес', $order->object_address); $this->assertSame(ProductionOrderStatus::Placed, $order->status); $this->assertSame('Уточнённое примечание', $order->note); $this->assertSame($this->catalogItem->id, $item->common_catalog_item_id); $this->assertSame('МАФ-OLD', $item->order_item_number); $this->assertSame('ЗН-NEW', $item->factory_number); $this->assertSame('2026-09-02', $item->manufacture_date?->toDateString()); } public function test_order_card_displays_separate_catalog_items(): void { $order = ProductionOrder::factory()->create(['manager_id' => $this->manager->id]); ProductionOrderItem::factory()->create([ 'production_order_id' => $order->id, 'common_catalog_item_id' => $this->catalogItem->id, 'order_item_number' => 'МАФ-КАРТОЧКА-1', ]); $this->actingAs($this->manager) ->get(route('schedule.orders.show', $order)) ->assertOk() ->assertSee('ГЗ-001') ->assertSee('Игровой комплекс') ->assertSee('МАФ-КАРТОЧКА-1'); } public function test_status_filter_uses_labels_and_filters_by_enum_value(): void { ProductionOrder::factory()->create([ 'order_number' => 'ГЗ-РАЗМЕЩЕН', 'status' => ProductionOrderStatus::Placed, 'manager_id' => $this->manager->id, ]); ProductionOrder::factory()->create([ 'order_number' => 'ГЗ-СКЛАД', 'status' => ProductionOrderStatus::InStock, 'manager_id' => $this->manager->id, ]); $this->actingAs($this->manager) ->getJson(route('getFilters', [ 'table' => 'schedule_orders', 'column' => 'status_name', ])) ->assertOk() ->assertJsonFragment(['Размещен']) ->assertJsonFragment(['На складе']); $this->actingAs($this->manager) ->get(route('schedule.orders', [ 'filters' => ['status_name' => 'На складе'], ])) ->assertOk() ->assertSee('ГЗ-СКЛАД') ->assertDontSee('ГЗ-РАЗМЕЩЕН'); } public function test_manager_filter_uses_names_and_filters_orders_by_manager(): void { $this->manager->update(['name' => 'Иванов Иван Иванович']); $otherManager = User::factory()->manager()->create(['name' => 'Петров Пётр Петрович']); $matching = ProductionOrder::factory()->create([ 'order_number' => 'ГЗ-МЕНЕДЖЕР-ИВАНОВ', 'manager_id' => $this->manager->id, ]); $other = ProductionOrder::factory()->create([ 'order_number' => 'ГЗ-МЕНЕДЖЕР-ПЕТРОВ', 'manager_id' => $otherManager->id, ]); $response = $this->actingAs($this->admin) ->getJson(route('getFilters', [ 'table' => 'schedule_orders', 'column' => 'manager_id', ])) ->assertOk(); $this->assertSame( ['Иванов Иван Иванович', 'Петров Пётр Петрович'], $response->json(), ); $this->assertNotContains((string) $this->manager->id, $response->json()); $this->actingAs($this->admin) ->get(route('schedule.orders', [ 'filters' => ['manager_id' => 'Иванов Иван Иванович'], ])) ->assertOk() ->assertSee($matching->order_number) ->assertDontSee($other->order_number); } public function test_order_schedule_shows_planning_dates_and_status_row_styles(): void { $closed = ProductionOrder::factory()->create([ 'order_number' => 'ГЗ-ЗАКРЫТ', 'status' => ProductionOrderStatus::Closed, 'manager_id' => $this->manager->id, ]); ProductionOrderDelivery::factory()->create([ 'production_order_id' => $closed->id, 'delivery_date' => '2026-09-14', ]); ProductionOrderInstallation::factory()->create([ 'production_order_id' => $closed->id, 'installation_date' => '2026-09-16', ]); $this->actingAs($this->admin) ->get(route('schedule.orders')) ->assertOk() ->assertSee('Дата доставки') ->assertSee('Дата монтажа') ->assertSee('14.09.2026') ->assertSee('16.09.2026') ->assertSee('table-dark opacity-50', false); } public function test_planning_date_filters_match_any_related_record(): void { $matching = ProductionOrder::factory()->create([ 'order_number' => 'ГЗ-ДАТА-НУЖНАЯ', 'manager_id' => $this->manager->id, ]); $other = ProductionOrder::factory()->create([ 'order_number' => 'ГЗ-ДАТА-ДРУГАЯ', 'manager_id' => $this->manager->id, ]); ProductionOrderDelivery::factory()->create([ 'production_order_id' => $matching->id, 'delivery_date' => '2026-09-20', ]); ProductionOrderDelivery::factory()->create([ 'production_order_id' => $other->id, 'delivery_date' => '2026-09-21', ]); $this->actingAs($this->manager) ->getJson(route('getFilters', [ 'table' => 'schedule_orders', 'column' => 'delivery_dates', ])) ->assertOk() ->assertJsonFragment(['2026-09-20']); $this->actingAs($this->manager) ->get(route('schedule.orders', [ 'filters' => ['delivery_dates' => '2026-09-20'], ])) ->assertOk() ->assertSee($matching->order_number) ->assertDontSee($other->order_number); } public function test_application_shipment_date_is_admin_only_by_default(): void { $this->assertTrue($this->admin->canViewField('schedule-orders', 'application_shipment_date')); $this->assertFalse($this->manager->canViewField('schedule-orders', 'application_shipment_date')); $order = ProductionOrder::factory()->create([ 'application_shipment_date' => '2026-11-27', 'manager_id' => $this->manager->id, ]); $this->actingAs($this->admin) ->get(route('schedule.orders')) ->assertOk() ->assertSee('Дата отгрузки по заявке') ->assertSee('27.11.2026'); $this->actingAs($this->manager) ->get(route('schedule.orders')) ->assertOk() ->assertDontSee('Дата отгрузки по заявке') ->assertDontSee('27.11.2026'); $this->actingAs($this->manager) ->get(route('schedule.orders.show', $order)) ->assertOk() ->assertDontSee('Дата отгрузки по заявке') ->assertDontSee('2026-11-27'); } public function test_order_card_displays_catalog_item_image_and_graph_actions(): void { $image = File::factory()->create([ 'link' => 'https://example.test/catalog/maf.jpg', 'mime_type' => 'image/jpeg', ]); $this->catalogItem->update(['image_file_id' => $image->id]); $order = ProductionOrder::factory()->create(['manager_id' => $this->manager->id]); ProductionOrderItem::factory()->create([ 'production_order_id' => $order->id, 'common_catalog_item_id' => $this->catalogItem->id, ]); $this->actingAs($this->admin) ->get(route('schedule.orders.show', $order)) ->assertOk() ->assertSee('https://example.test/catalog/maf.jpg', false) ->assertSee('Перенести в график доставок') ->assertSee('Перенести в график монтажей') ->assertSee('Создать рекламацию'); } 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([ 'article' => 'SEARCH-002', 'calculator_name' => 'Особенная карусель', ]); CommonCatalogItem::factory()->create([ 'article' => 'OTHER-003', 'calculator_name' => 'Другая позиция', ]); $deleted = CommonCatalogItem::factory()->create([ 'article' => 'SEARCH-DELETED', 'calculator_name' => 'Удалённая карусель', ]); $deleted->delete(); $this->actingAs($this->admin) ->getJson(route('schedule.orders.catalog-search', ['q' => 'карусель'])) ->assertOk() ->assertJsonFragment([ 'id' => $matchingByName->id, 'label' => 'SEARCH-002 — Особенная карусель', ]) ->assertJsonMissing(['id' => $deleted->id]); $this->actingAs($this->admin) ->getJson(route('schedule.orders.catalog-search', ['q' => 'ГЗ-001'])) ->assertOk() ->assertJsonFragment(['id' => $this->catalogItem->id]); $this->actingAs($this->manager) ->getJson(route('schedule.orders.catalog-search', ['q' => 'карусель'])) ->assertForbidden(); } public function test_create_form_loads_catalog_items_only_through_search(): void { $unrelated = CommonCatalogItem::factory()->create([ 'article' => 'НЕ-ЗАГРУЖАТЬ', 'calculator_name' => 'Тяжёлая позиция каталога', ]); $this->actingAs($this->admin) ->get(route('schedule.orders.create')) ->assertOk() ->assertSee('Добавить МАФ из общего каталога') ->assertSee('production-order-catalog-search', false) ->assertDontSee($unrelated->article); } /** @return array */ private function validData(array $overrides = []): array { return array_replace_recursive([ 'order_number' => 'ЗАК-001', 'order_year' => 2026, 'customer_name' => 'ООО Заказчик', 'object_address' => 'г. Москва, ул. Строителей, 1', 'invoice_number' => 'СЧ-001', 'invoice_date' => '2026-09-01', 'contract_number' => 'Д-001', 'contract_date' => '2026-08-31', 'payment_date' => '2026-09-01', 'supply_working_days' => 10, 'application_shipment_date' => '2026-09-20', 'status' => ProductionOrderStatus::Placed->value, 'execution_type' => ProductionOrderExecutionType::Delivery->value, 'manager_id' => $this->manager->id, 'note' => 'Тестовый заказ', 'items' => [[ 'common_catalog_item_id' => $this->catalogItem->id, 'order_item_number' => 'МАФ-001', 'factory_number' => null, 'manufacture_date' => null, ]], ], $overrides); } }