Explorar o código

implement production order schedule core

Alexander Musikhin hai 2 semanas
pai
achega
eb59396118
Modificáronse 39 ficheiros con 2359 adicións e 12 borrados
  1. 12 0
      app/Enums/ProductionOrderActivityType.php
  2. 35 0
      app/Enums/ProductionOrderExecutionType.php
  3. 13 0
      app/Enums/ProductionOrderSource.php
  4. 47 0
      app/Enums/ProductionOrderStatus.php
  5. 10 0
      app/Http/Controllers/FilterController.php
  6. 287 0
      app/Http/Controllers/ProductionOrderController.php
  7. 101 0
      app/Http/Requests/SaveProductionOrderRequest.php
  8. 29 0
      app/Models/ProductionCalendarDay.php
  9. 124 0
      app/Models/ProductionOrder.php
  10. 60 0
      app/Models/ProductionOrderActivity.php
  11. 65 0
      app/Models/ProductionOrderDelivery.php
  12. 51 0
      app/Models/ProductionOrderInstallation.php
  13. 63 0
      app/Models/ProductionOrderItem.php
  14. 3 0
      app/Models/Role.php
  15. 44 0
      app/Services/ProductionCalendarService.php
  16. 128 0
      app/Services/ProductionOrderService.php
  17. 42 0
      config/access.php
  18. 9 1
      config/access_routes.php
  19. 25 0
      database/factories/ProductionCalendarDayFactory.php
  20. 29 0
      database/factories/ProductionOrderActivityFactory.php
  21. 42 0
      database/factories/ProductionOrderDeliveryFactory.php
  22. 48 0
      database/factories/ProductionOrderFactory.php
  23. 29 0
      database/factories/ProductionOrderInstallationFactory.php
  24. 31 0
      database/factories/ProductionOrderItemFactory.php
  25. 7 0
      database/factories/UserFactory.php
  26. 137 0
      database/migrations/2026_09_03_000001_create_production_order_tables.php
  27. 26 0
      database/migrations/2026_09_03_000002_sync_production_order_permissions.php
  28. 28 2
      database/seeders/RbacSeeder.php
  29. 36 4
      docs/refactor/plan.md
  30. 4 0
      resources/views/partials/table.blade.php
  31. 257 0
      resources/views/production_orders/edit.blade.php
  32. 29 0
      resources/views/production_orders/index.blade.php
  33. 52 0
      resources/views/production_orders/partials/item-row.blade.php
  34. 9 4
      routes/web.php
  35. 330 0
      tests/Feature/ProductionOrderControllerTest.php
  36. 4 1
      tests/Unit/Helpers/RolesHelperTest.php
  37. 39 0
      tests/Unit/Models/ProductionOrderTest.php
  38. 18 0
      tests/Unit/Services/AccessServiceTest.php
  39. 56 0
      tests/Unit/Services/ProductionCalendarServiceTest.php

+ 12 - 0
app/Enums/ProductionOrderActivityType.php

@@ -0,0 +1,12 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Enums;
+
+enum ProductionOrderActivityType: string
+{
+    case StatusChanged = 'status_changed';
+    case DeliveryScheduled = 'delivery_scheduled';
+    case InstallationScheduled = 'installation_scheduled';
+}

+ 35 - 0
app/Enums/ProductionOrderExecutionType.php

@@ -0,0 +1,35 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Enums;
+
+enum ProductionOrderExecutionType: string
+{
+    case Pickup = 'pickup';
+    case Delivery = 'delivery';
+    case Installation = 'installation';
+    case DeliveryAndInstallation = 'delivery_and_installation';
+    case DeliveryAndSupervision = 'delivery_and_supervision';
+    case Supervision = 'supervision';
+
+    public const LABELS = [
+        self::Pickup->value => 'Самовывоз',
+        self::Delivery->value => 'Доставка',
+        self::Installation->value => 'Монтаж',
+        self::DeliveryAndInstallation->value => 'Доставка + Монтаж',
+        self::DeliveryAndSupervision->value => 'Доставка + Шеф-монтаж',
+        self::Supervision->value => 'Шеф-монтаж',
+    ];
+
+    public function label(): string
+    {
+        return self::LABELS[$this->value];
+    }
+
+    /** @return array<string, string> */
+    public static function options(): array
+    {
+        return self::LABELS;
+    }
+}

+ 13 - 0
app/Enums/ProductionOrderSource.php

@@ -0,0 +1,13 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Enums;
+
+enum ProductionOrderSource: string
+{
+    case Manual = 'manual';
+    case OneC = '1c';
+    case Spreadsheet = 'spreadsheet';
+    case Legacy = 'legacy';
+}

+ 47 - 0
app/Enums/ProductionOrderStatus.php

@@ -0,0 +1,47 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Enums;
+
+enum ProductionOrderStatus: string
+{
+    case Placed = 'placed';
+    case ReadyAtFactory = 'ready_at_factory';
+    case InTransit = 'in_transit';
+    case PartialReceipt = 'partial_receipt';
+    case InStock = 'in_stock';
+    case Closed = 'closed';
+
+    public const LABELS = [
+        self::Placed->value => 'Размещен',
+        self::ReadyAtFactory->value => 'Готов на фабрике',
+        self::InTransit->value => 'В пути',
+        self::PartialReceipt->value => 'Частичное поступление',
+        self::InStock->value => 'На складе',
+        self::Closed->value => 'Закрыт',
+    ];
+
+    public function label(): string
+    {
+        return self::LABELS[$this->value];
+    }
+
+    public function color(): string
+    {
+        return match ($this) {
+            self::Placed => 'secondary',
+            self::ReadyAtFactory => 'info',
+            self::InTransit => 'primary',
+            self::PartialReceipt => 'warning',
+            self::InStock => 'success',
+            self::Closed => 'dark',
+        };
+    }
+
+    /** @return array<string, string> */
+    public static function options(): array
+    {
+        return self::LABELS;
+    }
+}

+ 10 - 0
app/Http/Controllers/FilterController.php

@@ -29,6 +29,7 @@ class FilterController extends Controller
         'common_catalog_items' => 'common_catalog_items',
         'stock_availability' => 'common_catalog_items',
         'stock_orders' => 'stock_orders',
