| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306 |
- <?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\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()
- ->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 = IOFactory::load(base_path(self::TEMPLATE));
- $sheet = $spreadsheet->getActiveSheet();
- $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;
- $itemCount = count($items);
- $this->prepareItemRows($sheet, $itemCount);
- $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 = self::ITEM_START_ROW;
- $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['unit']);
- $sheet->setCellValue('D' . $row, $item['quantity']);
- $sheet->setCellValue('E' . $row, $item['price']);
- $sheet->setCellValue('F' . $row, $sum);
- $row++;
- }
- $summaryRow = self::ITEM_START_ROW + $itemCount;
- $vatRow = $summaryRow + 1;
- $totalWordsRow = $summaryRow + 2;
- $vatTextRow = $summaryRow + 3;
- $workStartRow = $summaryRow + 4;
- $workEndRow = $summaryRow + 5;
- $contractRow = $summaryRow + 6;
- $legalNameRow = $summaryRow + 9;
- $signerTitleRow = $summaryRow + 11;
- $directorRow = $summaryRow + 13;
- $vat = $this->calculateVat($total, $contractor->tax_rate);
- $sheet->setCellValue('E' . $summaryRow, 'Итого:');
- $sheet->setCellValue('F' . $summaryRow, $total);
- 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, 'Итого:');
- $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' . $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);
- $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');
- $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 = [];
- 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,
- };
- }
- }
|