| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272 |
- <?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,
- };
- }
- }
|