+        'schedule_orders' => 'production_orders',
     ];
 
     const SKIP_YEAR_FILTER = [
@@ -73,6 +74,10 @@ class FilterController extends Controller
         'stock_orders' => [
             'status_name' => 'status',
         ],
+        'schedule_orders' => [
+            'status_name' => 'status',
+            'execution_type_name' => 'execution_type',
+        ],
     ];
 
     /**
@@ -129,10 +134,15 @@ class FilterController extends Controller
         'stock_orders' => [
             'status' => StockOrder::STATUS_NAMES,
         ],
+        'schedule_orders' => [
+            'status' => \App\Enums\ProductionOrderStatus::LABELS,
+            'execution_type' => \App\Enums\ProductionOrderExecutionType::LABELS,
+        ],
     ];
 
     private const FIELD_ACCESS_MODULES = [
         'common_catalog_items' => 'common-catalog',
+        'schedule_orders' => 'schedule-orders',
     ];
 
     private const SESSION_KEYS = [

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

@@ -0,0 +1,287 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Http\Controllers;
+
+use App\Enums\ProductionOrderExecutionType;
+use App\Enums\ProductionOrderStatus;
+use App\Http\Requests\SaveProductionOrderRequest;
+use App\Models\CommonCatalogItem;
+use App\Models\ProductionOrder;
+use App\Models\Role;
+use App\Models\User;
+use App\Services\Access\FieldAccessService;
+use App\Services\ProductionOrderService;
+use Illuminate\Contracts\View\View;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+use Illuminate\Support\Arr;
+
+class ProductionOrderController extends Controller
+{
+    private const FIELD_MAP = [
+        'status_name' => 'status',
+        'execution_type_name' => 'execution_type',
+        'items_count' => 'id',
+    ];
+
+    protected array $data = [
+        'active' => 'schedule_orders',
+        'title' => 'График заказов',
+        'id' => 'schedule_orders',
+        'header' => [
+            'id' => 'ID',
+            'order_number' => 'Номер заказа',
+            'customer_name' => 'Заказчик',
+            'object_address' => 'Адрес объекта',
+            'invoice_number' => '№ счёта',
+            'payment_date' => 'Дата оплаты',
+            'supply_working_days' => 'Срок поставки',
+            'contract_shipment_date' => 'Дата отгрузки по договору',
+            'application_shipment_date' => 'Дата отгрузки по заявке',
+            'status_name' => 'Статус',
+            'execution_type_name' => 'Тип',
+            'manager_id' => 'Менеджер',
+            'items_count' => 'МАФ',
+            'note' => 'Примечание',
+        ],
+        'searchFields' => [
+            'order_number',
+            'customer_name',
+            'object_address',
+            'invoice_number',
+            'contract_number',
+            'note',
+        ],
+    ];
+
+    public function index(Request $request): View
+    {
+        session(['gp_schedule_orders' => $request->query()]);
+        $nav = $this->startNavigationContext($request);
+        $model = new ProductionOrder;
+
+        $this->data['header'] = array_filter(
+            $this->data['header'],
+            fn (string $field): bool => $request->user()->canViewField(
+                'schedule-orders',
+                self::FIELD_MAP[$field] ?? $field,
+            ),
+            ARRAY_FILTER_USE_KEY,
+        );
+        $this->data['searchFields'] = array_values(array_filter(
+            $this->data['searchFields'],
+            fn (string $field): bool => $request->user()->canViewField('schedule-orders', $field),
+        ));
+        $this->createDateFilters(
+            $model,
+            ...array_values(array_intersect([
+                'payment_date',
+                'contract_shipment_date',
+                'application_shipment_date',
+            ], array_keys($this->data['header']))),
+        );
+        if (isset($this->data['header']['supply_working_days'])) {
+            $this->createRangeFilters($model, 'supply_working_days');
+        }
+        foreach (['status_name', 'execution_type_name'] as $field) {
+            if (isset($this->data['header'][$field])) {
+                $this->data['filters'][$field] = [
+                    'title' => $this->data['header'][$field],
+                    'values' => [],
+                ];
+            }
+        }
+
+        $query = ProductionOrder::query()->with('manager')->withCount('items');
+        $this->acceptFilters($query, $request);
+        $this->acceptSearch($query, $request);
+
+        $requestedSort = $request->string('sortBy')->toString();
+        $sortMap = [
+            'status_name' => 'status',
+            'execution_type_name' => 'execution_type',
+            'items_count' => 'items_count',
+        ];
+        if (isset($sortMap[$requestedSort])) {
+            $request->merge(['sortBy' => $sortMap[$requestedSort]]);
+        }
+        $this->setSortAndOrderBy($model, $request);
+        if ($requestedSort !== '' && isset($sortMap[$requestedSort])) {
+            $this->data['sortBy'] = $requestedSort;
+            $query->orderBy($sortMap[$requestedSort], $this->data['orderBy']);
+            $query->orderBy('id', $this->data['orderBy']);
+        } else {
+            $this->applyStableSorting($query);
+        }
+
+        $this->data['orders'] = $query->paginate($this->data['per_page'])->withQueryString();
+        $this->data['nav'] = $nav;
+
+        return view('production_orders.index', $this->data);
+    }
+
+    public function create(Request $request, FieldAccessService $fieldAccess): View
+    {
+        return $this->orderView($request, $fieldAccess);
+    }
+
+    public function show(
+        Request $request,
+        ProductionOrder $productionOrder,
+        FieldAccessService $fieldAccess,
+    ): View {
+        return $this->orderView(
+            $request,
+            $fieldAccess,
+            $productionOrder->load(['manager', 'items.catalogItem.imageFile']),
+        );
+    }
+
+    public function store(
+        SaveProductionOrderRequest $request,
+        FieldAccessService $fieldAccess,
+        ProductionOrderService $service,
+    ): RedirectResponse {
+        $validated = $request->validated();
+        $items = $validated['items'];
+        $fields = array_keys(config('access.schedule-orders.fields', []));
+        $payload = $fieldAccess->filterValidatedPayload(
+            $request->user(),
+            'schedule-orders',
+            Arr::only($validated, $fields),
+        );
+        $payload['items'] = $items;
+
+        $order = $service->create($payload, $request->user());
+
+        return redirect()->route('schedule.orders.show', $this->withNav(
+            ['productionOrder' => $order],
+            $this->resolveNavToken($request),
+        ))->with('success', 'Заказ создан.');
+    }
+
+    public function update(
+        SaveProductionOrderRequest $request,
+        ProductionOrder $productionOrder,
+        FieldAccessService $fieldAccess,
+        ProductionOrderService $service,
+    ): RedirectResponse {
+        $validated = $request->validated();
+        $fields = array_keys(config('access.schedule-orders.fields', []));
+        $payload = $fieldAccess->filterValidatedPayload(
+            $request->user(),
+            'schedule-orders',
+            Arr::only($validated, $fields),
+        );
+        $canManageItems = $request->user()->hasPermission('schedule-orders.items.manage');
+        $editableItemFields = array_values(array_filter(
+            ['factory_number', 'manufacture_date'],
+            fn (string $field): bool => $request->user()->canUpdateField('schedule-orders', $field),
+        ));
+        if (($canManageItems || $editableItemFields !== []) && isset($validated['items'])) {
+            $payload['items'] = $validated['items'];
+        }
+
+        $service->update(
+            $productionOrder,
+            $payload,
+            $request->user(),
+            $canManageItems,
+            $editableItemFields,
+        );
+
+        return redirect()->route('schedule.orders.show', $this->withNav(
+            ['productionOrder' => $productionOrder],
+            $this->resolveNavToken($request),
+        ))->with('success', 'Заказ обновлён.');
+    }
+
+    public function destroy(Request $request, ProductionOrder $productionOrder): RedirectResponse
+    {
+        $productionOrder->delete();
+
+        return redirect()
+            ->route('schedule.orders', session('gp_schedule_orders'))
+            ->with('success', 'Заказ удалён.');
+    }
+
+    public function searchCatalogItems(Request $request): JsonResponse
+    {
+        $validated = $request->validate([
+            'q' => ['nullable', 'string', 'max:200'],
+        ]);
+        $search = trim((string) ($validated['q'] ?? ''));
+        if (mb_strlen($search) < 2) {
+            return response()->json([]);
+        }
+
+        $items = CommonCatalogItem::query()
+            ->where(function ($query) use ($search): void {
+                $query
+                    ->where('article', 'like', "%{$search}%")
+                    ->orWhere('calculator_name', 'like', "%{$search}%")
+                    ->orWhere('print_name', 'like', "%{$search}%");
+            })
+            ->orderBy('article')
+            ->limit(50)
+            ->get(['id', 'article', 'calculator_name', 'print_name'])
+            ->map(static fn (CommonCatalogItem $item): array => [
+                'id' => $item->id,
+                'label' => $item->article.' — '.($item->calculator_name ?: $item->print_name ?: 'Без наименования'),
+            ])
+            ->values();
+
+        return response()->json($items);
+    }
+
+    private function orderView(
+        Request $request,
+        FieldAccessService $fieldAccess,
+        ?ProductionOrder $order = null,
+    ): View {
+        $nav = $this->resolveNavToken($request);
+        $this->rememberNavigation($request, $nav);
+        $fields = array_keys(config('access.schedule-orders.fields', []));
+        $catalogItemIds = collect($order?->items ?? [])
+            ->pluck('common_catalog_item_id')
+            ->merge(collect($request->old('items', []))->pluck('common_catalog_item_id'))
+            ->filter()
+            ->unique()
+            ->values();
+
+        return view('production_orders.edit', [
+            'active' => 'schedule_orders',
+            'title' => $order ? 'Заказ '.$order->order_number : 'Новый заказ',
+            'order' => $order,
+            'nav' => $nav,
+            'back_url' => $this->navigationBackUrl(
+                $request,
+                $nav,
+                route('schedule.orders', session('gp_schedule_orders')),
+            ),
+            'readableFields' => array_fill_keys(
+                $fieldAccess->filterReadableFields($request->user(), 'schedule-orders', $fields),
+                true,
+            ),
+            'writableFields' => collect($fields)->mapWithKeys(fn (string $field): array => [
+                $field => $request->user()->canUpdateField('schedule-orders', $field),
+            ])->all(),
+            'canManageItems' => $request->user()->hasPermission('schedule-orders.items.manage'),
+            'canUpdateFactoryNumber' => $request->user()->canUpdateField('schedule-orders', 'factory_number'),
+            'canUpdateManufactureDate' => $request->user()->canUpdateField('schedule-orders', 'manufacture_date'),
+            'managers' => User::query()
+                ->where('role', Role::MANAGER)
+                ->orderBy('name')
+                ->pluck('name', 'id'),
+            'catalogItems' => CommonCatalogItem::query()
+                ->whereKey($catalogItemIds)
+                ->get(['id', 'article', 'calculator_name', 'print_name'])
+                ->keyBy('id'),
+            'statuses' => ProductionOrderStatus::options(),
+            'executionTypes' => ProductionOrderExecutionType::options(),
+        ]);
+    }
+}

+ 101 - 0
app/Http/Requests/SaveProductionOrderRequest.php

@@ -0,0 +1,101 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Http\Requests;
+
+use App\Enums\ProductionOrderExecutionType;
+use App\Enums\ProductionOrderStatus;
+use App\Models\ProductionOrder;
+use App\Models\Role;
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
+
+class SaveProductionOrderRequest extends FormRequest
+{
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /** @return array<string, mixed> */
+    public function rules(): array
+    {
+        /** @var ProductionOrder|null $productionOrder */
+        $productionOrder = $this->route('productionOrder');
+        $canManageItems = $this->user()?->hasPermission('schedule-orders.items.manage') ?? false;
+        $required = $productionOrder ? 'sometimes' : 'required';
+        $orderYear = $this->has('order_year')
+            ? $this->integer('order_year')
+            : $productionOrder?->order_year;
+
+        return [
+            'order_number' => [
+                $required,
+                'required',
+                'string',
+                'max:100',
+                Rule::unique('production_orders', 'order_number')
+                    ->where('order_year', $orderYear)
+                    ->ignore($productionOrder),
+            ],
+            'order_year' => [$required, 'required', 'integer', 'min:2000', 'max:2100'],
+            'customer_name' => [$required, 'required', 'string', 'max:500'],
+            'object_address' => [$required, 'required', 'string', 'max:5000'],
+            'invoice_number' => [$required, 'required', 'string', 'max:100'],
+            'invoice_date' => ['nullable', 'date'],
+            'contract_number' => ['nullable', 'string', 'max:100'],
+            'contract_date' => ['nullable', 'date'],
+            'payment_date' => [$required, 'required', 'date'],
+            'supply_working_days' => [$required, 'required', 'integer', 'min:0', 'max:2000'],
+            'application_shipment_date' => ['nullable', 'date'],
+            'status' => [$required, 'required', Rule::enum(ProductionOrderStatus::class)],
+            'execution_type' => [$required, 'required', Rule::enum(ProductionOrderExecutionType::class)],
+            'manager_id' => [
+                $required,
+                'required',
+                'integer',
+                Rule::exists('users', 'id')->where('role', Role::MANAGER),
+            ],
+            'note' => ['nullable', 'string', 'max:10000'],
+            'items' => [$canManageItems ? 'required' : 'sometimes', 'array', 'min:1'],
+            'items.*.id' => [
+                'nullable',
+                'integer',
+                Rule::exists('production_order_items', 'id')->where(
+                    'production_order_id',
+                    $productionOrder?->id,
+                ),
+            ],
+            'items.*.common_catalog_item_id' => [
+                'required',
+                'integer',
+                Rule::exists('common_catalog_items', 'id')->whereNull('deleted_at'),
+            ],
+            'items.*.order_item_number' => ['required', 'string', 'max:100'],
+            'items.*.factory_number' => ['nullable', 'string', 'max:100'],
+            'items.*.manufacture_date' => ['nullable', 'date'],
+            'nav' => ['nullable', 'string', 'max:100'],
+        ];
+    }
+
+    /** @return array<string, string> */
+    public function attributes(): array
+    {
+        return [
+            'order_number' => 'номер заказа',
+            'order_year' => 'год заказа',
+            'customer_name' => 'заказчик',
+            'object_address' => 'адрес объекта',
+            'invoice_number' => 'номер счёта',
+            'payment_date' => 'дата оплаты',
+            'supply_working_days' => 'срок поставки',
+            'status' => 'статус',
+            'execution_type' => 'тип исполнения',
+            'manager_id' => 'менеджер',
+            'items' => 'оборудование',
+            'items.*.common_catalog_item_id' => 'позиция общего каталога',
+            'items.*.order_item_number' => 'номер заказа МАФ',
+        ];
+    }
+}

+ 29 - 0
app/Models/ProductionCalendarDay.php

@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Models;
+
+use Database\Factories\ProductionCalendarDayFactory;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class ProductionCalendarDay extends Model
+{
+    /** @use HasFactory<ProductionCalendarDayFactory> */
+    use HasFactory;
+
+    protected $fillable = [
+        'date',
+        'is_working_day',
+        'name',
+    ];
+
+    protected function casts(): array
+    {
+        return [
+            'date' => 'immutable_date',
+            'is_working_day' => 'boolean',
+        ];
+    }
+}

+ 124 - 0
app/Models/ProductionOrder.php

