Browse Source

Add contractors and installation specifications

Alexander Musikhin 1 week ago
parent
commit
ac55b4fa51

+ 167 - 0
app/Http/Controllers/ContractorController.php

@@ -0,0 +1,167 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\Contractor;
+use App\Models\Product;
+use App\Models\Scopes\YearScope;
+use App\Services\ContractorPriceService;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+use Illuminate\View\View;
+use Symfony\Component\HttpFoundation\BinaryFileResponse;
+
+class ContractorController extends Controller
+{
+    protected string $id = 'contractors';
+    protected array $header = [
+        'id' => 'ID',
+        'name' => 'Наименование',
+        'legal_name' => 'Юридическое имя',
+        'contract_number' => '№ договора',
+        'contract_date' => 'Дата договора',
+        'organization_form_name' => 'Форма',
+        'tax_rate_name' => 'Налог',
+        'hidden_txt' => 'Скрыт',
+    ];
+    protected array $searchFields = ['name', 'legal_name', 'contract_number', 'director_name'];
+
+    public function index(Request $request): View
+    {
+        session(['gp_contractors' => $request->query()]);
+        $nav = $this->startNavigationContext($request);
+
+        $allowedSortFields = ['id', 'name', 'legal_name', 'contract_number', 'contract_date', 'organization_form', 'tax_rate', 'hidden'];
+        $sortBy = in_array($request->get('sortBy'), $allowedSortFields, true) ? $request->get('sortBy') : 'name';
+        $orderBy = $request->get('order') === 'desc' ? 'desc' : 'asc';
+
+        $contractors = Contractor::query();
+        if ($request->filled('s')) {
+            $search = $request->get('s');
+            $contractors->where(function ($query) use ($search) {
+                foreach ($this->searchFields as $field) {
+                    $query->orWhere($field, 'like', '%' . $search . '%');
+                }
+            });
+        }
+
+        $contractors = $contractors->orderBy($sortBy, $orderBy)->get();
+
+        return view('contractors.index', [
+            'active' => 'contractors',
+            'id' => $this->id,
+            'header' => $this->header,
+            'sortBy' => $sortBy,
+            'orderBy' => $orderBy,
+            'searchFields' => $this->searchFields,
+            'contractors' => $contractors,
+            'organizationForms' => Contractor::ORGANIZATION_FORMS,
+            'taxRates' => Contractor::TAX_RATES,
+            'nav' => $nav,
+        ]);
+    }
+
+    public function create(Request $request, ContractorPriceService $priceService): View
+    {
+        return $this->showForm($request, null, $priceService);
+    }
+
+    public function show(Request $request, Contractor $contractor, ContractorPriceService $priceService): View
+    {
+        return $this->showForm($request, $contractor, $priceService);
+    }
+
+    public function store(Request $request): RedirectResponse
+    {
+        $validated = $this->validatedContractor($request);
+        $contractor = Contractor::query()->create($validated);
+
+        return redirect()->route('contractors.show', $contractor)->with('success', 'Подрядчик создан');
+    }
+
+    public function update(Request $request, Contractor $contractor): RedirectResponse
+    {
+        $contractor->update($this->validatedContractor($request));
+
+        return redirect()->route('contractors.show', $contractor)->with('success', 'Подрядчик сохранён');
+    }
+
+    public function updatePrice(Request $request, Contractor $contractor, ContractorPriceService $priceService): RedirectResponse
+    {
+        $validated = $request->validate([
+            'product_id' => ['required', 'integer', 'exists:products,id'],
+            'name_in_spec' => ['nullable', 'string', 'max:255'],
+            'price' => ['nullable', 'numeric', 'min:0'],
+        ]);
+
+        $product = Product::withoutGlobalScope(YearScope::class)->withTrashed()->findOrFail($validated['product_id']);
+        $priceService->updatePrice(
+            $contractor,
+            $product,
+            year(),
+            $validated['name_in_spec'] ?? null,
+            (float) ($validated['price'] ?? 0),
+        );
+
+        return redirect()->route('contractors.show', $contractor)->with('success', 'Цена монтажа сохранена');
+    }
+
+    public function importPrices(Request $request, Contractor $contractor, ContractorPriceService $priceService): RedirectResponse
+    {
+        $validated = $request->validate([
+            'import_file' => ['required', 'file', 'mimes:xlsx,xls'],
+        ]);
+
+        $result = $priceService->import($contractor, $validated['import_file'], year());
+        $message = "Импорт завершён. Обновлено: {$result['updated']}. Без изменений: {$result['unchanged']}. Ошибок: " . count($result['errors']) . '.';
+
+        return redirect()
+            ->route('contractors.show', $contractor)
+            ->with('success', $message)
+            ->with('contractor_import_errors', $result['errors']);
+    }
+
+    public function exportPrices(Request $request, Contractor $contractor, ContractorPriceService $priceService): BinaryFileResponse
+    {
+        $catalogYear = year();
+
+        $path = $priceService->export($contractor, $catalogYear);
+
+        return response()
+            ->download($path, 'Цены монтажа ' . $contractor->name . ' ' . $catalogYear . '.xlsx')
+            ->deleteFileAfterSend();
+    }
+
+    private function showForm(Request $request, ?Contractor $contractor, ContractorPriceService $priceService): View
+    {
+        $nav = $this->resolveNavToken($request);
+        $this->rememberNavigation($request, $nav);
+        $year = year();
+
+        return view('contractors.edit', [
+            'active' => 'contractors',
+            'contractor' => $contractor,
+            'organizationForms' => Contractor::ORGANIZATION_FORMS,
+            'taxRates' => Contractor::TAX_RATES,
+            'priceRows' => $contractor ? $priceService->rowsForContractor($contractor, $year) : collect(),
+            'catalogYear' => $year,
+            'nav' => $nav,
+            'back_url' => $this->navigationBackUrl($request, $nav, route('contractors.index', session('gp_contractors'))),
+        ]);
+    }
+
+    private function validatedContractor(Request $request): array
+    {
+        return $request->validate([
+            'name' => ['required', 'string', 'max:255'],
+            'legal_name' => ['required', 'string', 'max:255'],
+            'contract_number' => ['required', 'string', 'max:255'],
+            'contract_date' => ['required', 'date'],
+            'director_name' => ['required', 'string', 'max:255'],
+            'organization_form' => ['required', 'string', 'in:' . implode(',', array_keys(Contractor::ORGANIZATION_FORMS))],
+            'tax_rate' => ['required', 'string', 'in:' . implode(',', array_keys(Contractor::TAX_RATES))],
+            'contract_header' => ['required', 'string'],
+            'hidden' => ['nullable', 'boolean'],
+        ]) + ['hidden' => false];
+    }
+}

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

@@ -14,6 +14,7 @@ use App\Jobs\GenerateInstallationPack;
 use App\Jobs\GenerateTtnPack;
 use App\Models\Dictionary\Area;
 use App\Models\Dictionary\District;
+use App\Models\Contractor;
 use App\Models\File;
 use App\Models\MafOrder;
 use App\Models\ObjectType;
@@ -26,6 +27,7 @@ use App\Models\Schedule;
 use App\Models\Ttn;
 use App\Models\User;
 use App\Services\FileService;
+use App\Services\ContractorSpecificationService;
 use App\Services\NotificationService;
 use Illuminate\Http\RedirectResponse;
 use Illuminate\Http\Request;
@@ -355,6 +357,10 @@ class OrderController extends Controller
         $this->data['chatResponsibleUserIds'] = $responsibleUserIds;
         $this->data['chatManagerUserId'] = $orderModel?->user_id ? (int) $orderModel->user_id : null;
         $this->data['chatBrigadierUserId'] = $orderModel?->brigadier_id ? (int) $orderModel->brigadier_id : null;
+        $this->data['contractors'] = Contractor::query()
+            ->where('hidden', false)
+            ->orderBy('name')
+            ->pluck('name', 'id');
         return view('orders.show', $this->data);
     }
 
@@ -722,6 +728,33 @@ class OrderController extends Controller
         return redirect()->back()->with(['success' => 'Задача формирования ТН создана!']);
     }
 
