ContractorSpecificationService.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. <?php
  2. namespace App\Services;
  3. use App\Models\Contractor;
  4. use App\Models\ContractorInstallationPrice;
  5. use App\Models\Order;
  6. use App\Models\ProductSKU;
  7. use Illuminate\Support\Carbon;
  8. use Illuminate\Validation\ValidationException;
  9. use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
  10. use PhpOffice\PhpSpreadsheet\Spreadsheet;
  11. use PhpOffice\PhpSpreadsheet\Style\Alignment;
  12. use PhpOffice\PhpSpreadsheet\Style\Border;
  13. use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
  14. use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
  15. class ContractorSpecificationService
  16. {
  17. public function generate(Order $order, Contractor $contractor, array $data): string
  18. {
  19. $skus = ProductSKU::query()
  20. ->with('product')
  21. ->where('order_id', $order->id)
  22. ->whereIn('id', $data['skus'])
  23. ->get();
  24. if ($skus->isEmpty()) {
  25. throw ValidationException::withMessages([
  26. 'skus' => 'Выберите хотя бы один МАФ.',
  27. ]);
  28. }
  29. $items = $this->buildItems($skus, $contractor);
  30. $spreadsheet = new Spreadsheet();
  31. $sheet = $spreadsheet->getActiveSheet();
  32. $sheet->setTitle('Спецификация');
  33. $specDate = Carbon::parse($data['specification_date']);
  34. $workStart = !empty($data['work_start_date']) ? Carbon::parse($data['work_start_date']) : null;
  35. $workEnd = !empty($data['work_end_date']) ? Carbon::parse($data['work_end_date']) : null;
  36. $sheet->setCellValue('A2', 'Договор №' . $contractor->contract_number . ' от ' . $contractor->contract_date->format('d.m.Y') . ' г.');
  37. $sheet->setCellValue('A5', 'Спецификация №' . $data['specification_number'] . ' от ' . $specDate->format('d.m.Y') . ' г.');
  38. $sheet->setCellValue('A9', $contractor->contract_header);
  39. $sheet->setCellValue('A12', 'г. Москва, ' . $order->common_name);
  40. $headers = ['№ п/п', 'Наименование МАФ', 'Цена', 'Ед. изм', 'Кол-во', 'Стоимость'];
  41. foreach ($headers as $index => $header) {
  42. $sheet->setCellValue(Coordinate::stringFromColumnIndex($index + 1) . '14', $header);
  43. }
  44. $row = 15;
  45. $total = 0.0;
  46. foreach ($items as $index => $item) {
  47. $sum = $item['price'] * $item['quantity'];
  48. $total += $sum;
  49. $sheet->setCellValue('A' . $row, $index + 1);
  50. $sheet->setCellValue('B' . $row, $item['name']);
  51. $sheet->setCellValue('C' . $row, $item['price']);
  52. $sheet->setCellValue('D' . $row, $item['unit']);
  53. $sheet->setCellValue('E' . $row, $item['quantity']);
  54. $sheet->setCellValue('F' . $row, $sum);
  55. $row++;
  56. }
  57. $summaryRow = max(21, $row + 1);
  58. $vatRow = $summaryRow + 1;
  59. $totalWordsRow = $summaryRow + 2;
  60. $vatTextRow = $summaryRow + 3;
  61. $workStartRow = $summaryRow + 4;
  62. $workEndRow = $summaryRow + 5;
  63. $contractRow = $summaryRow + 6;
  64. $legalNameRow = $summaryRow + 9;
  65. $signerTitleRow = $summaryRow + 10;
  66. $directorRow = $summaryRow + 13;
  67. $vat = $this->calculateVat($total, $contractor->tax_rate);
  68. $sheet->setCellValue('E' . $summaryRow, 'Итого');
  69. $sheet->setCellValue('F' . $summaryRow, $total);
  70. if ($vat !== null) {
  71. $sheet->setCellValue('E' . $vatRow, 'НДС');
  72. $sheet->setCellValue('F' . $vatRow, $vat);
  73. }
  74. $sheet->setCellValue('A' . $totalWordsRow, $this->formatAmountWithWords($total));
  75. $sheet->mergeCells('A' . $totalWordsRow . ':F' . $totalWordsRow);
  76. if ($vat === null) {
  77. $sheet->setCellValue('A' . $vatTextRow, 'Без НДС');
  78. } else {
  79. $sheet->setCellValue('A' . $vatTextRow, 'В т.ч. НДС ' . $contractor->tax_rate . '% ' . number_format($vat, 2, ',', ' ') . ' руб.');
  80. }
  81. $sheet->setCellValue('A' . $workStartRow, 'Начало работ: ' . ($workStart?->format('d.m.Y') ?? ''));
  82. $sheet->setCellValue('A' . $workEndRow, 'Окончание работ: ' . ($workEnd?->format('d.m.Y') ?? ''));
  83. $sheet->setCellValue('A' . $contractRow, 'Договор №' . $contractor->contract_number . ' от ' . $contractor->contract_date->format('d.m.Y') . ' г.');
  84. $sheet->setCellValue('A' . $legalNameRow, $contractor->legal_name);
  85. $sheet->setCellValue('A' . $signerTitleRow, $contractor->signer_title);
  86. $sheet->setCellValue('A' . $directorRow, $contractor->director_name);
  87. $lastRow = $directorRow;
  88. $sheet->getStyle('A14:F' . max(14, $row - 1))->getBorders()->getAllBorders()->setBorderStyle(Border::BORDER_THIN);
  89. $sheet->getStyle('A14:F14')->getFont()->setBold(true);
  90. $sheet->getStyle('A14:F14')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
  91. $sheet->getStyle('C15:C' . $lastRow)->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_NUMBER_00);
  92. $sheet->getStyle('F15:F' . $lastRow)->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_NUMBER_00);
  93. $sheet->getStyle('A9:A12')->getAlignment()->setWrapText(true);
  94. foreach (['A' => 10, 'B' => 50, 'C' => 14, 'D' => 12, 'E' => 12, 'F' => 16] as $column => $width) {
  95. $sheet->getColumnDimension($column)->setWidth($width);
  96. }
  97. $safeNumber = preg_replace('/[^0-9A-Za-zА-Яа-я_-]+/u', '_', (string) $data['specification_number']);
  98. $path = storage_path('app/specification-' . $order->id . '-' . $contractor->id . '-' . $safeNumber . '.xlsx');
  99. (new Xlsx($spreadsheet))->save($path);
  100. return $path;
  101. }
  102. private function buildItems($skus, Contractor $contractor): array
  103. {
  104. $grouped = [];
  105. foreach ($skus as $sku) {
  106. if (!$sku->product) {
  107. continue;
  108. }
  109. $article = $sku->product->article;
  110. $grouped[$article] ??= [
  111. 'product' => $sku->product,
  112. 'quantity' => 0,
  113. ];
  114. $grouped[$article]['quantity']++;
  115. }
  116. $items = [];
  117. $missing = [];
  118. foreach ($grouped as $article => $group) {
  119. $product = $group['product'];
  120. $price = ContractorInstallationPrice::query()
  121. ->where('contractor_id', $contractor->id)
  122. ->where('product_id', $product->id)
  123. ->where('catalog_year', $product->year)
  124. ->first();
  125. if (!$price || $price->price <= 0) {
  126. $missing[] = $article;
  127. continue;
  128. }
  129. $items[] = [
  130. 'article' => $article,
  131. 'name' => $price->name_in_spec ?: $product->name_tz,
  132. 'price' => $price->price,
  133. 'unit' => $product->unit,
  134. 'quantity' => $group['quantity'],
  135. ];
  136. }
  137. if ($missing) {
  138. throw ValidationException::withMessages([
  139. 'skus' => 'Нет цены монтажа у подрядчика для артикулов: ' . implode(', ', array_unique($missing)),
  140. ]);
  141. }
  142. return $items;
  143. }
  144. private function calculateVat(float $total, string $taxRate): ?float
  145. {
  146. if ($taxRate === Contractor::TAX_WITHOUT_VAT) {
  147. return null;
  148. }
  149. $rate = (float) $taxRate;
  150. return round($total / (1 + $rate / 100) * ($rate / 100), 2);
  151. }
  152. private function formatAmountWithWords(float $amount): string
  153. {
  154. $rubles = (int) floor($amount);
  155. $kopecks = (int) round(($amount - $rubles) * 100);
  156. return number_format($rubles, 0, ',', ' ') . ' рублей ' . sprintf('%02d', $kopecks) . ' копеек (' .
  157. $this->numberToWords($rubles) . ' рублей ' . sprintf('%02d', $kopecks) . ' копеек)';
  158. }
  159. private function numberToWords(int $number): string
  160. {
  161. if ($number === 0) {
  162. return 'Ноль';
  163. }
  164. $ones = [
  165. ['', 'один', 'два', 'три', 'четыре', 'пять', 'шесть', 'семь', 'восемь', 'девять'],
  166. ['', 'одна', 'две', 'три', 'четыре', 'пять', 'шесть', 'семь', 'восемь', 'девять'],
  167. ];
  168. $teens = ['десять', 'одиннадцать', 'двенадцать', 'тринадцать', 'четырнадцать', 'пятнадцать', 'шестнадцать', 'семнадцать', 'восемнадцать', 'девятнадцать'];
  169. $tens = ['', '', 'двадцать', 'тридцать', 'сорок', 'пятьдесят', 'шестьдесят', 'семьдесят', 'восемьдесят', 'девяносто'];
  170. $hundreds = ['', 'сто', 'двести', 'триста', 'четыреста', 'пятьсот', 'шестьсот', 'семьсот', 'восемьсот', 'девятьсот'];
  171. $units = [
  172. ['', '', '', 0],
  173. ['тысяча', 'тысячи', 'тысяч', 1],
  174. ['миллион', 'миллиона', 'миллионов', 0],
  175. ['миллиард', 'миллиарда', 'миллиардов', 0],
  176. ];
  177. $parts = [];
  178. $groups = array_reverse(str_split(str_pad((string) $number, (int) ceil(strlen((string) $number) / 3) * 3, '0', STR_PAD_LEFT), 3));
  179. foreach ($groups as $index => $group) {
  180. $value = (int) $group;
  181. if ($value === 0) {
  182. continue;
  183. }
  184. $gender = $units[$index][3] ?? 0;
  185. $hundred = intdiv($value, 100);
  186. $ten = intdiv($value % 100, 10);
  187. $one = $value % 10;
  188. $words = [];
  189. if ($hundred > 0) {
  190. $words[] = $hundreds[$hundred];
  191. }
  192. if ($ten === 1) {
  193. $words[] = $teens[$one];
  194. } else {
  195. if ($ten > 1) {
  196. $words[] = $tens[$ten];
  197. }
  198. if ($one > 0) {
  199. $words[] = $ones[$gender][$one];
  200. }
  201. }
  202. if ($index > 0) {
  203. $words[] = $this->plural($value, $units[$index][0], $units[$index][1], $units[$index][2]);
  204. }
  205. array_unshift($parts, implode(' ', $words));
  206. }
  207. $result = implode(' ', $parts);
  208. return mb_strtoupper(mb_substr($result, 0, 1)) . mb_substr($result, 1);
  209. }
  210. private function plural(int $number, string $one, string $two, string $many): string
  211. {
  212. $number = abs($number) % 100;
  213. $last = $number % 10;
  214. if ($number > 10 && $number < 20) {
  215. return $many;
  216. }
  217. return match ($last) {
  218. 1 => $one,
  219. 2, 3, 4 => $two,
  220. default => $many,
  221. };
  222. }
  223. }