@@ -0,0 +1,124 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Models;
+
+use App\Enums\ProductionOrderExecutionType;
+use App\Enums\ProductionOrderSource;
+use App\Enums\ProductionOrderStatus;
+use Database\Factories\ProductionOrderFactory;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+class ProductionOrder extends Model
+{
+    /** @use HasFactory<ProductionOrderFactory> */
+    use HasFactory;
+
+    use SoftDeletes;
+
+    public const DEFAULT_SORT_BY = 'created_at';
+
+    public const DEFAULT_ORDER_BY = 'desc';
+
+    protected $fillable = [
+        'external_id',
+        'source',
+        'order_number',
+        'order_year',
+        'customer_name',
+        'object_address',
+        'invoice_number',
+        'invoice_date',
+        'contract_number',
+        'contract_date',
+        'payment_date',
+        'supply_working_days',
+        'contract_shipment_date',
+        'application_shipment_date',
+        'status',
+        'execution_type',
+        'manager_id',
+        'note',
+        'created_by',
+        'updated_by',
+    ];
+
+    protected function casts(): array
+    {
+        return [
+            'source' => ProductionOrderSource::class,
+            'order_year' => 'integer',
+            'invoice_date' => 'immutable_date',
+            'contract_date' => 'immutable_date',
+            'payment_date' => 'immutable_date',
+            'supply_working_days' => 'integer',
+            'contract_shipment_date' => 'immutable_date',
+            'application_shipment_date' => 'immutable_date',
+            'status' => ProductionOrderStatus::class,
+            'execution_type' => ProductionOrderExecutionType::class,
+            'manager_id' => 'integer',
+            'created_by' => 'integer',
+            'updated_by' => 'integer',
+        ];
+    }
+
+    public function manager(): BelongsTo
+    {
+        return $this->belongsTo(User::class, 'manager_id');
+    }
+
+    public function creator(): BelongsTo
+    {
+        return $this->belongsTo(User::class, 'created_by');
+    }
+
+    public function updater(): BelongsTo
+    {
+        return $this->belongsTo(User::class, 'updated_by');
+    }
+
+    public function items(): HasMany
+    {
+        return $this->hasMany(ProductionOrderItem::class)->orderBy('sort')->orderBy('id');
+    }
+
+    public function deliveries(): HasMany
+    {
+        return $this->hasMany(ProductionOrderDelivery::class)->orderBy('delivery_date')->orderBy('id');
+    }
+
+    public function installations(): HasMany
+    {
+        return $this->hasMany(ProductionOrderInstallation::class)->orderBy('installation_date')->orderBy('id');
+    }
+
+    public function activities(): HasMany
+    {
+        return $this->hasMany(ProductionOrderActivity::class)->latest('created_at')->latest('id');
+    }
+
+    public function statusLabel(): string
+    {
+        return $this->status->label();
+    }
+
+    public function executionTypeLabel(): string
+    {
+        return $this->execution_type->label();
+    }
+
+    public function getStatusNameAttribute(): string
+    {
+        return $this->statusLabel();
+    }
+
+    public function getExecutionTypeNameAttribute(): string
+    {
+        return $this->executionTypeLabel();
+    }
+}

+ 60 - 0
app/Models/ProductionOrderActivity.php

@@ -0,0 +1,60 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Models;
+
+use App\Enums\ProductionOrderActivityType;
+use Database\Factories\ProductionOrderActivityFactory;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+class ProductionOrderActivity extends Model
+{
+    /** @use HasFactory<ProductionOrderActivityFactory> */
+    use HasFactory;
+
+    public const UPDATED_AT = null;
+
+    protected $fillable = [
+        'production_order_id',
+        'type',
+        'user_id',
+        'delivery_id',
+        'installation_id',
+        'message',
+    ];
+
+    protected function casts(): array
+    {
+        return [
+            'production_order_id' => 'integer',
+            'type' => ProductionOrderActivityType::class,
+            'user_id' => 'integer',
+            'delivery_id' => 'integer',
+            'installation_id' => 'integer',
+            'created_at' => 'immutable_datetime',
+        ];
+    }
+
+    public function productionOrder(): BelongsTo
+    {
+        return $this->belongsTo(ProductionOrder::class);
+    }
+
+    public function user(): BelongsTo
+    {
+        return $this->belongsTo(User::class);
+    }
+
+    public function delivery(): BelongsTo
+    {
+        return $this->belongsTo(ProductionOrderDelivery::class);
+    }
+
+    public function installation(): BelongsTo
+    {
+        return $this->belongsTo(ProductionOrderInstallation::class);
+    }
+}

+ 65 - 0
app/Models/ProductionOrderDelivery.php

@@ -0,0 +1,65 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Models;
+
+use Database\Factories\ProductionOrderDeliveryFactory;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+class ProductionOrderDelivery extends Model
+{
+    /** @use HasFactory<ProductionOrderDeliveryFactory> */
+    use HasFactory;
+
+    use SoftDeletes;
+
+    protected $fillable = [
+        'production_order_id',
+        'delivery_date',
+        'delivery_time',
+        'driver_id',
+        'carrier',
+        'request_number',
+        'request_date',
+        'carrier_note',
+        'contact_position',
+        'contact_name',
+        'contact_phone',
+        'desired_time',
+        'access_system',
+        'unloading_place',
+        'advance_notice',
+        'document_signing_method',
+        'has_passports_and_certificates',
+        'note',
+        'created_by',
+        'updated_by',
+    ];
+
+    protected function casts(): array
+    {
+        return [
+            'production_order_id' => 'integer',
+            'delivery_date' => 'immutable_date',
+            'request_date' => 'immutable_date',
+            'has_passports_and_certificates' => 'boolean',
+            'driver_id' => 'integer',
+            'created_by' => 'integer',
+            'updated_by' => 'integer',
+        ];
+    }
+
+    public function productionOrder(): BelongsTo
+    {
+        return $this->belongsTo(ProductionOrder::class);
+    }
+
+    public function driver(): BelongsTo
+    {
+        return $this->belongsTo(User::class, 'driver_id');
+    }
+}

+ 51 - 0
app/Models/ProductionOrderInstallation.php

@@ -0,0 +1,51 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Models;
+
+use Database\Factories\ProductionOrderInstallationFactory;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+class ProductionOrderInstallation extends Model
+{
+    /** @use HasFactory<ProductionOrderInstallationFactory> */
+    use HasFactory;
+
+    use SoftDeletes;
+
+    protected $fillable = [
+        'production_order_id',
+        'installation_date',
+        'installation_days',
+        'brigadier_id',
+        'note',
+        'created_by',
+        'updated_by',
+    ];
+
+    protected function casts(): array
+    {
+        return [
+            'production_order_id' => 'integer',
+            'installation_date' => 'immutable_date',
+            'installation_days' => 'integer',
+            'brigadier_id' => 'integer',
+            'created_by' => 'integer',
+            'updated_by' => 'integer',
+        ];
+    }
+
+    public function productionOrder(): BelongsTo
+    {
+        return $this->belongsTo(ProductionOrder::class);
+    }
+
+    public function brigadier(): BelongsTo
+    {
+        return $this->belongsTo(User::class, 'brigadier_id');
+    }
+}

+ 63 - 0
app/Models/ProductionOrderItem.php

@@ -0,0 +1,63 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Models;
+
+use Database\Factories\ProductionOrderItemFactory;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+class ProductionOrderItem extends Model
+{
+    /** @use HasFactory<ProductionOrderItemFactory> */
+    use HasFactory;
+
+    use SoftDeletes;
+
+    protected $fillable = [
+        'production_order_id',
+        'common_catalog_item_id',
+        'external_id',
+        'order_item_number',
+        'factory_number',
+        'manufacture_date',
+        'passport_file_id',
+        'sort',
+        'created_by',
+    ];
+
+    protected function casts(): array
+    {
+        return [
+            'production_order_id' => 'integer',
+            'common_catalog_item_id' => 'integer',
+            'manufacture_date' => 'immutable_date',
+            'passport_file_id' => 'integer',
+            'sort' => 'integer',
+            'created_by' => 'integer',
+        ];
+    }
+
+    public function productionOrder(): BelongsTo
+    {
+        return $this->belongsTo(ProductionOrder::class);
+    }
+
+    public function catalogItem(): BelongsTo
+    {
+        return $this->belongsTo(CommonCatalogItem::class, 'common_catalog_item_id');
+    }
+
+    public function passportFile(): BelongsTo
+    {
+        return $this->belongsTo(File::class, 'passport_file_id');
+    }
+
+    public function creator(): BelongsTo
+    {
+        return $this->belongsTo(User::class, 'created_by');
+    }
+}

+ 3 - 0
app/Models/Role.php

@@ -13,6 +13,7 @@ class Role extends Model
     const BRIGADIER = 'brigadier';
     const WAREHOUSE_HEAD = 'warehouse_head';
     const ASSISTANT_HEAD = 'assistant_head';