+    public function createContractorSpecification(Request $request, int $order, ContractorSpecificationService $service): BinaryFileResponse|RedirectResponse
+    {
+        $validated = $request->validate([
+            'contractor_id' => ['required', 'integer', 'exists:contractors,id'],
+            'specification_number' => ['required', 'string', 'max:255'],
+            'specification_date' => ['required', 'date'],
+            'work_start_date' => ['nullable', 'date'],
+            'work_end_date' => ['nullable', 'date'],
+            'skus' => ['required', 'array', 'min:1'],
+            'skus.*' => ['required', 'integer', 'exists:products_sku,id'],
+        ]);
+
+        $contractor = Contractor::query()
+            ->where('hidden', false)
+            ->findOrFail($validated['contractor_id']);
+
+        $orderModel = Order::query()
+            ->withoutGlobalScope(\App\Models\Scopes\YearScope::class)
+            ->findOrFail($order);
+
+        $path = $service->generate($orderModel, $contractor, $validated);
+
+        return response()
+            ->download($path, 'Спецификация №' . $validated['specification_number'] . '.xlsx')
+            ->deleteFileAfterSend();
+    }
+
     public function export(Request $request)
     {
         $request->validate([

+ 100 - 0
app/Models/Contractor.php

@@ -0,0 +1,100 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Casts\Attribute;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+
+class Contractor extends Model
+{
+    use HasFactory;
+
+    public const ORGANIZATION_FORM_IP = 'ip';
+    public const ORGANIZATION_FORM_OOO = 'ooo';
+    public const ORGANIZATION_FORM_SELF_EMPLOYED = 'self_employed';
+
+    public const TAX_WITHOUT_VAT = 'without_vat';
+    public const TAX_5 = '5';
+    public const TAX_7 = '7';
+    public const TAX_15 = '15';
+    public const TAX_22 = '22';
+
+    public const ORGANIZATION_FORMS = [
+        self::ORGANIZATION_FORM_IP => 'ИП',
+        self::ORGANIZATION_FORM_OOO => 'ООО',
+        self::ORGANIZATION_FORM_SELF_EMPLOYED => 'Самозанятый',
+    ];
+
+    public const TAX_RATES = [
+        self::TAX_WITHOUT_VAT => 'Без НДС',
+        self::TAX_5 => '5%',
+        self::TAX_7 => '7%',
+        self::TAX_15 => '15%',
+        self::TAX_22 => '22%',
+    ];
+
+    public const SIGNER_TITLES = [
+        self::ORGANIZATION_FORM_IP => 'Индивидуальный предприниматель',
+        self::ORGANIZATION_FORM_OOO => 'Генеральный директор',
+        self::ORGANIZATION_FORM_SELF_EMPLOYED => 'Самозанятый',
+    ];
+
+    protected $fillable = [
+        'name',
+        'legal_name',
+        'contract_number',
+        'contract_date',
+        'director_name',
+        'organization_form',
+        'tax_rate',
+        'contract_header',
+        'hidden',
+    ];
+
+    protected $casts = [
+        'contract_date' => 'date',
+        'hidden' => 'boolean',
+    ];
+
+    protected $appends = [
+        'organization_form_name',
+        'tax_rate_name',
+        'hidden_txt',
+        'signer_title',
+    ];
+
+    public function prices(): HasMany
+    {
+        return $this->hasMany(ContractorInstallationPrice::class);
+    }
+
+    protected function organizationFormName(): Attribute
+    {
+        return Attribute::make(
+            get: fn() => self::ORGANIZATION_FORMS[$this->organization_form] ?? $this->organization_form,
+        );
+    }
+
+    protected function taxRateName(): Attribute
+    {
+        return Attribute::make(
+            get: fn() => self::TAX_RATES[$this->tax_rate] ?? $this->tax_rate,
+        );
+    }
+
+    protected function hiddenTxt(): Attribute
+    {
+        return Attribute::make(
+            get: fn() => $this->hidden ? 'Да' : 'Нет',
+        );
+    }
+
+    protected function signerTitle(): Attribute
+    {
+        return Attribute::make(
+            get: fn() => self::SIGNER_TITLES[$this->organization_form] ?? '',
+        );
+    }
+}

+ 51 - 0
app/Models/ContractorInstallationPrice.php

@@ -0,0 +1,51 @@
+<?php
+
+namespace App\Models;
+
+use App\Helpers\Price;
+use Illuminate\Database\Eloquent\Casts\Attribute;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+class ContractorInstallationPrice extends Model
+{
+    use HasFactory;
+
+    protected $fillable = [
+        'contractor_id',
+        'product_id',
+        'catalog_year',
+        'name_in_spec',
+        'price',
+    ];
+
+    protected $appends = [
+        'price_txt',
+    ];
+
+    public function contractor(): BelongsTo
+    {
+        return $this->belongsTo(Contractor::class);
+    }
+
+    public function product(): BelongsTo
+    {
+        return $this->belongsTo(Product::class, 'product_id')->withoutGlobalScope(\App\Models\Scopes\YearScope::class)->withTrashed();
+    }
+
+    protected function price(): Attribute
+    {
+        return Attribute::make(
+            get: fn(int|null $value) => (float) ($value ?? 0) / 100,
+            set: fn(float|int|string|null $value) => (int) round(((float) ($value ?? 0)) * 100),
+        );
+    }
+
+    protected function priceTxt(): Attribute
+    {
+        return Attribute::make(
+            get: fn() => Price::format($this->price),
+        );
+    }
+}

+ 200 - 0
app/Services/ContractorPriceService.php

@@ -0,0 +1,200 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\Contractor;
+use App\Models\ContractorInstallationPrice;
+use App\Models\Product;
+use App\Models\Scopes\YearScope;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Collection;
+use PhpOffice\PhpSpreadsheet\IOFactory;
+use PhpOffice\PhpSpreadsheet\Spreadsheet;
+use PhpOffice\PhpSpreadsheet\Style\Border;
+use PhpOffice\PhpSpreadsheet\Worksheet\Drawing;
+use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
+
+class ContractorPriceService
+{
+    public function rowsForContractor(Contractor $contractor, int $year): Collection
+    {
+        $currentProducts = Product::query()
+            ->where('year', $year)
+            ->orderBy('article')
+            ->get()
+            ->keyBy('id');
+
+        $prices = $contractor->prices()
+            ->where('catalog_year', $year)
+            ->with('product')
+            ->get()
+            ->keyBy('product_id');
+
+        $rows = collect();
+
+        foreach ($currentProducts as $product) {
+            $rows->push($this->buildRow($product, $prices->get($product->id), true));
+        }
+
+        foreach ($prices as $price) {
+            if ($currentProducts->has($price->product_id) || !$price->product) {
+                continue;
+            }
+
+            $rows->push($this->buildRow($price->product, $price, false));
+        }
+
+        return $rows;
+    }
+
+    public function updatePrice(Contractor $contractor, Product $product, int $year, ?string $nameInSpec, float $price): ContractorInstallationPrice
+    {
+        return ContractorInstallationPrice::query()->updateOrCreate(
+            [
+                'contractor_id' => $contractor->id,
+                'product_id' => $product->id,
+                'catalog_year' => $year,
+            ],
+            [
+                'name_in_spec' => $nameInSpec ?: null,
+                'price' => max(0, $price),
+            ],
+        );
+    }
+
+    public function import(Contractor $contractor, UploadedFile $file, int $year): array
+    {
+        $spreadsheet = IOFactory::load($file->getRealPath());
+        $sheet = $spreadsheet->getActiveSheet();
+        $highestRow = $sheet->getHighestDataRow();
+
+        $updated = 0;
+        $unchanged = 0;
+        $errors = [];
+
+        for ($row = 2; $row <= $highestRow; $row++) {
+            $article = trim((string) $sheet->getCell('B' . $row)->getValue());
+
+            if ($article === '') {
+                continue;
+            }
+
+            $product = Product::query()
+                ->where('year', $year)
+                ->where('article', $article)
+                ->first();
+
+            if (!$product) {
+                $errors[] = "Строка {$row}: артикул {$article} не найден в каталоге {$year} года";
+                continue;
+            }
+
+            $nameInSpec = trim((string) $sheet->getCell('D' . $row)->getValue());
+            $price = $this->parsePrice($sheet->getCell('E' . $row)->getCalculatedValue());
+
+            $current = ContractorInstallationPrice::query()
+                ->where('contractor_id', $contractor->id)
+                ->where('product_id', $product->id)
+                ->where('catalog_year', $year)
+                ->first();
+
+            $currentName = (string) ($current?->name_in_spec ?? '');
+            $currentPrice = (float) ($current?->price ?? 0);
+
+            if ($current && $currentName === $nameInSpec && abs($currentPrice - $price) < 0.001) {
+                $unchanged++;
+                continue;
+            }
+
+            $this->updatePrice($contractor, $product, $year, $nameInSpec, $price);
+            $updated++;
+        }
+
+        return [
+            'updated' => $updated,
+            'unchanged' => $unchanged,
+            'errors' => $errors,
+        ];
+    }
+
+    public function export(Contractor $contractor, int $year): string
+    {
+        $spreadsheet = new Spreadsheet();
+        $sheet = $spreadsheet->getActiveSheet();
+        $sheet->setTitle('Цены монтажа');
+
+        $headers = [
+            'A' => 'Картинка МАФ',
+            'B' => 'Артикул МАФ',
+            'C' => 'Номер номенклатуры',
+            'D' => 'Наименование по спецификации',
+            'E' => 'Цена монтажа',
+        ];
+
+        foreach ($headers as $column => $title) {
+            $sheet->setCellValue($column . '1', $title);
+        }
+
+        $rowNumber = 2;
+        foreach ($this->rowsForContractor($contractor, $year) as $row) {
+            $this->insertProductImage($sheet, $row['product'], $rowNumber);
+            $sheet->setCellValue('B' . $rowNumber, $row['product']->article);
+            $sheet->setCellValue('C' . $rowNumber, $row['product']->nomenclature_number);
+            $sheet->setCellValue('D' . $rowNumber, $row['price']?->name_in_spec ?? '');
+            $sheet->setCellValue('E' . $rowNumber, $row['price']?->price ?? 0);
+            $sheet->getRowDimension($rowNumber)->setRowHeight(55);
+            $rowNumber++;
+        }
+
+        $sheet->getStyle('A1:E1')->getFont()->setBold(true);
+        $sheet->getStyle('A1:E' . max(1, $rowNumber - 1))->getBorders()->getAllBorders()->setBorderStyle(Border::BORDER_THIN);
+        $sheet->getStyle('E2:E' . max(2, $rowNumber))->getNumberFormat()->setFormatCode('#,##0.00');
+
+        foreach (range('A', 'E') as $column) {
+            $sheet->getColumnDimension($column)->setAutoSize(true);
+        }
+        $sheet->getColumnDimension('A')->setWidth(18);
+
+        $path = storage_path('app/contractor-prices-' . $contractor->id . '-' . $year . '-' . time() . '.xlsx');
+        (new Xlsx($spreadsheet))->save($path);
+
+        return $path;
+    }
+
+    private function buildRow(Product $product, ?ContractorInstallationPrice $price, bool $available): array
+    {
+        return [
+            'product' => $product,
+            'price' => $price,
+            'available' => $available && is_null($product->deleted_at),
+        ];
+    }
+
+    private function parsePrice(mixed $value): float
+    {
+        if ($value === null || $value === '') {
+            return 0;
+        }
+
+        $normalized = str_replace([' ', "\xc2\xa0", '₽'], '', (string) $value);
+        $normalized = str_replace(',', '.', $normalized);
+
+        return is_numeric($normalized) ? (float) $normalized : 0;
+    }
+
+    private function insertProductImage($sheet, Product $product, int $row): void
+    {
+        $path = public_path('images/main/' . $product->article . '.0000.0000.jpg');
+        if (!file_exists($path)) {
+            return;
+        }
+
+        $drawing = new Drawing();
+        $drawing->setPath($path);
+        $drawing->setCoordinates('A' . $row);
+        $drawing->setHeight(60);
+        $drawing->setOffsetX(4);
+        $drawing->setOffsetY(4);
+        $drawing->setWorksheet($sheet);
+    }
+}

+ 272 - 0
app/Services/ContractorSpecificationService.php

@@ -0,0 +1,272 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\Contractor;
+use App\Models\ContractorInstallationPrice;
+use App\Models\Order;
+use App\Models\ProductSKU;
+use Illuminate\Support\Carbon;
+use Illuminate\Validation\ValidationException;
+use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
+use PhpOffice\PhpSpreadsheet\Spreadsheet;
+use PhpOffice\PhpSpreadsheet\Style\Alignment;
+use PhpOffice\PhpSpreadsheet\Style\Border;
+use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
+use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
+
+class ContractorSpecificationService
+{
+    public function generate(Order $order, Contractor $contractor, array $data): string
+    {
+        $skus = ProductSKU::query()
+            ->with('product')
+            ->where('order_id', $order->id)
+            ->whereIn('id', $data['skus'])
+            ->get();
+
+        if ($skus->isEmpty()) {
+            throw ValidationException::withMessages([
+                'skus' => 'Выберите хотя бы один МАФ.',
+            ]);
+        }
+
+        $items = $this->buildItems($skus, $contractor);
+
+        $spreadsheet = new Spreadsheet();
+        $sheet = $spreadsheet->getActiveSheet();
+        $sheet->setTitle('Спецификация');
+
+        $specDate = Carbon::parse($data['specification_date']);
+        $workStart = !empty($data['work_start_date']) ? Carbon::parse($data['work_start_date']) : null;
+        $workEnd = !empty($data['work_end_date']) ? Carbon::parse($data['work_end_date']) : null;
+
+        $sheet->setCellValue('A2', 'Договор №' . $contractor->contract_number . ' от ' . $contractor->contract_date->format('d.m.Y') . ' г.');
+        $sheet->setCellValue('A5', 'Спецификация №' . $data['specification_number'] . ' от ' . $specDate->format('d.m.Y') . ' г.');
+        $sheet->setCellValue('A9', $contractor->contract_header);
+        $sheet->setCellValue('A12', 'г. Москва, ' . $order->common_name);
+
+        $headers = ['№ п/п', 'Наименование МАФ', 'Цена', 'Ед. изм', 'Кол-во', 'Стоимость'];
+        foreach ($headers as $index => $header) {
+            $sheet->setCellValue(Coordinate::stringFromColumnIndex($index + 1) . '14', $header);
+        }
+
+        $row = 15;
+        $total = 0.0;
+        foreach ($items as $index => $item) {
+            $sum = $item['price'] * $item['quantity'];
+            $total += $sum;
+
+            $sheet->setCellValue('A' . $row, $index + 1);
+            $sheet->setCellValue('B' . $row, $item['name']);
+            $sheet->setCellValue('C' . $row, $item['price']);
+            $sheet->setCellValue('D' . $row, $item['unit']);
+            $sheet->setCellValue('E' . $row, $item['quantity']);
+            $sheet->setCellValue('F' . $row, $sum);
+            $row++;
+        }
+
+        $summaryRow = max(21, $row + 1);
+        $vatRow = $summaryRow + 1;
+        $totalWordsRow = $summaryRow + 2;
+        $vatTextRow = $summaryRow + 3;
+        $workStartRow = $summaryRow + 4;
+        $workEndRow = $summaryRow + 5;
+        $contractRow = $summaryRow + 6;
+        $legalNameRow = $summaryRow + 9;
+        $signerTitleRow = $summaryRow + 10;
+        $directorRow = $summaryRow + 13;
+
+        $vat = $this->calculateVat($total, $contractor->tax_rate);
+
+        $sheet->setCellValue('E' . $summaryRow, 'Итого');
+        $sheet->setCellValue('F' . $summaryRow, $total);
+
+        if ($vat !== null) {
+            $sheet->setCellValue('E' . $vatRow, 'НДС');
+            $sheet->setCellValue('F' . $vatRow, $vat);
+        }
+
+        $sheet->setCellValue('A' . $totalWordsRow, $this->formatAmountWithWords($total));
+        $sheet->mergeCells('A' . $totalWordsRow . ':F' . $totalWordsRow);
+
+        if ($vat === null) {
+            $sheet->setCellValue('A' . $vatTextRow, 'Без НДС');
+        } else {
+            $sheet->setCellValue('A' . $vatTextRow, 'В т.ч. НДС ' . $contractor->tax_rate . '% ' . number_format($vat, 2, ',', ' ') . ' руб.');
+        }
+
+        $sheet->setCellValue('A' . $workStartRow, 'Начало работ: ' . ($workStart?->format('d.m.Y') ?? ''));
+        $sheet->setCellValue('A' . $workEndRow, 'Окончание работ: ' . ($workEnd?->format('d.m.Y') ?? ''));
+        $sheet->setCellValue('A' . $contractRow, 'Договор №' . $contractor->contract_number . ' от ' . $contractor->contract_date->format('d.m.Y') . ' г.');
+        $sheet->setCellValue('A' . $legalNameRow, $contractor->legal_name);
+        $sheet->setCellValue('A' . $signerTitleRow, $contractor->signer_title);
+        $sheet->setCellValue('A' . $directorRow, $contractor->director_name);
+
+        $lastRow = $directorRow;
+        $sheet->getStyle('A14:F' . max(14, $row - 1))->getBorders()->getAllBorders()->setBorderStyle(Border::BORDER_THIN);
+        $sheet->getStyle('A14:F14')->getFont()->setBold(true);
+        $sheet->getStyle('A14:F14')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
+        $sheet->getStyle('C15:C' . $lastRow)->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_NUMBER_00);
+        $sheet->getStyle('F15:F' . $lastRow)->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_NUMBER_00);
+        $sheet->getStyle('A9:A12')->getAlignment()->setWrapText(true);
+
+        foreach (['A' => 10, 'B' => 50, 'C' => 14, 'D' => 12, 'E' => 12, 'F' => 16] as $column => $width) {
+            $sheet->getColumnDimension($column)->setWidth($width);
+        }
+
+        $safeNumber = preg_replace('/[^0-9A-Za-zА-Яа-я_-]+/u', '_', (string) $data['specification_number']);
+        $path = storage_path('app/specification-' . $order->id . '-' . $contractor->id . '-' . $safeNumber . '.xlsx');
+        (new Xlsx($spreadsheet))->save($path);
+
+        return $path;
+    }
+
+    private function buildItems($skus, Contractor $contractor): array
+    {
+        $grouped = [];
+        foreach ($skus as $sku) {
+            if (!$sku->product) {
+                continue;
+            }
+
+            $article = $sku->product->article;
+            $grouped[$article] ??= [
+                'product' => $sku->product,
+                'quantity' => 0,
+            ];
+            $grouped[$article]['quantity']++;
+        }
+
+        $items = [];
+        $missing = [];
+
+        foreach ($grouped as $article => $group) {
+            $product = $group['product'];
+            $price = ContractorInstallationPrice::query()
+                ->where('contractor_id', $contractor->id)
+                ->where('product_id', $product->id)
+                ->where('catalog_year', $product->year)
+                ->first();
+
+            if (!$price || $price->price <= 0) {
+                $missing[] = $article;
+                continue;
+            }
+
+            $items[] = [
+                'article' => $article,
+                'name' => $price->name_in_spec ?: $product->name_tz,
+                'price' => $price->price,
+                'unit' => $product->unit,
+                'quantity' => $group['quantity'],
+            ];
+        }
+
+        if ($missing) {
+            throw ValidationException::withMessages([
+                'skus' => 'Нет цены монтажа у подрядчика для артикулов: ' . implode(', ', array_unique($missing)),
+            ]);
+        }
+
+        return $items;
+    }
+
+    private function calculateVat(float $total, string $taxRate): ?float
+    {
+        if ($taxRate === Contractor::TAX_WITHOUT_VAT) {
+            return null;
+        }
+
+        $rate = (float) $taxRate;
+
+        return round($total / (1 + $rate / 100) * ($rate / 100), 2);
+    }
+
+    private function formatAmountWithWords(float $amount): string
+    {
+        $rubles = (int) floor($amount);
+        $kopecks = (int) round(($amount - $rubles) * 100);
+
+        return number_format($rubles, 0, ',', ' ') . ' рублей ' . sprintf('%02d', $kopecks) . ' копеек (' .
+            $this->numberToWords($rubles) . ' рублей ' . sprintf('%02d', $kopecks) . ' копеек)';
+    }
+
+    private function numberToWords(int $number): string
+    {
+        if ($number === 0) {
+            return 'Ноль';
+        }
+
+        $ones = [
+            ['', 'один', 'два', 'три', 'четыре', 'пять', 'шесть', 'семь', 'восемь', 'девять'],
+            ['', 'одна', 'две', 'три', 'четыре', 'пять', 'шесть', 'семь', 'восемь', 'девять'],
+        ];
+        $teens = ['десять', 'одиннадцать', 'двенадцать', 'тринадцать', 'четырнадцать', 'пятнадцать', 'шестнадцать', 'семнадцать', 'восемнадцать', 'девятнадцать'];
+        $tens = ['', '', 'двадцать', 'тридцать', 'сорок', 'пятьдесят', 'шестьдесят', 'семьдесят', 'восемьдесят', 'девяносто'];
+        $hundreds = ['', 'сто', 'двести', 'триста', 'четыреста', 'пятьсот', 'шестьсот', 'семьсот', 'восемьсот', 'девятьсот'];
+        $units = [
+            ['', '', '', 0],
+            ['тысяча', 'тысячи', 'тысяч', 1],
+            ['миллион', 'миллиона', 'миллионов', 0],
+            ['миллиард', 'миллиарда', 'миллиардов', 0],
+        ];
+
+        $parts = [];
+        $groups = array_reverse(str_split(str_pad((string) $number, (int) ceil(strlen((string) $number) / 3) * 3, '0', STR_PAD_LEFT), 3));
+
+        foreach ($groups as $index => $group) {
+            $value = (int) $group;
+            if ($value === 0) {
+                continue;
+            }
+
+            $gender = $units[$index][3] ?? 0;
+            $hundred = intdiv($value, 100);
+            $ten = intdiv($value % 100, 10);
+            $one = $value % 10;
+            $words = [];
+
+            if ($hundred > 0) {
+                $words[] = $hundreds[$hundred];
+            }
+
+            if ($ten === 1) {
+                $words[] = $teens[$one];
+            } else {
+                if ($ten > 1) {
+                    $words[] = $tens[$ten];
+                }
+                if ($one > 0) {
+                    $words[] = $ones[$gender][$one];
+                }
+            }
+
+            if ($index > 0) {
+                $words[] = $this->plural($value, $units[$index][0], $units[$index][1], $units[$index][2]);
+            }
+
+            array_unshift($parts, implode(' ', $words));
+        }
+
+        $result = implode(' ', $parts);
+
+        return mb_strtoupper(mb_substr($result, 0, 1)) . mb_substr($result, 1);
+    }
+
+    private function plural(int $number, string $one, string $two, string $many): string
+    {
+        $number = abs($number) % 100;
+        $last = $number % 10;
+
+        if ($number > 10 && $number < 20) {
+            return $many;
+        }
+
+        return match ($last) {
+            1 => $one,
+            2, 3, 4 => $two,
+            default => $many,
+        };
+    }
+}

+ 47 - 0
database/migrations/2026_04_29_100000_create_contractors_tables.php

@@ -0,0 +1,47 @@
+<?php
+
+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('contractors', function (Blueprint $table) {
+            $table->id();
+            $table->string('name');
+            $table->string('legal_name');
+            $table->string('contract_number');
+            $table->date('contract_date');
+            $table->string('director_name');
+            $table->string('organization_form');
+            $table->string('tax_rate');
+            $table->text('contract_header');
+            $table->boolean('hidden')->default(false);
+            $table->timestamps();
+        });
+
+        Schema::create('contractor_installation_prices', function (Blueprint $table) {
+            $table->id();
+            $table->foreignId('contractor_id')
+                ->constrained('contractors')
+                ->cascadeOnDelete();
+            $table->foreignId('product_id')
+                ->constrained('products')
+                ->restrictOnDelete();
+            $table->unsignedInteger('catalog_year');
+            $table->string('name_in_spec')->nullable();
+            $table->unsignedBigInteger('price')->default(0);
+            $table->timestamps();
+
+            $table->unique(['contractor_id', 'product_id', 'catalog_year'], 'contractor_price_unique');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('contractor_installation_prices');
+        Schema::dropIfExists('contractors');
+    }
+};

+ 24 - 0
docs/contractors-answers.md

@@ -0,0 +1,24 @@
+1.1. Делаем полный запрет удаления, добавляем только возможность скрыть подрядчика из выпадающего списка.
+1.2. Для добавления всех подрядчиков требуется заполнение всех полей.
+1.3. Администратор и Помощник руководителя. Соответственно формировать и видеть вообще эту вкладку видят так же Администратор и Помощник
+
+2.1. Переход на новый год. каждый год для подрядчика начинаем с пустого прайса, то есть там выводится каталог текущего год, где цена монтажа и поле наименование по спецификации остаются пустыми
+
+2.2. ???? редактирование такое прям в таблице не требуется. Достаточно либо провалится в карточку товара и вписать или изменить нужно поле «цена монтажа» и «поле наименование по спецификации» или через импорт.
+  предлагаю в модалке редактировать
+
+2.3. Если маф убирается из каталога, то мы оставляет его в списке у подрядчика, просто помечаем что данный МАФ недоступен
+2.4. Пустая цена монтажа или наименование по спецификации очищаем поле у подрядчика и ставим просто 0 руб в цене.
+
+3.1. Историю спецификаций сохранять не требуется. Достаточно формирования документа и все. Далее эта спецификация подгружается в 1С и храниться только в бухгалтерии. Данные документы не должны видеть ни кто кроме администратора и помощника. Поэтому что бы не добавлять какие то новые поля в площадке, просто не делаем историю. Не храним эти документы.
+3.2. блокировать формирование с перечнем проблемных артикулов. Не формируем документ, выводим ошибку что на данном артикуле нету цены монтажа у подрядчика.
+3.3. Календарные
+3.4. Единицу измерения берем из каталога МАФ
+
+4.1. Все верно, у меня в ТЗ опечатка. Верная строка это 31
+4.2. 157 300 рублей 00 копеек (Сто пятьдесят семь тысяч триста рублей 00 копеек). Вот в таком формате нужно выводить. Пишем цену цифрами, и в скобках прописываем все текстом, копейки указываем цифрами.
+4.3. Да, всегда пишем г. Москва
+4.4. Да все верно и корректно
+
+
+

+ 141 - 0
docs/contractors-questions.md

@@ -0,0 +1,141 @@
+# Вопросы к заказчику по ТЗ «Подрядчики и спецификации»
+
+Документ содержит развёрнутые вопросы, которые необходимо уточнить у заказчика до начала реализации. Основной документ ТЗ: [contractors.md](./contractors.md).
+
+---
+
+## 1. Управление подрядчиками
+
+### 1.1. Удаление и архивирование
+
+Нужна ли возможность удалять или архивировать подрядчиков?
+
+Что делать, если на подрядчика уже выпущены спецификации или он используется в площадках:
+
+- жёсткое удаление запрещено;
+- мягкое удаление (архив) с сохранением истории;
+- полный запрет удаления — только скрытие из выпадающих списков.
+
+**Рекомендация:** мягкое удаление (флаг `archived`). Архивные подрядчики не видны в выпадающих списках при формировании спецификаций, но остаются в справочнике и доступны для просмотра ранее выпущенных документов.
+
+### 1.2. Обязательность полей карточки подрядчика
+
+Какие из 8 полей карточки (наименование, юр. имя, № договора, дата договора, ФИО руководителя, форма организации, налог, шапка в договоре) являются **обязательными** для сохранения?
+
+Варианты:
+
+- все 8 обязательны — подрядчика нельзя создать без полной анкеты;
+- обязательно только «Наименование» — остальное можно дозаполнить;
+- обязательны поля, без которых не сформируется спецификация (№ договора, дата, юр. имя, ФИО, форма организации, налог).
+
+### 1.3. Права на редактирование цен монтажа
+
+Кто может редактировать цены монтажа в карточке подрядчика:
+
+- только Администратор;
+- Администратор и Помощник руководителя;
+
+---
+
+## 2. Цены монтажа
+
+### 2.1. Переход на новый год
+
+Каталог МАФ привязан к году. При переходе на новый календарный год:
+
+- копировать ли цены прошлого года в новый каталог автоматически;
+- копировать только для МАФ с совпадающим артикулом;
+- начинать каждый год с пустого прайса (через импорт);
+
+### 2.2. Inline-редактирование в таблице цен
+
+Достаточно ли только импорта/экспорта для правок, или нужна возможность редактировать цену и наименование прямо в таблице (inline-edit, как в других разделах)?
+
+### 2.3. Удаление МАФ из каталога
+
+Что делать с ценой монтажа, если МАФ удалён/скрыт в каталоге текущего года:
+
+- скрывать цену из таблицы, но сохранять в БД;
+- удалять запись;
+- показывать с пометкой «МАФ недоступен».
+
+
+### 2.4. Пустые значения при импорте
+
+Как трактовать пустую ячейку в импортируемом файле:
+
+- пустая «Цена монтажа» = очистить поле у подрядчика;
+- пустая «Цена монтажа» = пропустить строку (не трогать текущее значение);
+- так же для «Наименование по спецификации».
+
+---
+
+## 3. Формирование спецификации
+
+### 3.1. История сформированных спецификаций
+
+Нужно ли сохранять в системе историю сформированных документов?
+
+- достаточно скачивания файла «на лету»;
+- сохранять запись (площадка, подрядчик, №, дата, параметры, ссылка на файл);
+- сохранять и показывать в сформированных документах площадки.
+
+### 3.2. Поведение при отсутствии цены у выбранного МАФ
+
+Если в площадке выбраны МАФ, для которых у выбранного подрядчика **не указана цена монтажа**:
+
+- блокировать формирование с перечнем проблемных артикулов;
+- формировать с ценой = 0 для таких строк;
+- пропускать такие МАФ (не попадают в спецификацию);
+
+### 3.3. «+4 дня» для окончания работ
+
+При заполнении «Начало работ» поле «Окончание работ» автоматически = «Начало + 4 дня». Это **календарные** или **рабочие** дни?
+
+### 3.4. Единица измерения
+
+В ТЗ указано «всегда ШТ». Могут ли в будущем быть позиции с другими единицами (комплект, м², м³)? Если да — взять единицу из каталога МАФ; если нет — захардкодить «шт».
+
+---
+
+## 4. Excel-шаблон
+
+### 4.1. Подтверждение опечатки
+
+В исходном ТЗ пункт xiii указывает «Строка 21», но строка 21 уже описана в пункте vi как «Итого». По смыслу (должность подписанта) это **строка 31**. Требуется подтверждение.
+
+### 4.2. Пропись суммы (строка 23)
+
+Какой формат прописи нужен для строки «Итого суммой + прописью»:
+
+- «Сто тысяч рублей 00 копеек»;
+- «100 000 (Сто тысяч) рублей 00 копеек»;
+- другой формат (например, без копеек, если сумма целая).
+
+Нужно определить точный шаблон — от этого зависит выбор библиотеки для прописи.
+
+### 4.3. Несколько городов
+
+В ТЗ строка 12 формируется как `г. Москва, <Название площадки>`. Всегда ли это Москва, или город нужно брать из площадки/карточки подрядчика?
+
+### 4.4. Поведение при «Без НДС»
+
+Подтверждение: при «Без НДС» строка 22 **полностью пустая** (ничего не выводится, даже пометки «Без НДС»), а пометка «Без НДС» уходит только в строку 24. Это корректно?
+
+### 4.5. Формат даты в документе
+
+В каком формате выводить даты в Excel-шаблоне: `ДД.ММ.ГГГГ`, `ДД «Месяц прописью» ГГГГ г.`, или другой?
+
+---
+
+## 5. Интерфейс и UX
+
+### 5.1. Расположение кнопки «Спецификация»
+
+«Сразу после кнопки ТН» — уточнить, имеется в виду панель действий в карточке площадки или конкретный блок. Нужно подтвердить, что кнопка размещается именно в том же блоке, где «ТН», и с тем же стилем.
+
+### 5.2. Отображение прайса подрядчика
+
+В таблице «Цены монтажа» — нужна ли фильтрация/поиск по артикулу, наименованию? Пагинация или загрузка всего каталога?
+
+**Рекомендация:** поиск по артикулу и наименованию, сортировка по колонкам, без пагинации (как в каталоге МАФ).

+ 230 - 0
docs/contractors.md

@@ -0,0 +1,230 @@
+# ТЗ: Подрядчики и формирование спецификаций на монтаж
+
+## 1. Цель
+
+Добавить в систему учёт подрядчиков с индивидуальными прайсами на монтаж МАФ и возможность формировать Excel-спецификации на монтаж по площадке.
+
+---
+
+## 2. Раздел «Подрядчики» в Администрировании
+
+### 2.1. Список подрядчиков
+
+- В меню **Администрирование** добавить пункт **«Подрядчики»**.
+- Страница представляет собой таблицу со списком всех подрядчиков.
+- Кнопка **«Добавить подрядчика»** — открывает форму создания.
+- Клик по строке — открывает карточку подрядчика на редактирование.
+- Удаление подрядчиков не предусмотрено.
+- Для подрядчика нужна возможность скрытия из выпадающих списков без удаления из справочника.
+
+### 2.2. Карточка подрядчика (создание/редактирование)
+
+Поля (все редактируемые в режиме просмотра):
+
+| # | Поле | Тип | Примечание |
+|---|------|-----|------------|
+| 1 | Наименование подрядчика | текст | короткое имя для выпадающих списков |
+| 2 | Юридическое имя | текст | полное юр. наименование |
+| 3 | № договора | текст | |
+| 4 | Дата договора | дата | |
+| 5 | ФИО руководителя | текст | |
+| 6 | Форма организации | select | ИП / ООО / Самозанятый |
+| 7 | Налог | select | Без НДС / 5% / 7% / 15% / 22% |
+| 8 | Шапка в договоре | textarea (большое поле) | используется в шаблоне спецификации |
+
+Все 8 полей обязательны для создания и сохранения подрядчика.
+
+---
+
+## 3. Вкладка «Цены монтажа» в карточке подрядчика
+
+Вкладка **«Цены монтажа»** доступна ролям **Администратор** и **Помощник руководителя**. Эти же роли могут редактировать цены монтажа и наименования для спецификации.
+
+### 3.1. Таблица цен
+
+Внутри карточки подрядчика — вкладка **«Цены монтажа»**.
+
+Таблица формируется на основе **каталога МАФ текущего года**. Для каждого нового года прайс подрядчика начинается пустым: каталог текущего года выводится, а поля «Наименование по спецификации» и «Цена монтажа» изначально пустые.
+
+Колонки:
+
+| # | Колонка | Источник |
+|---|---------|----------|
+| 1 | Картинка МАФ | каталог МАФ текущего года |
+| 2 | Артикул МАФ | каталог МАФ текущего года |
+| 3 | Номер номенклатуры | каталог МАФ текущего года |
+| 4 | Наименование по спецификации | заполняется импортом или руками (уникально для подрядчика) |
+| 5 | Цена монтажа | заполняется импортом или руками (уникально для подрядчика) |
+
+У каждого подрядчика — **собственный набор значений** для колонок 4 и 5.
+
+Редактирование прямо в строке таблицы не требуется. Редактирование значений колонок 4 и 5 выполняется через модальное окно редактирования строки либо через импорт.
+
+Если МАФ убран из каталога, его цена у подрядчика сохраняется и строка остаётся в списке с пометкой **«МАФ недоступен»**.
+
+### 3.2. Формат файла импорта/экспорта
+
+Импорт и экспорт используют **единый набор колонок** (5 колонок), полностью совпадающий с отображением в таблице «Цены монтажа»:
+
+1. Картинка МАФ
+2. Артикул МАФ
+3. Номер номенклатуры
+4. Наименование по спецификации
+5. Цена монтажа
+
+Редактируемые при импорте: только **«Наименование по спецификации»** и **«Цена монтажа»**. Остальные колонки (картинка, артикул, номер номенклатуры) — справочные; берутся из каталога МАФ и при импорте игнорируются (используются только для идентификации строки по артикулу).
+
+Пустые значения при импорте:
+
+- пустая ячейка «Наименование по спецификации» очищает поле у подрядчика;
+- пустая ячейка «Цена монтажа» очищает цену и сохраняет значение `0`.
+
+### 3.3. Импорт цен
+
+Кнопка **«Импортировать/обновить цены»**.
+
+**Логика:**
+
+- По **артикулу МАФ** (колонка 2) находим строку в таблице подрядчика.
+- Сравниваем значения «Наименование по спецификации» и «Цена монтажа».
+- Если значения отличаются — обновляем.
+- Если совпадают — пропускаем.
+- Если артикула в каталоге подрядчика нет — пишем в **лог ошибок** и продолжаем.
+- По окончании показываем сводку: обновлено / без изменений / ошибки.
+
+### 3.4. Экспорт цен
+
+Кнопка **«Экспортировать»** — выгружает таблицу цен в Excel в формате, описанном в п. 3.2. Файл полностью совместим с импортом: можно поправить значения в редактируемых колонках и загрузить обратно.
+
+---
+
+## 4. Формирование спецификации на площадке
+
+### 4.1. Кнопка «Спецификация»
+
+- В карточке площадки добавить кнопку **«Спецификация»** сразу после кнопки **«ТН»**.
+- **Видимость:** только роли **Администратор** и **Помощник руководителя**.
+- **Условие активации:** в площадке выбран как минимум **1 МАФ**. Иначе — сообщение «Выберите хотя бы один МАФ».
+
+### 4.2. Модальное окно формирования
+
+Поля (каждое с подписью сверху):
+
+| # | Поле | Тип | Поведение |
+|---|------|-----|-----------|
+| 1 | Подрядчик | select | список из «Наименование подрядчика» |
+| 2 | № спецификации | текст | ручной ввод |
+| 3 | Дата спецификации | date | по умолчанию — сегодня, редактируемо |
+| 4 | Начало работ | date | пусто по умолчанию |
+| 5 | Окончание работ | date | авто = «Начало работ» + 4 календарных дня при заполнении п.4, редактируемо |
+| 6 | Кнопка «Сформировать» | — | генерирует Excel |
+
+### 4.3. Агрегация МАФ для спецификации
+
+- В площадке МАФ может быть расписан построчно. В спецификации **одинаковые артикулы суммируются** в одну строку с указанием количества.
+- Стоимость позиции = Цена монтажа × Количество.
+- Если у выбранного подрядчика для одного или нескольких МАФ не указана цена монтажа, формирование спецификации блокируется. Пользователю показывается ошибка со списком проблемных артикулов. Документ не формируется.
+
+---
+
+## 5. Заполнение Excel-шаблона спецификации
+
+Заказчиком предоставлен шаблон (Приложение «Форма Спецификации»). Привязка полей:
+
+| Строка | Содержимое | Источник |
+|--------|------------|----------|
+| 2 | № договора и дата договора | карточка подрядчика |
+| 5 | № спецификации | модальное окно |
+| 9 | «Шапка в договоре» | карточка подрядчика |
+| 12 | `г. Москва, <Название площадки>` | площадка |
+| 14+ | Таблица позиций (см. ниже) | каталог подрядчика + площадка |
+| 21 | Итого = сумма колонки «Стоимость» | расчёт |
+| 22 | НДС (только если налог ≠ «Без НДС») | расчёт по формуле |
+| 23 | Итого суммой + прописью | расчёт + пропись |
+| 24 | «Без НДС» или «В т.ч. НДС X%» + сумма | карточка подрядчика |
+| 25, 26 | Начало работ / Окончание работ | модальное окно |
+| 27 | № договора и дата | карточка подрядчика |
+| 30 | Юридическое имя | карточка подрядчика |
+| 31 | Должность подписанта (см. маппинг) | карточка подрядчика |
+| 34 | ФИО руководителя | карточка подрядчика |
+
+> В исходном ТЗ п. xiii была указана «Строка 21». Подтверждено, что это опечатка: верная строка для должности подписанта — **строка 31**.
+
+### 5.1. Таблица позиций (начиная со строки 14)
+
+Колонки:
+
+1. **№ п/п** — автонумерация
+2. **Наименование МАФ** — «Наименование по спецификации» из цен подрядчика
+3. **Цена** — «Цена монтажа» из цен подрядчика
+4. **Ед. изм** — из каталога МАФ
+5. **Кол-во** — сумма одинаковых артикулов по площадке
+6. **Стоимость** — Цена × Кол-во
+
+### 5.2. Расчёт НДС (строка 22)
+
+Только если в карточке подрядчика выбран налог ≠ «Без НДС»:
+
+| Ставка | Формула |
+|--------|---------|
+| 5% | ИТОГО / 1,05 × 0,05 |
+| 7% | ИТОГО / 1,07 × 0,07 |
+| 15% | ИТОГО / 1,15 × 0,15 |
+| 22% | ИТОГО / 1,22 × 0,22 |
+
+Если «Без НДС» — **строка 22 оставляется пустой полностью** (не формируется).
+
+Строка 24 при этом содержит пометку **«Без НДС»**. Для ставок НДС строка 24 содержит текст **«В т.ч. НДС X%»** и рассчитанную сумму НДС.
+
+### 5.3. Формат суммы прописью (строка 23)
+
+Формат: `157 300 рублей 00 копеек (Сто пятьдесят семь тысяч триста рублей 00 копеек)`.
+
+Правила:
+
+- сначала сумма цифрами;
+- затем в скобках сумма прописью;
+- копейки указываются цифрами в обеих частях.
+
+### 5.4. Маппинг «Форма организации» → подпись (строка 31)
+
+| Форма | Текст |
+|-------|-------|
+| ИП | Индивидуальный предприниматель |
+| ООО | Генеральный директор |
+| Самозанятый | Самозанятый |
+
+---
+
+## 6. Модель данных (предварительно)
+
+Новые сущности:
+
+- `contractors` — карточка подрядчика (8 полей + признак скрытия из выпадающих списков + timestamps).
+- `contractor_installation_prices` — цены монтажа: `contractor_id`, `product_sku_id`, `catalog_year`, `name_in_spec`, `price`.
+
+История сформированных спецификаций не хранится. Документ формируется и скачивается «на лету»; дальнейшее хранение выполняется вне CRM.
+
+**Мультигодовая структура:** цены монтажа привязаны к году каталога. При наступлении нового года прайс подрядчика не копируется из прошлого года; значения для нового каталога заполняются заново вручную или импортом.
+
+---
+
+## 7. Роли и права
+
+- Раздел «Подрядчики»: **Администратор** (полный доступ).
+- Кнопка «Спецификация» на площадке: **Администратор**, **Помощник руководителя**.
+- Вкладка «Цены монтажа»: просмотр и редактирование — **Администратор**, **Помощник руководителя**.
+
+---
+
+## 8. Открытые вопросы
+
+Часть вопросов закрыта ответами заказчика и перенесена в это ТЗ.
+
+Остаются открытыми:
+
+- формат вывода дат в Excel-шаблоне;
+- точное расположение кнопки «Спецификация» в интерфейсе карточки площадки;
+- нужны ли поиск, фильтрация, сортировка и пагинация в таблице «Цены монтажа».
+
+Развёрнутый список вопросов к заказчику: [contractors-questions.md](./contractors-questions.md). Ответы заказчика: [contractors-answers.md](./contractors-answers.md).

+ 395 - 0
docs/navigation-context-plan.md

@@ -0,0 +1,395 @@
+# Navigation Context Plan
+
+## Goal
+
+Replace full `previous_url` query parameters with a short `nav` token and keep navigation history in session. This must:
+
+- eliminate URL growth;
+- preserve nested back navigation;
+- reduce conflicts between unrelated navigation chains;
+- avoid large `Location` headers on redirects.
+
+## Target Model
+
+In URL:
+
+```text
+?nav=abc123xyz
+```
+
+In session:
+
+```php
+[
+    'navigation' => [
+        'abc123xyz' => [
+            'updated_at' => 1713945600,
+            'stack' => [
+                '/reclamations?filters[comment]=...',
+                '/reclamations/show/848',
+                '/catalog/show/15',
+            ],
+        ],
+    ],
+]
+```
+
+## Core Principles
+
+1. Pass only `nav` in links and forms.
+2. Store history only in session.
+3. Push only GET pages into the stack.
+4. Never pass full `previous_url` in redirects again.
+5. Keep a fallback route per module if token/session context is missing.
+
+## Implementation Plan
+
+### 1. Add Navigation Service
+
+Create:
+
+- `app/Services/NavigationContextService.php`
+
+Responsibilities:
+
+- get existing token or create a new one;
+- store URL stack in session by token;
+- return correct back URL for current page;
+- cap stack size;
+- prune expired tokens.
+
+Planned public API:
+
+```php
+getOrCreateToken(Request $request): string
+rememberCurrentPage(Request $request, string $token): void
+backUrl(Request $request, string $token, ?string $fallback = null): ?string
+routeParams(array $params, string $token): array
+pruneExpired(): void
+forgetToken(string $token): void
+```
+
+Internal helpers:
+
+```php
+normalizeUrl(string $url): string
+isGetPage(Request $request): bool
+context(string $token): array
+saveContext(string $token, array $context): void
+```
+
+### 2. Define Stack Rules
+
+Rules for session history:
+
+1. Only GET pages are pushed to stack.
+2. POST/PUT/PATCH/DELETE requests are never pushed.
+3. Before storing URL:
+   - remove `nav`;
+   - remove service-only params if needed.
+4. If URL is already the last stack item, do not push it again.
+5. If URL already exists earlier in stack:
+   - remove old occurrence;
+   - append it to the end.
+6. Limit stack length to 20 items.
+7. Store `updated_at`.
+8. Remove expired contexts older than 24 hours.
+
+### 3. Token Format
+
+Requirements:
+
+- short;
+- URL-safe;
+- unique enough for one session.
+
+Recommended format:
+
+```php
+bin2hex(random_bytes(8))
+```
+
+### 4. Extend Base Controller
+
+Current base methods:
+
+- `resolvePreviousUrl()`
+- `previousUrlForRedirect()`
+
+Add wrappers over the new service:
+
+- `resolveNavToken(Request $request): string`
+- `rememberNavigation(Request $request, string $token): void`
+- `navigationBackUrl(Request $request, string $token, ?string $fallback = null): ?string`
+- `withNav(array $params, string $token): array`
+
+For transition period, do not remove old methods immediately.
+
+### 5. Define Fallback Routes per Module
+
+If token is missing or stack is empty, each module needs a stable fallback.
+
+Initial list:
+
+- reclamations -> `route('reclamations.index', session('gp_reclamations'))`
+- orders -> `route('order.index', session('gp_orders'))`
+- spare parts -> `route('spare_parts.index')`
+- spare part orders -> `route('spare_part_orders.index')`
+- import -> `route('import.index', session('gp_import'))`
+
+Need to confirm catalog/product fallback if dedicated index route exists.
+
+### 6. Implement Service Behavior
+
+`backUrl()` must return previous page relative to current page, not just latest entry.
+
+If stack is:
+
+```text
+/reclamations?filters=...
+/reclamations/show/848
+/catalog/show/15
+```
+
+Then on `/catalog/show/15` back URL must be `/reclamations/show/848`.
+
+Algorithm:
+
+1. Normalize current request URL.
+2. Read stack.
+3. If current URL equals last stack item, return previous one.
+4. Otherwise return last item.
+5. If nothing found, return fallback.
+
+### 7. Migrate GET Controllers First
+
+Start with pages that render show/edit screens and need back navigation.
+
+Priority controllers:
+
+1. `ReclamationController@show`
+2. `OrderController@show`, `@edit`
+3. `ProductController@show/edit`
+4. `ProductSKUController@show/edit`
+5. `SparePartController@show/edit`
+6. `SparePartOrderController@show/edit`
+7. `ImportController@show`
+
+Pattern for each GET action:
+
+```php
+$nav = $this->resolveNavToken($request);
+$this->rememberNavigation($request, $nav);
+
+$this->data['nav'] = $nav;
+$this->data['back_url'] = $this->navigationBackUrl(
+    $request,
+    $nav,
+    route('reclamations.index', session('gp_reclamations'))
+);
+```
+
+### 8. Replace `previous_url` in Blade Links
+
+Main rule:
+
+- stop passing `previous_url`;
+- pass only `nav`.
+
+Highest-risk files:
+
+1. `resources/views/partials/table.blade.php`
+2. `resources/views/orders/show.blade.php`
+3. `resources/views/reclamations/edit.blade.php`
+4. `resources/views/catalog/edit.blade.php`
+5. `resources/views/products_sku/edit.blade.php`
+6. `resources/views/spare_parts/edit.blade.php`
+7. `resources/views/spare_part_orders/edit.blade.php`
+
+Expected view variables:
+
+- `$nav`
+- `$back_url`
+
+Buttons/links:
+
+- use `$back_url` for "Назад";
+- use `route(..., ['nav' => $nav])` for nested transitions.
+
+### 9. Replace `previous_url` in Forms and AJAX
+
+Hidden fields:
+
+- replace hidden `previous_url` with hidden `nav`.
+
+AJAX updates:
+
+- send `nav`;
+- do not send `previous_url`;
+- return `204 No Content` for AJAX updates where page refresh is not needed.
+
+This is especially relevant for:
+
+- `resources/views/reclamations/edit.blade.php`
+
+### 10. Replace Redirect Logic in POST Controllers
+
+Main rule:
+
+- POST handlers keep `nav`;
+- redirects return either to current page with `nav`, or to resolved back URL;
+- full `previous_url` must not appear in redirect params.
+
+High-priority controllers:
+
+1. `ReclamationController`
+2. `ProductController`
+3. `OrderController`
+4. `ProductSKUController`
+5. `SparePartController`
+6. `SparePartOrderController`
+
+Pattern:
+
+```php
+$nav = $request->string('nav')->toString();
+
+return redirect()->route('reclamations.show', [
+    'reclamation' => $reclamation,
+    'nav' => $nav,
+]);
+```
+
+Use `redirect()->to($backUrl)` only when action should return to parent page instead of current card.
+
+### 11. Add Transitional Compatibility
+
+For rollout safety:
+
+1. If `nav` exists, use new logic.
+2. If `nav` is missing but `previous_url` exists:
+   - create a new token;
+   - import `previous_url` as first stack item;
+   - then append current page.
+3. If neither exists:
+   - use fallback route.
+
+This allows gradual migration without breaking old entry points.
+
+### 12. Standardize Shared Partials
+
+Review and update shared templates:
+
+- `resources/views/partials/submit.blade.php`
+- `resources/views/partials/table.blade.php`
+
+Target:
+
+- use `$back_url` instead of `$previous_url`;
+- use `$nav` instead of embedding source URL directly.
+
+`url()->previous()` should remain only as a last-resort fallback if needed.
+
+### 13. Session Cleanup Strategy
+
+To prevent session growth:
+
+- maximum contexts per session: 30
+- maximum stack length per context: 20
+- context TTL: 24 hours
+
+Cleanup should run inside service on:
+
+- `getOrCreateToken()`
+- `rememberCurrentPage()`
+
+### 14. Test Coverage
+
+Add feature tests for:
+
+1. token creation when `nav` absent;
+2. token reuse when `nav` present;
+3. GET page stored in stack;
+4. POST page not stored in stack;
+5. `nav` removed during URL normalization;
+6. duplicate URLs do not accumulate;
+7. `list -> show` back navigation;
+8. `list -> reclamation -> catalog` nested back navigation;
+9. separate `nav` tokens do not conflict;
+10. fallback works on empty/missing context;
+11. POST redirect does not include full `previous_url`;
+12. AJAX update returns `204` and no `Location`.
+
+### 15. Rollout Stages
+
+#### Stage 1. Infrastructure
+
+- create `NavigationContextService`;
+- add wrappers in base `Controller`;
+- add tests for service behavior.
+
+#### Stage 2. Reclamations
+
+- migrate `ReclamationController`;
+- migrate `resources/views/reclamations/edit.blade.php`;
+- migrate `resources/views/partials/table.blade.php`;
+- verify uploads, details, spare parts, and AJAX update flow.
+
+#### Stage 3. Orders and Linked Navigation
+
+- migrate `OrderController`;
+- migrate `resources/views/orders/show.blade.php`.
+
+#### Stage 4. Catalog and SKU
+
+- migrate `ProductController`;
+- migrate `ProductSKUController`;
+- migrate:
+  - `resources/views/catalog/edit.blade.php`
+  - `resources/views/products_sku/edit.blade.php`
+
+#### Stage 5. Spare Parts
+
+- migrate `SparePartController`;
+- migrate `SparePartOrderController`;
+- migrate related edit/show templates.
+
+#### Stage 6. Legacy Cleanup
+
+- remove `previous_url` from links and hidden inputs;
+- keep temporary compatibility branch only where needed;
+- later remove `resolvePreviousUrl()` and `previousUrlForRedirect()`.
+
+### 16. Decisions to Confirm Before Coding
+
+1. TTL for navigation context: recommended 24 hours
+2. Max contexts per session: recommended 30
+3. Max stack size per context: recommended 20
+4. Duplicate policy: move existing URL to end instead of duplicating
+5. Fallback route per module: must be explicitly defined
+
+### 17. Risks
+
+1. Mixed old/new navigation while migration is incomplete  
+   Mitigation: transitional compatibility
+
+2. Reusing one token across unrelated flows  
+   Mitigation: create token only at chain entry, then pass it through
+
+3. Cyclic or noisy back navigation  
+   Mitigation: normalize URLs and deduplicate stack
+
+4. Wrong fallback target  
+   Mitigation: define fallback per module instead of global fallback
+
+### 18. Definition of Done
+
+Task is done when:
+
+1. full `previous_url` is no longer passed in links and redirects;
+2. `nav` is used in key transitions;
+3. nested back flow works at least for:
+   - `reclamations list -> reclamation -> catalog -> sku`
+4. URLs no longer grow from filter chains;
+5. tests cover nested back navigation and separate tabs/contexts;
+6. redirect responses no longer produce oversized `Location` headers.

+ 207 - 0
resources/views/contractors/edit.blade.php

@@ -0,0 +1,207 @@
+@extends('layouts.app')
+
+@section('content')
+    <div class="px-3">
+        <h4 class="mb-4">{{ $contractor ? 'Редактирование подрядчика' : 'Добавление подрядчика' }}</h4>
+
+        @if(session('success'))
+            <div class="alert alert-success alert-dismissible fade show" role="alert">
+                {{ session('success') }}
+                <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Закрыть"></button>
+            </div>
+        @endif
+
+        @if(session('contractor_import_errors'))
+            <div class="alert alert-warning alert-dismissible fade show" role="alert">
+                <div class="fw-bold mb-1">Ошибки импорта:</div>
+                @foreach(session('contractor_import_errors') as $error)
+                    <div>{{ $error }}</div>
+                @endforeach
+                <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Закрыть"></button>
+            </div>
+        @endif
+
+        @if($errors->any())
+            <div class="alert alert-danger alert-dismissible fade show" role="alert">
+                @foreach($errors->all() as $error)
+                    <div>{{ $error }}</div>
+                @endforeach
+                <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Закрыть"></button>
+            </div>
+        @endif
+
+        <ul class="nav nav-tabs mb-3" role="tablist">
+            <li class="nav-item" role="presentation">
+                <button class="nav-link active" id="contractor-main-tab" data-bs-toggle="tab" data-bs-target="#contractor-main-pane" type="button" role="tab">
+                    Карточка
+                </button>
+            </li>
+            @if($contractor)
+                <li class="nav-item" role="presentation">
+                    <button class="nav-link" id="contractor-prices-tab" data-bs-toggle="tab" data-bs-target="#contractor-prices-pane" type="button" role="tab">
+                        Цены монтажа
+                    </button>
+                </li>
+            @endif
+        </ul>
+
+        <div class="tab-content">
+            <div class="tab-pane fade show active" id="contractor-main-pane" role="tabpanel" aria-labelledby="contractor-main-tab">
+                <div class="col-xxl-7 offset-xxl-1">
+                    <form action="{{ $contractor ? route('contractors.update', $contractor) : route('contractors.store') }}" method="post">
+                        @csrf
+
+                        @include('partials.input', ['name' => 'name', 'title' => 'Наименование подрядчика', 'required' => true, 'value' => $contractor->name ?? ''])
+                        @include('partials.input', ['name' => 'legal_name', 'title' => 'Юридическое имя', 'required' => true, 'value' => $contractor->legal_name ?? ''])
+                        @include('partials.input', ['name' => 'contract_number', 'title' => '№ договора', 'required' => true, 'value' => $contractor->contract_number ?? ''])
+                        @include('partials.input', ['name' => 'contract_date', 'title' => 'Дата договора', 'type' => 'date', 'required' => true, 'value' => optional($contractor?->contract_date)->format('Y-m-d')])
+                        @include('partials.input', ['name' => 'director_name', 'title' => 'ФИО руководителя', 'required' => true, 'value' => $contractor->director_name ?? ''])
+                        @include('partials.select', ['name' => 'organization_form', 'title' => 'Форма организации', 'required' => true, 'options' => $organizationForms, 'value' => $contractor->organization_form ?? null, 'first_empty' => true])
+                        @include('partials.select', ['name' => 'tax_rate', 'title' => 'Налог', 'required' => true, 'options' => $taxRates, 'value' => $contractor->tax_rate ?? null, 'first_empty' => true])
+                        @include('partials.textarea', ['name' => 'contract_header', 'title' => 'Шапка в договоре', 'required' => true, 'size' => 8, 'value' => $contractor->contract_header ?? ''])
+
+                        <div class="row mb-3">
+                            <div class="offset-md-4 col-md-8">
+                                <input type="hidden" name="hidden" value="0">
+                                <input type="checkbox" class="form-check-input" id="hidden" name="hidden" value="1" @checked(old('hidden', $contractor->hidden ?? false))>
+                                <label for="hidden" class="form-check-label">Скрыть из выпадающих списков</label>
+                            </div>
+                        </div>
+
+                        @include('partials.submit', ['name' => 'Сохранить', 'back_url' => $back_url ?? route('contractors.index')])
+                    </form>
+                </div>
+            </div>
+
+            @if($contractor)
+                <div class="tab-pane fade" id="contractor-prices-pane" role="tabpanel" aria-labelledby="contractor-prices-tab">
+                    <div class="row mb-3 align-items-end">
+                        <div class="col-md-3">
+                            <div class="text-muted small">
+                                Год каталога: <strong>{{ $catalogYear }}</strong>
+                            </div>
+                        </div>
+                        <div class="col-md-9 text-md-end mt-3 mt-md-0">
+                            <form action="{{ route('contractors.prices.export', $contractor) }}" method="post" class="d-inline">
+                                @csrf
+                                <button type="submit" class="btn btn-sm btn-outline-success">Экспортировать</button>
+                            </form>
+                            <button type="button" class="btn btn-sm btn-success" data-bs-toggle="modal" data-bs-target="#importPricesModal">
+                                Импортировать/обновить цены
+                            </button>
+                        </div>
+                    </div>
+
+                    <div class="table-responsive">
+                        <table class="table table-sm table-bordered align-middle">
+                            <thead class="table-primary">
+                            <tr>
+                                <th>Картинка МАФ</th>
+                                <th>Артикул МАФ</th>
+                                <th>Номер номенклатуры</th>
+                                <th>Наименование по спецификации</th>
+                                <th>Цена монтажа</th>
+                                <th>Статус</th>
+                                <th></th>
+                            </tr>
+                            </thead>
+                            <tbody>
+                            @foreach($priceRows as $row)
+                                @php
+                                    $product = $row['product'];
+                                    $price = $row['price'];
+                                @endphp
+                                <tr>
+                                    <td style="width: 120px">
+                                        @if($product->image)
+                                            <img src="{{ $product->image }}" alt="" class="img-thumbnail maf-img">
+                                        @endif
+                                    </td>
+                                    <td>{{ $product->article }}</td>
+                                    <td>{{ $product->nomenclature_number }}</td>
+                                    <td>{{ $price?->name_in_spec }}</td>
+                                    <td>{{ $price?->price_txt ?? '0.00₽' }}</td>
+                                    <td>
+                                        @if($row['available'])
+                                            <span class="badge text-bg-success">Доступен</span>
+                                        @else
+                                            <span class="badge text-bg-warning">МАФ недоступен</span>
+                                        @endif
+                                    </td>
+                                    <td class="text-end">
+                                        <button
+                                            type="button"
+                                            class="btn btn-sm btn-outline-primary edit-price"
+                                            data-bs-toggle="modal"
+                                            data-bs-target="#editPriceModal"
+                                            data-product-id="{{ $product->id }}"
+                                            data-article="{{ $product->article }}"
+                                            data-name="{{ e($price?->name_in_spec ?? '') }}"
+                                            data-price="{{ $price?->price ?? 0 }}"
+                                        >
+                                            Изменить
+                                        </button>
+                                    </td>
+                                </tr>
+                            @endforeach
+                            </tbody>
+                        </table>
+                    </div>
+
+                    <div class="modal fade" id="editPriceModal" tabindex="-1" aria-labelledby="editPriceModalLabel" aria-hidden="true">
+                        <div class="modal-dialog">
+                            <div class="modal-content">
+                                <div class="modal-header">
+                                    <h1 class="modal-title fs-5" id="editPriceModalLabel">Цена монтажа</h1>
+                                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
+                                </div>
+                                <div class="modal-body">
+                                    <form action="{{ route('contractors.prices.update', $contractor) }}" method="post">
+                                        @csrf
+                                        <input type="hidden" name="product_id" id="price_product_id">
+                                        <div class="mb-2 small text-muted" id="price_article"></div>
+                                        @include('partials.input', ['name' => 'name_in_spec', 'title' => 'Наименование по спецификации'])
+                                        @include('partials.input', ['name' => 'price', 'title' => 'Цена монтажа', 'type' => 'number', 'min' => '0'])
+                                        @include('partials.submit', ['name' => 'Сохранить'])
+                                    </form>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+
+                    <div class="modal fade" id="importPricesModal" tabindex="-1" aria-labelledby="importPricesModalLabel" aria-hidden="true">
+                        <div class="modal-dialog">
+                            <div class="modal-content">
+                                <div class="modal-header">
+                                    <h1 class="modal-title fs-5" id="importPricesModalLabel">Импорт цен монтажа</h1>
+                                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
+                                </div>
+                                <div class="modal-body">
+                                    <form action="{{ route('contractors.prices.import', $contractor) }}" method="post" enctype="multipart/form-data">
+                                        @csrf
+                                        <p class="text-muted small">
+                                            Файл должен содержать колонки: Картинка МАФ, Артикул МАФ, Номер номенклатуры, Наименование по спецификации, Цена монтажа.
+                                        </p>
+                                        @include('partials.input', ['name' => 'import_file', 'type' => 'file', 'title' => 'XLSX файл', 'required' => true])
+                                        @include('partials.submit', ['name' => 'Импорт'])
+                                    </form>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            @endif
+        </div>
+    </div>
+@endsection
+
+@push('scripts')
+    <script type="module">
+        $('.edit-price').on('click', function () {
+            $('#price_product_id').val($(this).data('product-id'));
+            $('#price_article').text('Артикул: ' + $(this).data('article'));
+            $('#name_in_spec').val($(this).data('name'));
+            $('#price').val($(this).data('price'));
+        });
+    </script>
+@endpush

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

@@ -0,0 +1,29 @@
+@extends('layouts.app')
+
+@section('content')
+    <div class="row mb-2">
+        <div class="col-md-6">
+            <h3>Подрядчики</h3>
+        </div>
+        <div class="col-md-6 text-end">
+            <a href="{{ route('contractors.create', ['nav' => $nav ?? null]) }}" class="btn btn-sm btn-primary">
+                Добавить подрядчика
+            </a>
+        </div>
+    </div>
+
+    @include('partials.table', [
+        'id' => $id,
+        'header' => $header,
+        'strings' => $contractors,
+        'routeName' => 'contractors.show',
+        'searchFields' => $searchFields,
+        'sortBy' => $sortBy,
+        'orderBy' => $orderBy,
+        'filters' => [],
+        'ranges' => [],
+        'dates' => [],
+        'enableColumnFilters' => false,
+        'nav' => $nav ?? null,
+    ])
+@endsection

+ 5 - 0
resources/views/layouts/menu.blade.php

@@ -26,6 +26,10 @@
         <li class="nav-item"><a class="nav-link @if($active == 'maf_order') active @endif"
                                 href="{{ route('maf_order.index', session('gp_maf_order')) }}">Заказы МАФ</a></li>
     @endif
+    @if(auth()->user()?->role === \App\Models\Role::ASSISTANT_HEAD)
+        <li class="nav-item"><a class="nav-link @if($active == 'contractors') active @endif"
+                                href="{{ route('contractors.index', session('gp_contractors')) }}">Подрядчики</a></li>
+    @endif
     @if(auth()->user()?->role === \App\Models\Role::ADMIN)
         <li class="nav-item dropdown">
             <a id="navbarDropdown" class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"
@@ -33,6 +37,7 @@
                 Администрирование
             </a>
             <ul class="dropdown-menu dropdown-menu-end">
+                <li class="dropdown-item"><a class="nav-link" href="{{ route('contractors.index', session('gp_contractors')) }}">Подрядчики</a></li>
                 <li class="dropdown-item"><a class="nav-link" href="{{ route('contract.index', session('gp_contracts')) }}">Договоры</a></li>
                 <li class="dropdown-item"><a class="nav-link" href="{{ route('user.index', session('gp_users')) }}">Пользователи</a></li>
                 <li class="dropdown-item"><a class="nav-link" href="{{ route('admin.settings.index') }}">Настройки</a></li>

+ 86 - 2
resources/views/orders/show.blade.php

@@ -339,6 +339,9 @@
                             @endif
                             @if(hasRole('admin'))
                                 <a href="#" class="btn btn-primary btn-sm mb-1" id="ttnBtn">ТН</a>
+                            @endif
+                            @if(hasRole('admin,assistant_head'))
+                                <a href="#" class="btn btn-primary btn-sm mb-1" id="contractorSpecificationBtn">Спецификация</a>
                             @endif
                                 <br class="d-md-none">
 
@@ -365,7 +368,7 @@
         </div>
     </div>
 
-    @if(hasRole('admin'))
+    @if(hasRole('admin,assistant_head'))
         <!-- Модальное окно графика -->
         <div class="modal fade" id="copySchedule" tabindex="-1" aria-labelledby="copyScheduleLabel" aria-hidden="true">
             <div class="modal-dialog modal-fullscreen-sm-down modal-lg">
@@ -426,6 +429,46 @@
             </div>
         </div>
 
+        <!-- Модальное окно спецификации подрядчика -->
+        <div class="modal fade" id="contractorSpecificationModal" tabindex="-1" aria-labelledby="contractorSpecificationModalLabel" aria-hidden="true">
+            <div class="modal-dialog modal-fullscreen-sm-down modal-lg">
+                <div class="modal-content">
+                    <div class="modal-header">
+                        <h1 class="modal-title fs-5" id="contractorSpecificationModalLabel">Сформировать спецификацию</h1>
+                        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
+                    </div>
+                    <div class="modal-body">
+                        <form action="{{ route('order.contractor-specification', $order) }}" method="post" id="contractorSpecificationForm">
+                            @csrf
+                            <div>
+                                <label for="contractor_id" class="form-label">Подрядчик</label>
+                                <select class="form-select mb-3" id="contractor_id" name="contractor_id" required>
+                                    <option value="">Выберите...</option>
+                                    @foreach($contractors ?? [] as $contractorId => $contractorName)
+                                        <option value="{{ $contractorId }}">{{ $contractorName }}</option>
+                                    @endforeach
+                                </select>
+
+                                <label for="specification_number" class="form-label">№ спецификации</label>
+                                <input type="text" class="form-control mb-3" id="specification_number" name="specification_number" required>
+
+                                <label for="specification_date" class="form-label">Дата спецификации</label>
+                                <input type="date" class="form-control mb-3" id="specification_date" name="specification_date" value="{{ date('Y-m-d') }}" required>
+
+                                <label for="work_start_date" class="form-label">Начало работ</label>
+                                <input type="date" class="form-control mb-3" id="work_start_date" name="work_start_date">
+
+                                <label for="work_end_date" class="form-label">Окончание работ</label>
+                                <input type="date" class="form-control mb-3" id="work_end_date" name="work_end_date">
+
+                                <button type="button" class="btn btn-primary" id="createContractorSpecification">Сформировать</button>
+                            </div>
+                        </form>
+                    </div>
+                </div>
+            </div>
+        </div>
+
         <style>
             #select_order {
                 max-width: 100%;
@@ -529,7 +572,7 @@
             }
         });
 
