Alexander Musikhin 1 день назад
Родитель
Сommit
01459b0c46
3 измененных файлов с 76 добавлено и 140 удалено
  1. 0 98
      CLAUDE.md
  2. 76 42
      app/Services/ContractorSpecificationService.php
  3. BIN
      templates/Specification.xlsx

+ 0 - 98
CLAUDE.md

@@ -1,98 +0,0 @@
-# CLAUDE.md
-
-This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
-
-## Обзор проекта
-
-Stroyprofit CRM — Laravel 11 приложение для управления заказами строительных конструкций. Включает каталог товаров, управление заказами (обычные и МАФ), рекламации, генерацию документов и расписание.
-
-## Команды разработки
-
-### Docker и запуск
-```bash
-make up                   # Запуск проекта
-make stop                 # Остановка
-make install              # Полная установка с миграциями
-make application          # Bash в контейнер приложения
-```
-
-Все тесты, `artisan`-команды и прочие проектные проверки запускать внутри контейнера.
-Предпочтительно использовать `make test`, а для точечных команд `docker compose exec app ...`.
-
-### База данных
-```bash
-make db-migrate           # Применить миграции
-make db-seed              # Запустить сидеры
-make db-migrate-refresh   # Сбросить БД и засеять
-make db-rollback          # Откатить последнюю миграцию
-```
-
-### Frontend
-```bash
-make frontend-build       # npm install + build
-npm run dev               # Vite dev server
-npm run build             # Production build
-```
-
-### Очереди и логи
-```bash
-make queue-restart        # Перезапуск воркера
-make queue-log            # Логи очереди
-make scheduler-log        # Логи планировщика
-make application-log      # Логи приложения
-```
-
-### Полное dev-окружение
-```bash
-composer dev              # Запуск server + queue + logs + vite параллельно
-```
-
-## Архитектура
-
-### Service Layer
-Бизнес-логика вынесена в `app/Services/`:
-- **Export/** — экспорт данных в Excel (Orders, MAFs, Schedules)
-- **Import/** — импорт из Excel (Orders, MAFs, Reclamations)
-- **Generate/** — генерация документов (Installation, Handover, Reclamation packs)
-
-### Queue Jobs
-Тяжёлые операции выполняются через очереди (`app/Jobs/`):
-- Экспорт/импорт данных
-- Генерация пакетов документов
-- Уведомления
-
-### Основные модели
-- **Order** / **MafOrder** — заказы клиентов и МАФ
-- **Product** / **ProductSKU** — каталог товаров
-- **Reclamation** — рекламации
-- **Contract** — договоры
-
-### Views для отчётов
-Используются database views для агрегации данных:
-- `OrderView`, `MafOrdersView`, `ReclamationView`
-
-## Технологический стек
-
-- **Backend:** PHP 8.2+, Laravel 11, MySQL 8, Redis
-- **Frontend:** Bootstrap 5, jQuery, Vite, Sass
-- **DevOps:** Docker, Nginx, Node.js WebSocket server
-
-## Особенности
-
-### Мультигодовая структура
-Данные сегментируются по годам. Текущий год хранится в сессии.
-
-### Шаблоны документов
-Excel-шаблоны находятся в `/templates/` — используются для экспорта и генерации документов.
-
-### WebSocket
-Node.js сервер в `docker/simple-ws/` для real-time обновлений. JWT-аутентификация через переменную `JWT_SECRET`.
-
-## Ключевые настройки .env
-
-```
-APP_LOCALE=ru
-APP_TIMEZONE=Europe/Moscow
-QUEUE_CONNECTION=redis
-PAGINATION_LIMIT=2000
-```

+ 76 - 42
app/Services/ContractorSpecificationService.php

@@ -8,15 +8,17 @@ 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\IOFactory;
+use PhpOffice\PhpSpreadsheet\Shared\Date;
+use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
 use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
 
 class ContractorSpecificationService
 {
+    private const TEMPLATE = 'templates/Specification.xlsx';
+    private const ITEM_START_ROW = 15;
+    private const TEMPLATE_ITEM_ROWS = 6;
+
     public function generate(Order $order, Contractor $contractor, array $data): string
     {
         $skus = ProductSKU::query()
@@ -33,25 +35,25 @@ class ContractorSpecificationService
 
         $items = $this->buildItems($skus, $contractor);
 
-        $spreadsheet = new Spreadsheet();
+        $spreadsheet = IOFactory::load(base_path(self::TEMPLATE));
         $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);
+        $itemCount = count($items);
+        $this->prepareItemRows($sheet, $itemCount);
 
-        $headers = ['№ п/п', 'Наименование МАФ', 'Цена', 'Ед. изм', 'Кол-во', 'Стоимость'];
-        foreach ($headers as $index => $header) {
-            $sheet->setCellValue(Coordinate::stringFromColumnIndex($index + 1) . '14', $header);
-        }
+        $sheet->setCellValue('A2', 'к Договору подряда №' . $contractor->contract_number . ' от ' . $contractor->contract_date->format('d.m.Y') . ' г.');
+        $sheet->setCellValue('A5', 'Спецификация №');
+        $sheet->setCellValue('D5', $data['specification_number']);
+        $sheet->setCellValue('A7', 'г.Москва');
+        $sheet->setCellValue('E7', Date::PHPToExcel($specDate));
+        $sheet->setCellValue('A9', $contractor->contract_header);
+        $sheet->setCellValue('A12', 'г.Москва, ' . $order->common_name);
 
-        $row = 15;
+        $row = self::ITEM_START_ROW;
         $total = 0.0;
         foreach ($items as $index => $item) {
             $sum = $item['price'] * $item['quantity'];
@@ -59,14 +61,14 @@ class ContractorSpecificationService
 
             $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('C' . $row, $item['unit']);
+            $sheet->setCellValue('D' . $row, $item['quantity']);
+            $sheet->setCellValue('E' . $row, $item['price']);
             $sheet->setCellValue('F' . $row, $sum);
             $row++;
         }
 
-        $summaryRow = max(21, $row + 1);
+        $summaryRow = self::ITEM_START_ROW + $itemCount;
         $vatRow = $summaryRow + 1;
         $totalWordsRow = $summaryRow + 2;
         $vatTextRow = $summaryRow + 3;
@@ -74,54 +76,86 @@ class ContractorSpecificationService
         $workEndRow = $summaryRow + 5;
         $contractRow = $summaryRow + 6;
         $legalNameRow = $summaryRow + 9;
-        $signerTitleRow = $summaryRow + 10;
+        $signerTitleRow = $summaryRow + 11;
         $directorRow = $summaryRow + 13;
 
         $vat = $this->calculateVat($total, $contractor->tax_rate);
 
-        $sheet->setCellValue('E' . $summaryRow, 'Итого');
+        $sheet->setCellValue('E' . $summaryRow, 'Итого:');
         $sheet->setCellValue('F' . $summaryRow, $total);
 
-        if ($vat !== null) {
-            $sheet->setCellValue('E' . $vatRow, 'НДС');
+        if ($vat === null) {
+            $sheet->removeRow($vatRow);
+            $totalWordsRow--;
+            $vatTextRow--;
+            $workStartRow--;
+            $workEndRow--;
+            $contractRow--;
+            $legalNameRow--;
+            $signerTitleRow--;
+            $directorRow--;
+        } else {
+            $sheet->setCellValue('E' . $vatRow, 'В т.ч. НДС ' . $contractor->tax_rate . '%');
             $sheet->setCellValue('F' . $vatRow, $vat);
         }
 
-        $sheet->setCellValue('A' . $totalWordsRow, $this->formatAmountWithWords($total));
-        $sheet->mergeCells('A' . $totalWordsRow . ':F' . $totalWordsRow);
+        $sheet->setCellValue('A' . $totalWordsRow, 'Итого:');
+        $sheet->setCellValue('B' . $totalWordsRow, $this->formatAmountWithWords($total));
 
         if ($vat === null) {
             $sheet->setCellValue('A' . $vatTextRow, 'Без НДС');
         } else {
-            $sheet->setCellValue('A' . $vatTextRow, 'В т.ч. НДС ' . $contractor->tax_rate . '% ' . number_format($vat, 2, ',', ' ') . ' руб.');
+            $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' . $workStartRow, '2. Срок начала выполнения работ Подрядчиком – не позднее ');
+        $sheet->setCellValue('E' . $workStartRow, $workStart ? Date::PHPToExcel($workStart) : '');
+        $sheet->setCellValue('A' . $workEndRow, '3. Срок окончания выполнения работ Подрядчиком – не позднее ');
+        $sheet->setCellValue('E' . $workEndRow, $workEnd ? Date::PHPToExcel($workEnd) : '');
+        $sheet->setCellValue(
+            'A' . $contractRow,
+            '4. Настоящая Спецификация служит основанием для производства взаимных платежей и расчетов между Подрядчиком и Заказчиком.' . "\n"
+            . '5. Настоящая Спецификация является неотъемлемой частью Договора №' . $contractor->contract_number
+            . ' от ' . $contractor->contract_date->format('d.m.Y')
+            . ' г., составлена в 2 (двух) экземплярах, имеющих равную юридическую силу, по одному экземпляру для каждой из Сторон'
+        );
         $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);
-        }
+        $sheet->removeColumn('G');
+        $sheet->getAutoFilter()->setRange('A14:F' . $summaryRow);
+        $sheet->getPageSetup()->setPrintArea('A1:F' . ($directorRow + 2));
 
         $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);
+        $writer = new Xlsx($spreadsheet);
+        $writer->setPreCalculateFormulas(false);
+        $writer->save($path);
 
         return $path;
     }
 
+    private function prepareItemRows(Worksheet $sheet, int $itemCount): void
+    {
+        if ($itemCount > self::TEMPLATE_ITEM_ROWS) {
+            $extraRows = $itemCount - self::TEMPLATE_ITEM_ROWS;
+            $insertBefore = self::ITEM_START_ROW + self::TEMPLATE_ITEM_ROWS;
+            $sheet->insertNewRowBefore($insertBefore, $extraRows);
+
+            for ($row = $insertBefore; $row < $insertBefore + $extraRows; $row++) {
+                $sheet->duplicateStyle($sheet->getStyle('A' . ($insertBefore - 1) . ':F' . ($insertBefore - 1)), 'A' . $row . ':F' . $row);
+                $sheet->getRowDimension($row)->setRowHeight($sheet->getRowDimension($insertBefore - 1)->getRowHeight());
+            }
+
+            return;
+        }
+
+        if ($itemCount < self::TEMPLATE_ITEM_ROWS) {
+            $sheet->removeRow(self::ITEM_START_ROW + $itemCount, self::TEMPLATE_ITEM_ROWS - $itemCount);
+        }
+    }
+
     private function buildItems($skus, Contractor $contractor): array
     {
         $grouped = [];

BIN
templates/Specification.xlsx