+    const DRIVER = 'driver';
 
     const VALID_ROLES = [
         self::ADMIN,
@@ -20,6 +21,7 @@ class Role extends Model
         self::BRIGADIER,
         self::WAREHOUSE_HEAD,
         self::ASSISTANT_HEAD,
+        self::DRIVER,
     ];
 
     const NAMES = [
@@ -28,6 +30,7 @@ class Role extends Model
         self::BRIGADIER => 'Бригадир',
         self::WAREHOUSE_HEAD => 'Рук. Склада',
         self::ASSISTANT_HEAD => 'Помощник рук.',
+        self::DRIVER => 'Водитель',
     ];
 
     protected $fillable = [

+ 44 - 0
app/Services/ProductionCalendarService.php

@@ -0,0 +1,44 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Services;
+
+use App\Models\ProductionCalendarDay;
+use Carbon\CarbonImmutable;
+use DateTimeInterface;
+use InvalidArgumentException;
+
+class ProductionCalendarService
+{
+    public function addWorkingDays(DateTimeInterface|string $startDate, int $workingDays): CarbonImmutable
+    {
+        if ($workingDays < 0) {
+            throw new InvalidArgumentException('Количество рабочих дней не может быть отрицательным.');
+        }
+
+        $date = CarbonImmutable::parse($startDate)->startOfDay();
+        if ($workingDays === 0) {
+            return $date;
+        }
+
+        $overrides = ProductionCalendarDay::query()
+            ->pluck('is_working_day', 'date')
+            ->map(static fn (mixed $value): bool => (bool) $value);
+
+        $remaining = $workingDays;
+        while ($remaining > 0) {
+            $date = $date->addDay();
+            $key = $date->toDateString();
+            $isWorkingDay = $overrides->has($key)
+                ? $overrides->get($key)
+                : ! $date->isWeekend();
+
+            if ($isWorkingDay) {
+                $remaining--;
+            }
+        }
+
+        return $date;
+    }
+}

+ 128 - 0
app/Services/ProductionOrderService.php

@@ -0,0 +1,128 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Services;
+
+use App\Enums\ProductionOrderSource;
+use App\Models\ProductionOrder;
+use App\Models\User;
+use Illuminate\Support\Arr;
+use Illuminate\Support\Facades\DB;
+
+class ProductionOrderService
+{
+    public function __construct(
+        private readonly ProductionCalendarService $calendar,
+    ) {}
+
+    /** @param array<string, mixed> $data */
+    public function create(array $data, User $actor): ProductionOrder
+    {
+        return DB::transaction(function () use ($data, $actor): ProductionOrder {
+            $items = Arr::pull($data, 'items', []);
+            unset($data['nav']);
+
+            $data['source'] = ProductionOrderSource::Manual;
+            $data['contract_shipment_date'] = $this->contractShipmentDate($data);
+            $data['created_by'] = $actor->id;
+            $data['updated_by'] = $actor->id;
+
+            $order = ProductionOrder::query()->create($data);
+            $this->syncItems($order, $items, $actor);
+
+            return $order->load(['manager', 'items.catalogItem']);
+        });
+    }
+
+    /** @param array<string, mixed> $data */
+    public function update(
+        ProductionOrder $order,
+        array $data,
+        User $actor,
+        bool $syncItems,
+        array $editableItemFields = [],
+    ): ProductionOrder {
+        return DB::transaction(function () use (
+            $order,
+            $data,
+            $actor,
+            $syncItems,
+            $editableItemFields,
+        ): ProductionOrder {
+            $items = Arr::pull($data, 'items', null);
+            unset($data['nav']);
+
+            if (array_key_exists('payment_date', $data) || array_key_exists('supply_working_days', $data)) {
+                $data['contract_shipment_date'] = $this->contractShipmentDate([
+                    'payment_date' => $data['payment_date'] ?? $order->payment_date,
+                    'supply_working_days' => $data['supply_working_days'] ?? $order->supply_working_days,
+                ]);
+            }
+            $data['updated_by'] = $actor->id;
+            $order->update($data);
+
+            if ($syncItems && is_array($items)) {
+                $this->syncItems($order, $items, $actor);
+            } elseif (is_array($items) && $editableItemFields !== []) {
+                $this->updateItemDetails($order, $items, $editableItemFields);
+            }
+
+            return $order->refresh()->load(['manager', 'items.catalogItem']);
+        });
+    }
+
+    /**
+     * @param  array<int, array<string, mixed>>  $items
+     * @param  array<int, string>  $editableFields
+     */
+    private function updateItemDetails(ProductionOrder $order, array $items, array $editableFields): void
+    {
+        foreach ($items as $itemData) {
+            $id = isset($itemData['id']) ? (int) $itemData['id'] : 0;
+            if ($id < 1) {
+                continue;
+            }
+
+            $order->items()->whereKey($id)->firstOrFail()->update(
+                Arr::only($itemData, $editableFields),
+            );
+        }
+    }
+
+    /**
+     * @param  array<int, array<string, mixed>>  $items
+     */
+    private function syncItems(ProductionOrder $order, array $items, User $actor): void
+    {
+        $keptIds = [];
+        foreach (array_values($items) as $sort => $itemData) {
+            $id = isset($itemData['id']) ? (int) $itemData['id'] : null;
+            $payload = Arr::only($itemData, [
+                'common_catalog_item_id',
+                'order_item_number',
+                'factory_number',
+                'manufacture_date',
+            ]);
+            $payload['sort'] = $sort;
+
+            if ($id !== null && $id > 0) {
+                $item = $order->items()->whereKey($id)->firstOrFail();
+                $item->update($payload);
+            } else {
+                $item = $order->items()->create($payload + ['created_by' => $actor->id]);
+            }
+            $keptIds[] = $item->id;
+        }
+
+        $order->items()->whereNotIn('id', $keptIds)->delete();
+    }
+
+    /** @param array<string, mixed> $data */
+    private function contractShipmentDate(array $data): string
+    {
+        return $this->calendar
+            ->addWorkingDays((string) $data['payment_date'], (int) $data['supply_working_days'])
+            ->toDateString();
+    }
+}

+ 42 - 0
config/access.php

@@ -66,6 +66,48 @@ return [
             'scope.brigadier' => 'Область видимости: бригадир',
         ],
     ],
+    'schedule-orders' => [
+        'name' => 'График заказов',
+        'entity' => 'production_order',
+        'actions' => [
+            'view' => 'Просмотр',
+            'create' => 'Создание',
+            'update' => 'Редактирование',
+            'delete' => 'Удаление',
+            'items.manage' => 'Управление МАФ',
+            'deliveries.manage' => 'Управление доставками',
+            'installations.manage' => 'Управление монтажами',
+            'documents.manage' => 'Управление документами',
+            'photos.manage' => 'Управление фотографиями',
+            'passports.manage' => 'Управление паспортами МАФ',
+            'chat.create' => 'Сообщения',
+            'export' => 'Экспорт',
+            'import' => 'Импорт',
+            'documents.generate' => 'Генерация документов',
+        ],
+        'fields' => [
+            'id' => 'ID',
+            'order_number' => 'Номер заказа',
+            'order_year' => 'Год заказа',
+            'customer_name' => 'Заказчик',
+            'object_address' => 'Адрес объекта',
+            'invoice_number' => 'Номер счёта',
+            'invoice_date' => 'Дата счёта',
+            'contract_number' => 'Номер договора',
+            'contract_date' => 'Дата договора',
+            'payment_date' => 'Дата оплаты',
+            'supply_working_days' => 'Срок поставки',
+            'contract_shipment_date' => 'Дата отгрузки по договору',
+            'application_shipment_date' => 'Дата отгрузки по заявке',
+            'status' => 'Статус',
+            'execution_type' => 'Тип исполнения',
+            'manager_id' => 'Менеджер',
+            'note' => 'Примечание',
+            'order_item_number' => 'Номер заказа МАФ',
+            'factory_number' => 'Заводской номер МАФ',
+            'manufacture_date' => 'Дата производства МАФ',
+        ],
+    ],
     'catalog' => [
         'name' => 'Каталог',
         'entity' => 'product',

+ 9 - 1
config/access_routes.php

@@ -12,7 +12,7 @@ return [
         'product.search' => 'catalog.search',
         'pricing_codes.get_description' => 'pricing_codes.search',
         'pricing_codes.search' => 'pricing_codes.search',
-        'schedule.orders' => true,
+        'schedule.orders' => 'schedule-orders.view',
         'schedule.deliveries' => true,
         'user.profile' => 'profile.view',
         'profile.store' => 'profile.update',
@@ -93,6 +93,14 @@ return [
             'orders.destroy' => 'stock.orders.manage',
             'orders.import' => 'stock.orders.import',
         ],
+        'schedule.orders.' => [
+            'create' => 'schedule-orders.create',
+            'store' => 'schedule-orders.create',
+            'catalog-search' => 'schedule-orders.items.manage',
+            'show' => 'schedule-orders.view',
+            'update' => 'schedule-orders.update',
+            'destroy' => 'schedule-orders.delete',
+        ],
         'contract.' => [
             'index' => 'contracts.view',
             'show' => 'contracts.view',

+ 25 - 0
database/factories/ProductionCalendarDayFactory.php

@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Database\Factories;
+
+use App\Models\ProductionCalendarDay;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/** @extends Factory<ProductionCalendarDay> */
+class ProductionCalendarDayFactory extends Factory
+{
+    protected $model = ProductionCalendarDay::class;
+
+    public function definition(): array
+    {
+        $date = fake()->unique()->dateTimeBetween('-1 year', '+2 years');
+
+        return [
+            'date' => $date->format('Y-m-d'),
+            'is_working_day' => ! in_array((int) $date->format('N'), [6, 7], true),
+            'name' => fake()->optional()->sentence(3),
+        ];
+    }
+}

+ 29 - 0
database/factories/ProductionOrderActivityFactory.php

@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Database\Factories;
+
+use App\Enums\ProductionOrderActivityType;
+use App\Models\ProductionOrder;
+use App\Models\ProductionOrderActivity;
+use App\Models\User;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/** @extends Factory<ProductionOrderActivity> */
+class ProductionOrderActivityFactory extends Factory
+{
+    protected $model = ProductionOrderActivity::class;
+
+    public function definition(): array
+    {
+        return [
+            'production_order_id' => ProductionOrder::factory(),
+            'type' => fake()->randomElement(ProductionOrderActivityType::cases()),
+            'user_id' => User::factory(),
+            'delivery_id' => null,
+            'installation_id' => null,
+            'message' => fake()->sentence(),
+        ];
+    }
+}

+ 42 - 0
database/factories/ProductionOrderDeliveryFactory.php

@@ -0,0 +1,42 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Database\Factories;
+
+use App\Models\ProductionOrder;
+use App\Models\ProductionOrderDelivery;
+use App\Models\User;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/** @extends Factory<ProductionOrderDelivery> */
+class ProductionOrderDeliveryFactory extends Factory
+{
+    protected $model = ProductionOrderDelivery::class;
+
+    public function definition(): array
+    {
+        return [
+            'production_order_id' => ProductionOrder::factory(),
+            'delivery_date' => fake()->dateTimeBetween('now', '+3 months'),
+            'delivery_time' => fake()->optional()->time('H:i'),
+            'driver_id' => User::factory()->driver(),
+            'carrier' => fake()->optional()->company(),
+            'request_number' => fake()->optional()->numerify('####'),
+            'request_date' => fake()->optional()->date(),
+            'carrier_note' => fake()->optional()->sentence(),
+            'contact_position' => fake()->optional()->jobTitle(),
+            'contact_name' => fake()->optional()->name(),
+            'contact_phone' => fake()->optional()->phoneNumber(),
+            'desired_time' => fake()->optional()->time('H:i'),
+            'access_system' => fake()->optional()->sentence(),
+            'unloading_place' => fake()->optional()->sentence(),
+            'advance_notice' => fake()->optional()->sentence(),
+            'document_signing_method' => fake()->optional()->sentence(),
+            'has_passports_and_certificates' => fake()->optional()->boolean(),
+            'note' => fake()->optional()->sentence(),
+            'created_by' => null,
+            'updated_by' => null,
+        ];
+    }
+}

+ 48 - 0
database/factories/ProductionOrderFactory.php

@@ -0,0 +1,48 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Database\Factories;
+
+use App\Enums\ProductionOrderExecutionType;
+use App\Enums\ProductionOrderSource;
+use App\Enums\ProductionOrderStatus;
+use App\Models\ProductionOrder;
+use App\Models\User;
+use Carbon\CarbonImmutable;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/** @extends Factory<ProductionOrder> */
+class ProductionOrderFactory extends Factory
+{
+    protected $model = ProductionOrder::class;
+
+    public function definition(): array
+    {
+        $paymentDate = CarbonImmutable::instance(fake()->dateTimeBetween('-3 months', '+1 month'));
+        $supplyDays = fake()->numberBetween(5, 60);
+
+        return [
+            'external_id' => null,
+            'source' => ProductionOrderSource::Manual,
+            'order_number' => fake()->unique()->numerify('####.##'),
+            'order_year' => (int) $paymentDate->format('Y'),
+            'customer_name' => fake()->company(),
+            'object_address' => fake()->address(),
+            'invoice_number' => fake()->numerify('СЧ-######'),
+            'invoice_date' => $paymentDate->subDays(2),
+            'contract_number' => fake()->optional()->numerify('Д-######'),
+            'contract_date' => fake()->optional()->passthrough($paymentDate->subDays(10)),
+            'payment_date' => $paymentDate,
+            'supply_working_days' => $supplyDays,
+            'contract_shipment_date' => $paymentDate->addWeekdays($supplyDays),
+            'application_shipment_date' => null,
+            'status' => ProductionOrderStatus::Placed,
+            'execution_type' => fake()->randomElement(ProductionOrderExecutionType::cases()),
+            'manager_id' => User::factory()->manager(),
+            'note' => fake()->optional()->sentence(),
+            'created_by' => null,
+            'updated_by' => null,
+        ];
+    }
+}

+ 29 - 0
database/factories/ProductionOrderInstallationFactory.php

@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Database\Factories;
+
+use App\Models\ProductionOrder;
+use App\Models\ProductionOrderInstallation;
+use App\Models\User;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/** @extends Factory<ProductionOrderInstallation> */
+class ProductionOrderInstallationFactory extends Factory
+{
+    protected $model = ProductionOrderInstallation::class;
+
+    public function definition(): array
+    {
+        return [
+            'production_order_id' => ProductionOrder::factory(),
+            'installation_date' => fake()->dateTimeBetween('now', '+3 months'),
+            'installation_days' => fake()->numberBetween(1, 20),
+            'brigadier_id' => User::factory()->brigadier(),
+            'note' => fake()->optional()->sentence(),
+            'created_by' => null,
+            'updated_by' => null,
+        ];
+    }
+}

+ 31 - 0
database/factories/ProductionOrderItemFactory.php

@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Database\Factories;
+
+use App\Models\CommonCatalogItem;
+use App\Models\ProductionOrder;
+use App\Models\ProductionOrderItem;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/** @extends Factory<ProductionOrderItem> */
+class ProductionOrderItemFactory extends Factory
+{
+    protected $model = ProductionOrderItem::class;
+
+    public function definition(): array
+    {
+        return [
+            'production_order_id' => ProductionOrder::factory(),
+            'common_catalog_item_id' => CommonCatalogItem::factory(),
+            'external_id' => null,
+            'order_item_number' => fake()->numerify('МАФ-######'),
+            'factory_number' => null,
+            'manufacture_date' => null,
+            'passport_file_id' => null,
+            'sort' => 0,
+            'created_by' => null,
+        ];
+    }
+}

+ 7 - 0
database/factories/UserFactory.php

@@ -69,6 +69,13 @@ class UserFactory extends Factory
         ]);
     }
 