-        @if(hasRole('admin'))
+        @if(hasRole('admin,assistant_head'))
             $('#createScheduleButton').on('click', function () {
                 let ids = Array();
                 $('.check-maf').each(function () {
@@ -574,6 +617,47 @@
                     customAlert('Нужно выбрать МАФ для ТН!');
                 }
             });
+
+            $('#contractorSpecificationBtn').on('click', function () {
+                if ($('input.check-maf:checkbox:checked').length > 0) {
+                    let modal = new bootstrap.Modal(document.getElementById("contractorSpecificationModal"), {});
+                    modal.show();
+                } else {
+                    customAlert('Выберите хотя бы один МАФ');
+                }
+            });
+
+            $('#work_start_date').on('change', function () {
+                if (!$(this).val()) {
+                    return;
+                }
+
+                let date = new Date($(this).val() + 'T00:00:00');
+                date.setDate(date.getDate() + 4);
+                $('#work_end_date').val(date.toISOString().slice(0, 10));
+            });
+
+            $('#createContractorSpecification').on('click', function () {
+                const form = document.getElementById('contractorSpecificationForm');
+                if (!form.reportValidity()) {
+                    return;
+                }
+
+                $('#contractorSpecificationForm input[name="skus[]"]').remove();
+                let ids = Array();
+                $('.check-maf').each(function () {
+                    if ($(this).prop('checked')) {
+                        ids.push($(this).attr('data-maf-id'));
+                        $('#contractorSpecificationForm').append('<input type="hidden" name="skus[]" value="' + $(this).attr('data-maf-id') + '">');
+                    }
+                });
+
+                if (ids.length) {
+                    form.submit();
+                } else {
+                    customAlert('Выберите хотя бы один МАФ');
+                }
+            });
         @endif
 
         $('.update-once').on('focus', function () {

+ 16 - 0
routes/web.php

@@ -7,6 +7,7 @@ use App\Http\Controllers\Admin\AdminSettingsController;
 use App\Http\Controllers\ChatMessageController;
 use App\Http\Controllers\AreaController;
 use App\Http\Controllers\ClearDataController;
+use App\Http\Controllers\ContractorController;
 use App\Http\Controllers\YearDataController;
 use App\Http\Controllers\ContractController;
 use App\Http\Controllers\FilterController;
@@ -161,6 +162,18 @@ Route::middleware('auth:web')->group(function () {
         Route::post('contract/{contract}', [ContractController::class, 'update'])->name('contract.update');
         Route::delete('contract/{contract}', [ContractController::class, 'delete'])->name('contract.delete');
 
+        // contractors
+        Route::prefix('contractors')->name('contractors.')->middleware('role:admin,' . Role::ASSISTANT_HEAD)->group(function () {
+            Route::get('', [ContractorController::class, 'index'])->name('index');
+            Route::get('create', [ContractorController::class, 'create'])->name('create');
+            Route::post('', [ContractorController::class, 'store'])->name('store');
+            Route::get('{contractor}', [ContractorController::class, 'show'])->name('show');
+            Route::post('{contractor}', [ContractorController::class, 'update'])->name('update');
+            Route::post('{contractor}/prices', [ContractorController::class, 'updatePrice'])->name('prices.update');
+            Route::post('{contractor}/prices/import', [ContractorController::class, 'importPrices'])->name('prices.import');
+            Route::post('{contractor}/prices/export', [ContractorController::class, 'exportPrices'])->name('prices.export');
+        });
+
         // orders
         Route::get('order/edit/{order}', [OrderController::class, 'edit'])->name('order.edit');
 
@@ -220,6 +233,9 @@ Route::middleware('auth:web')->group(function () {
         ->middleware('role:' . Role::ADMIN);
     Route::get('order/generate-photos-pack/{order}', [OrderController::class, 'generatePhotosPack'])->name('order.generate-photos-pack');
     Route::get('order/download-tech-docs/{order}', [OrderController::class, 'downloadTechDocs'])->name('order.download-tech-docs');
+    Route::post('order/{order}/contractor-specification', [OrderController::class, 'createContractorSpecification'])
+        ->name('order.contractor-specification')
+        ->middleware('role:admin,' . Role::ASSISTANT_HEAD);
 
     Route::middleware('role:' . Role::ADMIN)->group(function () {
         Route::get('catalog/create', [ProductController::class, 'create'])->name('catalog.create');