+    public function driver(): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'role' => 'driver',
+        ]);
+    }
+
     /**
      * Indicate that the model's email address should be unverified.
      */

+ 137 - 0
database/migrations/2026_09_03_000001_create_production_order_tables.php

@@ -0,0 +1,137 @@
+<?php
+
+declare(strict_types=1);
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::create('production_calendar_days', function (Blueprint $table): void {
+            $table->id();
+            $table->date('date')->unique();
+            $table->boolean('is_working_day');
+            $table->string('name')->nullable();
+            $table->timestamps();
+        });
+
+        Schema::create('production_orders', function (Blueprint $table): void {
+            $table->id();
+            $table->string('external_id')->nullable()->unique();
+            $table->string('source', 32)->default('manual');
+            $table->string('order_number', 100);
+            $table->unsignedSmallInteger('order_year');
+            $table->string('customer_name', 500);
+            $table->text('object_address');
+            $table->string('invoice_number', 100);
+            $table->date('invoice_date')->nullable();
+            $table->string('contract_number', 100)->nullable();
+            $table->date('contract_date')->nullable();
+            $table->date('payment_date');
+            $table->unsignedSmallInteger('supply_working_days');
+            $table->date('contract_shipment_date');
+            $table->date('application_shipment_date')->nullable();
+            $table->string('status', 40)->default('placed');
+            $table->string('execution_type', 40);
+            $table->foreignId('manager_id')->constrained('users')->restrictOnDelete();
+            $table->text('note')->nullable();
+            $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
+            $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
+            $table->timestamps();
+            $table->softDeletes();
+
+            $table->unique(['order_number', 'order_year']);
+            $table->index(['status', 'contract_shipment_date']);
+            $table->index(['manager_id', 'created_at']);
+        });
+
+        Schema::create('production_order_items', function (Blueprint $table): void {
+            $table->id();
+            $table->foreignId('production_order_id')->constrained('production_orders')->cascadeOnDelete();
+            $table->foreignId('common_catalog_item_id')->constrained('common_catalog_items')->restrictOnDelete();
+            $table->string('external_id')->nullable();
+            $table->string('order_item_number', 100);
+            $table->string('factory_number', 100)->nullable();
+            $table->date('manufacture_date')->nullable();
+            $table->foreignId('passport_file_id')->nullable()->constrained('files')->nullOnDelete();
+            $table->unsignedInteger('sort')->default(0);
+            $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
+            $table->timestamps();
+            $table->softDeletes();
+
+            $table->unique(['production_order_id', 'external_id'], 'prod_order_items_order_external_unique');
+            $table->index(['production_order_id', 'order_item_number'], 'prod_order_items_order_number_index');
+            $table->index(['common_catalog_item_id', 'factory_number'], 'prod_order_items_catalog_factory_index');
+        });
+
+        Schema::create('production_order_deliveries', function (Blueprint $table): void {
+            $table->id();
+            $table->foreignId('production_order_id')->constrained('production_orders')->cascadeOnDelete();
+            $table->date('delivery_date');
+            $table->time('delivery_time')->nullable();
+            $table->foreignId('driver_id')->constrained('users')->restrictOnDelete();
+            $table->string('carrier')->nullable();
+            $table->string('request_number', 100)->nullable();
+            $table->date('request_date')->nullable();
+            $table->text('carrier_note')->nullable();
+            $table->string('contact_position')->nullable();
+            $table->string('contact_name')->nullable();
+            $table->string('contact_phone', 100)->nullable();
+            $table->time('desired_time')->nullable();
+            $table->text('access_system')->nullable();
+            $table->text('unloading_place')->nullable();
+            $table->text('advance_notice')->nullable();
+            $table->text('document_signing_method')->nullable();
+            $table->boolean('has_passports_and_certificates')->nullable();
+            $table->text('note')->nullable();
+            $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
+            $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
+            $table->timestamps();
+            $table->softDeletes();
+
+            $table->index(['delivery_date', 'driver_id'], 'prod_order_deliveries_date_driver_index');
+        });
+
+        Schema::create('production_order_installations', function (Blueprint $table): void {
+            $table->id();
+            $table->foreignId('production_order_id')->constrained('production_orders')->cascadeOnDelete();
+            $table->date('installation_date');
+            $table->unsignedSmallInteger('installation_days');
+            $table->foreignId('brigadier_id')->constrained('users')->restrictOnDelete();
+            $table->text('note')->nullable();
+            $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
+            $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
+            $table->timestamps();
+            $table->softDeletes();
+
+            $table->index(['installation_date', 'brigadier_id'], 'prod_order_installations_date_brig_index');
+        });
+
+        Schema::create('production_order_activities', function (Blueprint $table): void {
+            $table->id();
+            $table->foreignId('production_order_id')->constrained('production_orders')->cascadeOnDelete();
+            $table->string('type', 40);
+            $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
+            $table->foreignId('delivery_id')->nullable()->constrained('production_order_deliveries')->nullOnDelete();
+            $table->foreignId('installation_id')->nullable()->constrained('production_order_installations')->nullOnDelete();
+            $table->text('message');
+            $table->timestamp('created_at')->useCurrent();
+
+            $table->index(['production_order_id', 'created_at'], 'prod_order_activities_order_created_index');
+            $table->index(['type', 'created_at'], 'prod_order_activities_type_created_index');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('production_order_activities');
+        Schema::dropIfExists('production_order_installations');
+        Schema::dropIfExists('production_order_deliveries');
+        Schema::dropIfExists('production_order_items');
+        Schema::dropIfExists('production_orders');
+        Schema::dropIfExists('production_calendar_days');
+    }
+};

+ 26 - 0
database/migrations/2026_09_03_000002_sync_production_order_permissions.php

@@ -0,0 +1,26 @@
+<?php
+
+declare(strict_types=1);
+
+use Database\Seeders\RbacSeeder;
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        if (
+            Schema::hasTable('roles')
+            && Schema::hasTable('permissions')
+            && Schema::hasTable('role_permissions')
+        ) {
+            app(RbacSeeder::class)->run();
+        }
+    }
+
+    public function down(): void
+    {
+        // Системные роли и permissions остаются в истории RBAC.
+    }
+};

+ 28 - 2
database/seeders/RbacSeeder.php

@@ -132,6 +132,7 @@ class RbacSeeder extends Seeder
             'areas.ajax.view',
             'catalog.search',
             'common-catalog.view',
+            'schedule-orders.view',
             'documents.view',
             'stock.view',
             'stock.reserve',
@@ -149,6 +150,7 @@ class RbacSeeder extends Seeder
             fn (string $field): string => "common-catalog.fields.{$field}.view",
             $commonCatalogPublicFields,
         ));
+        $auth = array_merge($auth, $this->fieldPermissions('schedule-orders', ['view'], $permissions));
 
         $manager = array_merge($auth, [
             'orders.update',
@@ -216,10 +218,33 @@ class RbacSeeder extends Seeder
             'schedule.scope.manager',
         ]);
 
-        $assistantHead = array_values(array_diff(
+        $scheduleOrderPermissions = array_values(array_filter(
             $all,
-            $this->commonCatalogAdminOnlyPermissions(),
+            static fn (string $slug): bool => str_starts_with($slug, 'schedule-orders.'),
         ));
+        $scheduleOrderAssistantPermissions = array_merge(
+            [
+                'schedule-orders.view',
+                'schedule-orders.update',
+                'schedule-orders.deliveries.manage',
+                'schedule-orders.installations.manage',
+            ],
+            $this->fieldPermissions('schedule-orders', ['view'], $permissions),
+            [
+                'schedule-orders.fields.object_address.update',
+                'schedule-orders.fields.note.update',
+                'schedule-orders.fields.factory_number.update',
+                'schedule-orders.fields.manufacture_date.update',
+            ],
+        );
+        $assistantHead = array_values(array_unique(array_merge(
+            array_diff(
+                $all,
+                $this->commonCatalogAdminOnlyPermissions(),
+                $scheduleOrderPermissions,
+            ),
+            $scheduleOrderAssistantPermissions,
+        )));
 
         $rolePermissions = [
             Role::ADMIN => $all,
@@ -227,6 +252,7 @@ class RbacSeeder extends Seeder
             Role::MANAGER => $manager,
             Role::BRIGADIER => $brigadier,
             Role::WAREHOUSE_HEAD => $warehouseHead,
+            Role::DRIVER => $auth,
         ];
 
         foreach ($rolePermissions as $roleSlug => $permissionSlugs) {

+ 36 - 4
docs/refactor/plan.md

@@ -85,7 +85,7 @@
 | 6. Склад: ядро | Реализован и проверен | Ядро склада принято после пользовательской проверки. Цены относятся к общему каталогу; для склада остаётся отдельной задачей только PDF-экспорт после согласования шаблона. |
 | 7. Техническое описание | Реализован и проверен | Данные встроены в общий каталог, вид цены выбирается администратором (по умолчанию `проект`), одиночный DOCX и массовый DOCX/ZIP приняты по результатам пользовательской проверки. |
 | 8. Калькуляции | Исходник изучен, ожидается отдельное ТЗ | Восстановлены сущности, формулы и экспорты `calc.stroyprofit.com`; финальная архитектура, формулы и сценарии не фиксируются до ТЗ. Рабочая база источника сейчас не содержит расчётных данных. |
-| 9. Графики заказов и доставок | Готов к реализации без интеграции с 1С | Ответы заказчика и шаблон заявки получены; ручное ядро, доставки, монтажи, документы, уведомления и аудит разблокированы. Контракт обмена требует специалиста 1С. |
+| 9. Графики заказов и доставок | В работе | Реализованы нормализованное ядро, права и первый ручной контур заказов с отдельными МАФ; доставки/монтажи и обмен с 1С остаются следующими подэтапами. |
 | 10. Рекламации из графика заказов | ТЗ получено, реализация после основы графика | Подтверждены выбор отдельных МАФ и тип `Прочее`, но новая связь рекламации зависит от моделей заказа и его экземпляров. |
 
 ## Этап 1. Реорганизация меню и заглушки
@@ -245,10 +245,42 @@
 
 ## Этап 9. Графики заказов и доставок
 
-Статус: **ТЗ, ответы заказчика и шаблон заявки получены; можно реализовывать весь ручной контур, кроме обмена с 1С и неоднозначной части XLS-импорта**.
+Статус: **в работе; реализуется ручной контур, обмен с 1С и неоднозначная часть XLS-импорта вынесены в отдельные подэтапы**.
 
 Старый `График отгрузок` является источником существующих данных и сценариев, но не переносится одной таблицей. Архитектура нового модуля должна оставаться нормализованной и расширяемой под дополнительные требования заказчика.
 
+### Техническая схема
+
+- `production_orders` — заголовок производственного заказа; не смешивается с ДКР-`orders`.
+- `production_order_items` — отдельный экземпляр МАФ со ссылкой на `common_catalog_items` и собственным паспортом.
+- `production_order_deliveries` — неограниченное число доставок с водителем и данными заявки.
+- `production_order_installations` — неограниченное число этапов монтажа с бригадиром.
+- `production_order_activities` — журнал только согласованных значимых событий.
+- `production_calendar_days` — переносы и исключения производственного календаря; базовое правило без исключений — понедельник–пятница.
+- Фиксированные статусы и типы исполнения оформляются PHP-enum со строковым значением в БД.
+- Номер заказа уникален в пределах календарного года; внешний GUID хранится отдельно и остаётся nullable до согласования 1С.
+- Данные доставок, монтажей и другие ручные поля не включаются в payload замены из 1С.
+
+### Подэтапы реализации
+
+1. **9.1. Основа данных и права** — таблицы, модели, enum, роль `driver`, permissions и расчёт даты отгрузки.
+2. **9.2. Ручной заказ** — список с фильтрами, карточка, создание/редактирование, отдельные экземпляры МАФ из общего каталога.
+3. **9.3. Доставки и монтажи** — несколько записей на заказ, выбор водителя/бригадира, недельный график доставок и связь с графиком монтажей.
+4. **9.4. Файлы и коммуникации** — документы, фотографии, чат заказа, скан паспорта каждого экземпляра МАФ.
+5. **9.5. События и документы** — аудит, настраиваемые уведомления, экспорт МАФ, монтажный пакет, заявка на доставку и техдокументация.
+6. **9.6. Обмен данными** — экспорт списка, XLS-импорт после уточнения формата, API 1С с журналом загрузок и отдельная миграция старых данных.
+7. **9.7. Рекламации и приёмка** — связь с этапом 10, полные автотесты и пользовательская проверка.
+
+Текущий подэтап: **9.3. Доставки и монтажи**.
+
+- [x] **9.1. Основа данных и права:** добавлены нормализованные таблицы и модели, enum статусов/типов, производственный календарь, роль `driver`, действия и permissions отдельных полей.
+- [x] **9.2. Ручной заказ:** реализованы список с фильтрами, цветовой статус, карточка, создание/редактирование, расчёт договорной даты отгрузки и отдельные экземпляры МАФ из общего каталога.
+- [ ] **9.3. Доставки и монтажи:** реализовать несколько доставок/монтажей на заказ и недельный график доставок.
+- [ ] **9.4. Файлы и коммуникации:** реализовать документы, фотографии, чат и паспорт отдельного МАФ.
+- [ ] **9.5. События и документы:** реализовать аудит, уведомления и согласованные экспорты/пакеты.
+- [ ] **9.6. Обмен данными:** реализовать согласованную часть XLS/1С и отдельный перенос истории.
+- [ ] **9.7. Рекламации и приёмка:** связать этап 10 и выполнить итоговую проверку.
+
 - [x] Изучить старый `График отгрузок` в `manager.stroyprofit.com`.
 - [x] Описать найденный HTTP-обмен с 1С и состав текущего payload.
 - [x] Проанализировать существующие статусы, ручные поля, вложения и несколько доставок по заказу.
@@ -263,12 +295,12 @@
 - [ ] Согласовать принадлежность полей источнику или ручному редактированию, чтобы повторная загрузка не уничтожала плановые данные.
 - [x] Согласовать календарь рабочих дней и значения полей дат отгрузки.
 - [x] Зафиксировать `Водитель` как отдельную роль в ролевой модели.
-- [ ] Спроектировать нормализованные таблицы и связи после получения ТЗ.
+- [x] Спроектировать нормализованные таблицы и связи после получения ТЗ.
 - [ ] Спроектировать загрузку данных через 1С.
 - [ ] Реализовать безопасный идемпотентный импорт с журналом запусков и ошибок.
 - [ ] Реализовать `График заказов` без прямого копирования устаревшей таблицы `graf1`.
 - [ ] Подготовить отдельную миграцию исторических данных старого Manager с отчётом о конфликтах и дублях.
-- [ ] Добавить карточку заказа с выбором одного или нескольких МАФ.
+- [x] Добавить карточку заказа с выбором одного или нескольких МАФ.
 - [ ] Добавить ручное создание заказа, файлы, фотографии, чат, паспорт каждого МАФ и журнал действий.
 - [ ] Добавить статусы, типы исполнения, цветовую индикацию и настраиваемые уведомления.
 - [ ] Реализовать импорт/экспорт списка заказов и экспорт МАФ одного заказа.

+ 4 - 0
resources/views/partials/table.blade.php

@@ -199,6 +199,10 @@
                                     <option value="{{ $statusId }}" @selected($statusId == $string->status_id)>{{ $statusName }}</option>
                                 @endforeach
                             </select>
+                        @elseif($id === 'schedule_orders' && $headerName === 'status_name')
+                            <span class="badge text-bg-{{ $string->status->color() }}">
+                                {{ $string->status_name }}
+                            </span>
                         @elseif($headerName === 'tsn_number' && $string->$headerName)
                             <span data-bs-toggle="tooltip"
                                   data-bs-placement="top"

+ 257 - 0
resources/views/production_orders/edit.blade.php

@@ -0,0 +1,257 @@
+@extends('layouts.app')
+
+@section('content')
+    @php
+        $canView = static fn (string $field): bool => (bool) ($readableFields[$field] ?? false);
+        $canUpdate = static fn (string $field): bool => (bool) ($writableFields[$field] ?? false);
+        $itemRows = old('items');
+        if ($itemRows === null) {
+            $itemRows = $order
+                ? $order->items->map(static fn ($item): array => [
+                    'id' => $item->id,
+                    'common_catalog_item_id' => $item->common_catalog_item_id,
+                    'order_item_number' => $item->order_item_number,
+                    'factory_number' => $item->factory_number,
+                    'manufacture_date' => $item->manufacture_date?->toDateString(),
+                ])->values()->all()
+                : [];
+        }
+    @endphp
+
+    <div class="px-3">
+        <div class="row mb-3">
+            <div class="col-md-7 d-flex align-items-center">
+                <h3 class="mb-0">{{ $order ? 'Заказ '.$order->order_number : 'Новый заказ' }}</h3>
+            </div>
+            @if($order && hasPermission('schedule-orders.delete'))
+                <div class="col-md-5 text-md-end">
+                    <form action="{{ route('schedule.orders.destroy', $order) }}" method="POST"
+                          onsubmit="return confirm('Удалить заказ?')">
+                        @csrf
+                        @method('DELETE')
+                        <button type="submit" class="btn btn-sm btn-outline-danger">Удалить</button>
+                    </form>
+                </div>
+            @endif
+        </div>
+
+        <form action="{{ $order ? route('schedule.orders.update', $order) : route('schedule.orders.store') }}"
+              method="POST">
+            @csrf
+            @if($order) @method('PUT') @endif
+            <input type="hidden" name="nav" value="{{ $nav ?? '' }}">
+
+            <div class="card mb-3">
+                <div class="card-header"><strong>Заказ</strong></div>
+                <div class="card-body">
+                    <div class="row">
+                        <div class="col-xl-6">
+                            @if($canView('order_number'))
+                                @include('partials.input', ['name' => 'order_number', 'title' => 'Номер заказа', 'required' => true, 'value' => $order?->order_number, 'disabled' => !$canUpdate('order_number')])
+                            @endif
+                            @if($canView('order_year'))
+                                @include('partials.input', ['name' => 'order_year', 'title' => 'Год заказа', 'type' => 'number', 'min' => 2000, 'max' => 2100, 'required' => true, 'value' => $order?->order_year ?? now()->year, 'disabled' => !$canUpdate('order_year')])
+                            @endif
+                            @if($canView('customer_name'))
+                                @include('partials.input', ['name' => 'customer_name', 'title' => 'Заказчик', 'required' => true, 'value' => $order?->customer_name, 'disabled' => !$canUpdate('customer_name')])
+                            @endif
+                            @if($canView('object_address'))
+                                <div class="row mb-2">
+                                    <label for="object_address" class="col-form-label small col-md-4 text-md-end">Адрес объекта <sup>*</sup></label>
+                                    <div class="col-md-8">
+                                        <textarea name="object_address" id="object_address" rows="3" required
+                                                  @disabled(!$canUpdate('object_address'))
+                                                  class="form-control form-control-sm @error('object_address') is-invalid @enderror">{{ old('object_address', $order?->object_address) }}</textarea>
+                                        @error('object_address') <div class="invalid-feedback"><strong>{{ $message }}</strong></div> @enderror
+                                    </div>
+                                </div>
+                            @endif
+                            @if($canView('manager_id'))
+                                @include('partials.select', ['name' => 'manager_id', 'title' => 'Менеджер', 'required' => true, 'first_empty' => true, 'options' => $managers, 'value' => old('manager_id', $order?->manager_id), 'disabled' => !$canUpdate('manager_id')])
+                            @endif
+                            @if($canView('status'))
+                                @include('partials.select', ['name' => 'status', 'title' => 'Статус', 'required' => true, 'options' => $statuses, 'value' => old('status', $order?->status?->value ?? \App\Enums\ProductionOrderStatus::Placed->value), 'disabled' => !$canUpdate('status')])
+                            @endif
+                            @if($canView('execution_type'))
+                                @include('partials.select', ['name' => 'execution_type', 'title' => 'Тип исполнения', 'required' => true, 'first_empty' => true, 'options' => $executionTypes, 'value' => old('execution_type', $order?->execution_type?->value), 'disabled' => !$canUpdate('execution_type')])
+                            @endif
+                        </div>
+                        <div class="col-xl-6">
+                            @if($canView('invoice_number'))
+                                @include('partials.input', ['name' => 'invoice_number', 'title' => '№ счёта', 'required' => true, 'value' => $order?->invoice_number, 'disabled' => !$canUpdate('invoice_number')])
+                            @endif
+                            @if($canView('invoice_date'))
+                                @include('partials.input', ['name' => 'invoice_date', 'title' => 'Дата счёта', 'type' => 'date', 'value' => $order?->invoice_date?->toDateString(), 'disabled' => !$canUpdate('invoice_date')])
+                            @endif
+                            @if($canView('contract_number'))
+                                @include('partials.input', ['name' => 'contract_number', 'title' => '№ договора', 'value' => $order?->contract_number, 'disabled' => !$canUpdate('contract_number')])
+                            @endif
+                            @if($canView('contract_date'))
+                                @include('partials.input', ['name' => 'contract_date', 'title' => 'Дата договора', 'type' => 'date', 'value' => $order?->contract_date?->toDateString(), 'disabled' => !$canUpdate('contract_date')])
+                            @endif
+                            @if($canView('payment_date'))
+                                @include('partials.input', ['name' => 'payment_date', 'title' => 'Дата оплаты', 'type' => 'date', 'required' => true, 'value' => $order?->payment_date?->toDateString(), 'disabled' => !$canUpdate('payment_date')])
+                            @endif
+                            @if($canView('supply_working_days'))
+                                @include('partials.input', ['name' => 'supply_working_days', 'title' => 'Срок поставки, раб. дней', 'type' => 'number', 'min' => 0, 'max' => 2000, 'required' => true, 'value' => $order?->supply_working_days, 'disabled' => !$canUpdate('supply_working_days')])
+                            @endif
+                            @if($order && $canView('contract_shipment_date'))
+                                @include('partials.input', ['name' => 'contract_shipment_date_display', 'title' => 'Дата отгрузки по договору', 'type' => 'date', 'value' => $order->contract_shipment_date?->toDateString(), 'disabled' => true])
+                            @endif
+                            @if($canView('application_shipment_date'))
+                                @include('partials.input', ['name' => 'application_shipment_date', 'title' => 'Дата отгрузки по заявке', 'type' => 'date', 'value' => $order?->application_shipment_date?->toDateString(), 'disabled' => !$canUpdate('application_shipment_date')])
+                            @endif
+                            @if($canView('note'))
+                                <div class="row mb-2">
+                                    <label for="note" class="col-form-label small col-md-4 text-md-end">Примечание</label>
+                                    <div class="col-md-8">
+                                        <textarea name="note" id="note" rows="3" @disabled(!$canUpdate('note'))
+                                                  class="form-control form-control-sm @error('note') is-invalid @enderror">{{ old('note', $order?->note) }}</textarea>
+                                        @error('note') <div class="invalid-feedback"><strong>{{ $message }}</strong></div> @enderror
+                                    </div>
+                                </div>
+                            @endif
+                        </div>
+                    </div>
+                </div>
+            </div>
+
+            <div class="card mb-3">
+                <div class="card-header d-flex justify-content-between align-items-center">
+                    <strong>Оборудование</strong>
+                </div>
+                <div class="card-body {{ $canManageItems ? 'pb-2' : 'p-0' }}">
+                    @if($canManageItems)
+                        <div class="mb-3">
+                            <label for="production-order-catalog-search" class="form-label small mb-1">
+                                Добавить МАФ из общего каталога
+                            </label>
+                            <div class="row g-2 mb-2">
+                                <div class="col-md-9">
+                                    <input type="search" id="production-order-catalog-search"
+                                           class="form-control form-control-sm"
+                                           placeholder="Введите артикул или наименование">
+                                </div>
+                                <div class="col-md-3">
+                                    <div class="input-group input-group-sm">
+                                        <span class="input-group-text">Количество</span>
+                                        <input type="number" id="production-order-catalog-quantity"
+                                               class="form-control" min="1" max="100" value="1">
+                                    </div>
+                                </div>
+                            </div>
+                            <select id="production-order-catalog-results" class="form-select form-select-sm" size="6"
+                                    aria-label="Результаты поиска МАФ"></select>
+                            <div class="form-text">
+                                Введите не менее двух символов и выберите позицию. Для каждой единицы будет создана отдельная строка.
+                            </div>
+                        </div>
+                    @endif
+                    <div class="table-responsive">
+                        <table class="table table-sm align-middle mb-0">
+                            <thead>
+                            <tr>
+                                <th>Позиция общего каталога</th>
+                                <th>Номер заказа МАФ</th>
+                                <th>Заводской номер</th>
+                                <th>Дата производства</th>
+                                @if($canManageItems)<th></th>@endif
+                            </tr>
+                            </thead>
+                            <tbody id="production-order-items">
+                            @foreach($itemRows as $index => $itemRow)
+                                @include('production_orders.partials.item-row', ['index' => $index, 'itemRow' => $itemRow])
+                            @endforeach
+                            </tbody>
+                        </table>
+                    </div>
+                    @error('items') <div class="text-danger small p-2"><strong>{{ $message }}</strong></div> @enderror
+                </div>
+            </div>
+
+            <div class="d-flex flex-wrap gap-2">
+                @if(($order && hasPermission('schedule-orders.update')) || (!$order && hasPermission('schedule-orders.create')))
+                    <button type="submit" class="btn btn-sm btn-primary">Сохранить</button>
+                @endif
+                <a href="{{ $back_url }}" class="btn btn-sm btn-outline-secondary">Назад</a>
+            </div>
+        </form>
+
+        @if($canManageItems)
+            <template id="production-order-item-template">
+                @include('production_orders.partials.item-row', [
+                    'index' => '__INDEX__',
+                    'itemRow' => ['common_catalog_item_id' => '', 'order_item_number' => '', 'factory_number' => '', 'manufacture_date' => ''],
+                ])
+            </template>
+        @endif
+    </div>
+
+    @if($canManageItems)
+        @push('scripts')
+            <script>
+                document.addEventListener('DOMContentLoaded', () => {
+                    const rows = document.getElementById('production-order-items');
+                    const template = document.getElementById('production-order-item-template');
+                    const search = document.getElementById('production-order-catalog-search');
+                    const results = document.getElementById('production-order-catalog-results');
+                    const quantity = document.getElementById('production-order-catalog-quantity');
+                    let nextIndex = rows.querySelectorAll('tr').length;
+                    let searchTimer;
+                    let requestNumber = 0;
+
+                    search.addEventListener('input', () => {
+                        clearTimeout(searchTimer);
+                        const query = search.value.trim();
+                        results.replaceChildren();
+                        const currentRequest = ++requestNumber;
+                        if (query.length < 2) return;
+
+                        searchTimer = setTimeout(async () => {
+                            try {
+                                const url = new URL(@json(route('schedule.orders.catalog-search')), window.location.origin);
+                                url.searchParams.set('q', query);
+                                const response = await fetch(url, {headers: {'Accept': 'application/json'}});
+                                if (!response.ok || currentRequest !== requestNumber) return;
+
+                                const items = await response.json();
+                                results.replaceChildren(...items.map((item) => {
+                                    const option = document.createElement('option');
+                                    option.value = item.id;
+                                    option.textContent = item.label;
+                                    return option;
+                                }));
+                            } catch (error) {
+                                if (currentRequest === requestNumber) results.replaceChildren();
+                            }
+                        }, 250);
+                    });
+
+                    results.addEventListener('change', () => {
+                        const option = results.selectedOptions[0];
+                        if (!option) return;
+
+                        const count = Math.min(100, Math.max(1, Number.parseInt(quantity.value, 10) || 1));
+                        for (let i = 0; i < count; i++) {
+                            const fragment = template.content.cloneNode(true);
+                            const row = fragment.querySelector('tr');
+                            row.innerHTML = row.innerHTML.replaceAll('__INDEX__', nextIndex++);
+                            row.querySelector('.production-order-item-catalog-id').value = option.value;
+                            row.querySelector('.production-order-item-catalog-label').textContent = option.textContent;
+                            rows.appendChild(fragment);
+                        }
+                        search.value = '';
+                        quantity.value = '1';
+                        results.replaceChildren();
+                    });
+
+                    rows.addEventListener('click', (event) => {
+                        const button = event.target.closest('.remove-production-order-item');
+                        if (!button) return;
+                        button.closest('tr').remove();
+                    });
+                });
+            </script>
+        @endpush
+    @endif
+@endsection

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

@@ -0,0 +1,29 @@
+@extends('layouts.app')
+
+@section('content')
+    <div class="row mb-2 page-header-row">
+        <div class="col-12 col-md-6 page-header-title">
+            <h3>График заказов</h3>
+        </div>
+        <div class="col-12 col-md-6 text-md-end page-header-actions">
+            @if(hasPermission('schedule-orders.create'))
+                <a href="{{ route('schedule.orders.create', ['nav' => $nav ?? null]) }}"
+                   class="btn btn-sm btn-primary page-action-btn">
+                    <i class="bi bi-plus-lg page-action-btn__icon"></i>
+                    <span class="page-action-btn__label">Добавить заказ</span>
+                </a>
+            @endif
+        </div>
+    </div>
+
+    @include('partials.table', [
+        'id' => $id,
+        'header' => $header,
+        'strings' => $orders,
+        'routeName' => 'schedule.orders.show',
+        'routeParam' => 'productionOrder',
+        'nav' => $nav,
+    ])
+
+    @include('partials.pagination', ['items' => $orders])
+@endsection

+ 52 - 0
resources/views/production_orders/partials/item-row.blade.php

@@ -0,0 +1,52 @@
+<tr>
+    <td style="min-width: 320px">
+        @if(!empty($itemRow['id']))
+            <input type="hidden" name="items[{{ $index }}][id]" value="{{ $itemRow['id'] }}">
+        @endif
+        @php($catalogItem = $catalogItems->get((int) ($itemRow['common_catalog_item_id'] ?? 0)))
+        <input type="hidden" name="items[{{ $index }}][common_catalog_item_id]"
+               class="production-order-item-catalog-id"
+               value="{{ $itemRow['common_catalog_item_id'] ?? '' }}">
+        <span class="production-order-item-catalog-label">
+            @if($catalogItem)
+                {{ $catalogItem->article }} — {{ $catalogItem->calculator_name ?: $catalogItem->print_name ?: 'Без наименования' }}
+            @endif
+        </span>
+        @error('items.'.$index.'.common_catalog_item_id') <div class="text-danger small"><strong>{{ $message }}</strong></div> @enderror
+    </td>
+    <td style="min-width: 180px">
+        @if($canManageItems)
+            <input type="text" name="items[{{ $index }}][order_item_number]" required
+                   value="{{ $itemRow['order_item_number'] ?? '' }}"
+                   class="form-control form-control-sm @error('items.'.$index.'.order_item_number') is-invalid @enderror">
+            @error('items.'.$index.'.order_item_number') <div class="invalid-feedback"><strong>{{ $message }}</strong></div> @enderror
+        @else
+            <input type="hidden" name="items[{{ $index }}][order_item_number]"
+                   value="{{ $itemRow['order_item_number'] ?? '' }}">
+            {{ $itemRow['order_item_number'] ?? '' }}
+        @endif
+    </td>
+    <td style="min-width: 160px">
+        @if($canManageItems || $canUpdateFactoryNumber)
+            <input type="text" name="items[{{ $index }}][factory_number]"
+                   value="{{ $itemRow['factory_number'] ?? '' }}" class="form-control form-control-sm">
+        @else
+            {{ $itemRow['factory_number'] ?? '' }}
+        @endif
+    </td>
+    <td style="min-width: 160px">
+        @if($canManageItems || $canUpdateManufactureDate)
+            <input type="date" name="items[{{ $index }}][manufacture_date]"
+                   value="{{ $itemRow['manufacture_date'] ?? '' }}" class="form-control form-control-sm">
+        @else
+            {{ $itemRow['manufacture_date'] ?? '' }}
+        @endif
+    </td>
+    @if($canManageItems)
+        <td class="text-end">
+            <button type="button" class="btn btn-sm btn-outline-danger remove-production-order-item" title="Удалить">
+                <i class="bi bi-trash"></i>
+            </button>
+        </td>
+    @endif
+</tr>

+ 9 - 4
routes/web.php

@@ -19,6 +19,7 @@ use App\Http\Controllers\OrderController;
 use App\Http\Controllers\PricingCodeController;
 use App\Http\Controllers\ProductController;
 use App\Http\Controllers\ProductSKUController;
+use App\Http\Controllers\ProductionOrderController;
 use App\Http\Controllers\ReclamationController;
 use App\Http\Controllers\ReportController;
 use App\Http\Controllers\ResponsibleController;
@@ -176,10 +177,14 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
     });
 
     Route::prefix('schedules')->name('schedule.')->group(function () {
-        Route::get('orders', UnderDevelopmentController::class)
-            ->defaults('title', 'График заказов')
-            ->defaults('active', 'schedule_orders')
-            ->name('orders');
+        Route::get('orders', [ProductionOrderController::class, 'index'])->name('orders');
+        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', [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');
+        Route::delete('orders/{productionOrder}', [ProductionOrderController::class, 'destroy'])->name('orders.destroy');
         Route::get('deliveries', UnderDevelopmentController::class)
             ->defaults('title', 'График доставок')
             ->defaults('active', 'schedule_deliveries')

+ 330 - 0
tests/Feature/ProductionOrderControllerTest.php

@@ -0,0 +1,330 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tests\Feature;
+
+use App\Enums\ProductionOrderExecutionType;
+use App\Enums\ProductionOrderStatus;
+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 Tests\TestCase;
+
+class ProductionOrderControllerTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected bool $seed = true;
+
+    private User $admin;
+
+    private User $manager;
+
+    private User $assistant;
+
+    private CommonCatalogItem $catalogItem;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->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_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<string, mixed> */
+    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);
+    }
+}

+ 4 - 1
tests/Unit/Helpers/RolesHelperTest.php

@@ -22,6 +22,7 @@ class RolesHelperTest extends TestCase
         $this->assertArrayHasKey(Role::BRIGADIER, $roles);
         $this->assertArrayHasKey(Role::WAREHOUSE_HEAD, $roles);
         $this->assertArrayHasKey(Role::ASSISTANT_HEAD, $roles);
+        $this->assertArrayHasKey(Role::DRIVER, $roles);
     }
 
     public function test_get_roles_returns_name_by_key(): void
@@ -31,6 +32,7 @@ class RolesHelperTest extends TestCase
         $this->assertEquals('Бригадир', getRoles(Role::BRIGADIER));
         $this->assertEquals('Рук. Склада', getRoles(Role::WAREHOUSE_HEAD));
         $this->assertEquals('Помощник рук.', getRoles(Role::ASSISTANT_HEAD));
+        $this->assertEquals('Водитель', getRoles(Role::DRIVER));
     }
 
     public function test_get_roles_returns_all_when_key_not_found(): void
@@ -38,7 +40,7 @@ class RolesHelperTest extends TestCase
         $result = getRoles('nonexistent');
 
         $this->assertIsArray($result);
-        $this->assertCount(5, $result);
+        $this->assertCount(6, $result);
     }
 
     public function test_has_role_returns_true_for_correct_role(): void
@@ -85,6 +87,7 @@ class RolesHelperTest extends TestCase
         $this->assertEquals('Бригадир', roleName(Role::BRIGADIER));
         $this->assertEquals('Рук. Склада', roleName(Role::WAREHOUSE_HEAD));
         $this->assertEquals('Помощник рук.', roleName(Role::ASSISTANT_HEAD));
+        $this->assertEquals('Водитель', roleName(Role::DRIVER));
     }
 
     public function test_file_name_replaces_special_chars(): void

+ 39 - 0
tests/Unit/Models/ProductionOrderTest.php

@@ -0,0 +1,39 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tests\Unit\Models;
+
+use App\Enums\ProductionOrderExecutionType;
+use App\Enums\ProductionOrderStatus;
+use App\Models\ProductionOrder;
+use App\Models\ProductionOrderDelivery;
+use App\Models\ProductionOrderInstallation;
+use App\Models\ProductionOrderItem;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
+
+class ProductionOrderTest extends TestCase
+{
+    use RefreshDatabase;
+
+    public function test_order_relations_keep_items_deliveries_and_installations_normalized(): void
+    {
+        $order = ProductionOrder::factory()->create([
+            'status' => ProductionOrderStatus::InTransit,
+            'execution_type' => ProductionOrderExecutionType::DeliveryAndInstallation,
+        ]);
+        ProductionOrderItem::factory()->count(2)->for($order)->create();
+        ProductionOrderDelivery::factory()->count(2)->for($order)->create();
+        ProductionOrderInstallation::factory()->count(2)->for($order)->create();
+
+        $order->load(['items', 'deliveries', 'installations']);
+
+        $this->assertCount(2, $order->items);
+        $this->assertCount(2, $order->deliveries);
+        $this->assertCount(2, $order->installations);
+        $this->assertSame(ProductionOrderStatus::InTransit, $order->status);
+        $this->assertSame('В пути', $order->statusLabel());
+        $this->assertSame('Доставка + Монтаж', $order->executionTypeLabel());
+    }
+}

+ 18 - 0
tests/Unit/Services/AccessServiceTest.php

@@ -144,6 +144,24 @@ class AccessServiceTest extends TestCase
         $this->assertTrue(app(AccessService::class)->can($assistantHead, 'maf_orders.delete'));
     }
 
+    public function test_production_order_default_permissions_follow_agreed_roles(): void
+    {
+        $admin = User::factory()->create(['role' => Role::ADMIN]);
+        $assistant = User::factory()->create(['role' => Role::ASSISTANT_HEAD]);
+        $manager = User::factory()->create(['role' => Role::MANAGER]);
+        $driver = User::factory()->create(['role' => Role::DRIVER]);
+
+        $this->assertTrue($admin->hasPermission('schedule-orders.create'));
+        $this->assertTrue($assistant->hasPermission('schedule-orders.update'));
+        $this->assertTrue($assistant->canUpdateField('schedule-orders', 'object_address'));
+        $this->assertFalse($assistant->canUpdateField('schedule-orders', 'order_number'));
+        $this->assertFalse($assistant->hasPermission('schedule-orders.create'));
+        $this->assertTrue($manager->hasPermission('schedule-orders.view'));
+        $this->assertFalse($manager->hasPermission('schedule-orders.update'));
+        $this->assertTrue($driver->hasPermission('schedule-orders.view'));
+        $this->assertFalse($driver->hasPermission('schedule-orders.update'));
+    }
+
     public function test_custom_role_with_admin_permissions_has_same_action_access(): void
     {
         $this->seed(RbacSeeder::class);

+ 56 - 0
tests/Unit/Services/ProductionCalendarServiceTest.php

@@ -0,0 +1,56 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tests\Unit\Services;
+
+use App\Models\ProductionCalendarDay;
+use App\Services\ProductionCalendarService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use InvalidArgumentException;
+use Tests\TestCase;
+
+class ProductionCalendarServiceTest extends TestCase
+{
+    use RefreshDatabase;
+
+    public function test_it_skips_weekends_by_default(): void
+    {
+        $result = app(ProductionCalendarService::class)->addWorkingDays('2024-01-05', 1);
+
+        $this->assertSame('2024-01-08', $result->toDateString());
+    }
+
+    public function test_calendar_override_can_mark_a_weekday_as_non_working(): void
+    {
+        ProductionCalendarDay::factory()->create([
+            'date' => '2024-01-08',
+            'is_working_day' => false,
+            'name' => 'Праздник',
+        ]);
+
+        $result = app(ProductionCalendarService::class)->addWorkingDays('2024-01-05', 1);
+
+        $this->assertSame('2024-01-09', $result->toDateString());
+    }
+
+    public function test_calendar_override_can_mark_a_weekend_as_working(): void
+    {
+        ProductionCalendarDay::factory()->create([
+            'date' => '2024-01-06',
+            'is_working_day' => true,
+            'name' => 'Рабочая суббота',
+        ]);
+
+        $result = app(ProductionCalendarService::class)->addWorkingDays('2024-01-05', 1);
+
+        $this->assertSame('2024-01-06', $result->toDateString());
+    }
+
+    public function test_negative_working_days_are_rejected(): void
+    {
+        $this->expectException(InvalidArgumentException::class);
+
+        app(ProductionCalendarService::class)->addWorkingDays('2024-01-05', -1);
+    }
+}