ContractorSpecificationService.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  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\IOFactory;
  10. use PhpOffice\PhpSpreadsheet\Shared\Date;
  11. use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
  12. use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
  13. class ContractorSpecificationService
  14. {
  15. private const TEMPLATE = 'templates/Specification.xlsx';
  16. private const ITEM_START_ROW = 15;
  17. private const TEMPLATE_ITEM_ROWS = 6;
  18. public function generate(Order $order, Contractor $contractor, array $data): string
  19. {
  20. $skus = ProductSKU::query()
  21. ->with('product')
  22. ->where('order_id', $order->id)
  23. ->whereIn('id', $data['skus'])
  24. ->get();
  25. if ($skus->isEmpty()) {
  26. throw ValidationException::withMessages([
  27. 'skus' => 'Выберите хотя бы один МАФ.',
  28. ]);
  29. }
  30. $items = $this->buildItems($skus, $contractor);
  31. $spreadsheet = IOFactory::load(base_path(self::TEMPLATE));
  32. $sheet = $spreadsheet->getActiveSheet();
  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. $itemCount = count($items);
  37. $this->prepareItemRows($sheet, $itemCount);
  38. $sheet->setCellValue('A2', 'к Договору подряда №' . $contractor->contract_number . ' от ' . $contractor->contract_date->format('d.m.Y') . ' г.');
  39. $sheet->setCellValue('A5', 'Спецификация №');
  40. $sheet->setCellValue('D5', $data['specification_number']);
  41. $sheet->setCellValue('A7', 'г.Москва');
  42. $sheet->setCellValue('E7', Date::PHPToExcel($specDate));
  43. $sheet->setCellValue('A9', $contractor->contract_header);
  44. $sheet->setCellValue('A12', 'г.Москва, ' . $order->common_name);
  45. $row = self::ITEM_START_ROW;
  46. $total = 0.0;
  47. foreach ($items as $index => $item) {
  48. $sum = $item['price'] * $item['quantity'];
  49. $total += $sum;
  50. $sheet->setCellValue('A' . $row, $index + 1);
  51. $sheet->setCellValue('B' . $row, $item['name']);
  52. $sheet->setCellValue('C' . $row, $item['unit']);
  53. $sheet->setCellValue('D' . $row, $item['quantity']);
  54. $sheet->setCellValue('E' . $row, $item['price']);
  55. $sheet->setCellValue('F' . $row, $sum);
  56. $row++;
  57. }
  58. $summaryRow = self::ITEM_START_ROW + $itemCount;
  59. $vatRow = $summaryRow + 1;
  60. $totalWordsRow = $summaryRow + 2;
  61. $vatTextRow = $summaryRow + 3;
  62. $workStartRow = $summaryRow + 4;
  63. $workEndRow = $summaryRow + 5;
  64. $contractRow = $summaryRow + 6;
  65. $legalNameRow = $summaryRow + 9;
  66. $signerTitleRow = $summaryRow + 11;
  67. $directorRow = $summaryRow + 13;
  68. $vat = $this->calculateVat($total, $contractor->tax_rate);
  69. $sheet->setCellValue('E' . $summaryRow, 'Итого:');
  70. $sheet->setCellValue('F' . $summaryRow, $total);
  71. if ($vat === null) {
  72. $sheet->removeRow($vatRow);
  73. $totalWordsRow--;
  74. $vatTextRow--;
  75. $workStartRow--;
  76. $workEndRow--;
  77. $contractRow--;
  78. $legalNameRow--;
  79. $signerTitleRow--;
  80. $directorRow--;
  81. } else {
  82. $sheet->setCellValue('E' . $vatRow, 'В т.ч. НДС ' . $contractor->tax_rate . '%');
  83. $sheet->setCellValue('F' . $vatRow, $vat);
  84. }
  85. $sheet->setCellValue('A' . $totalWordsRow, 'Итого:');
  86. $sheet->setCellValue('B' . $totalWordsRow, $this->formatAmountWithWords($total));
  87. if ($vat === null) {
  88. $sheet->setCellValue('A' . $vatTextRow, 'Без НДС');
  89. } else {
  90. $sheet->setCellValue('A' . $vatTextRow, 'В т.ч. НДС ' . $contractor->tax_rate . '% ' . number_format($vat, 2, ',', ' ') . ' р.');
  91. }
  92. $sheet->setCellValue('A' . $workStartRow, '2. Срок начала выполнения работ Подрядчиком – не позднее ');
  93. $sheet->setCellValue('E' . $workStartRow, $workStart ? Date::PHPToExcel($workStart) : '');
  94. $sheet->setCellValue('A' . $workEndRow, '3. Срок окончания выполнения работ Подрядчиком – не позднее ');
  95. $sheet->setCellValue('E' . $workEndRow, $workEnd ? Date::PHPToExcel($workEnd) : '');
  96. $sheet->setCellValue(
  97. 'A' . $contractRow,
  98. '4. Настоящая Спецификация служит основанием для производства взаимных платежей и расчетов между Подрядчиком и Заказчиком.' . "\n"
  99. . '5. Настоящая Спецификация является неотъемлемой частью Договора №' . $contractor->contract_number
  100. . ' от ' . $contractor->contract_date->format('d.m.Y')
  101. . ' г., составлена в 2 (двух) экземплярах, имеющих равную юридическую силу, по одному экземпляру для каждой из Сторон'
  102. );
  103. $sheet->setCellValue('A' . $legalNameRow, $contractor->legal_name);
  104. $sheet->setCellValue('A' . $signerTitleRow, $contractor->signer_title);
  105. $sheet->setCellValue('A' . $directorRow, $contractor->director_name);
  106. $sheet->removeColumn('G');
  107. $sheet->getAutoFilter()->setRange('A14:F' . $summaryRow);
  108. $sheet->getPageSetup()->setPrintArea('A1:F' . ($directorRow + 2));
  109. $safeNumber = preg_replace('/[^0-9A-Za-zА-Яа-я_-]+/u', '_', (string) $data['specification_number']);
  110. $path = storage_path('app/specification-' . $order->id . '-' . $contractor->id . '-' . $safeNumber . '.xlsx');
  111. $writer = new Xlsx($spreadsheet);
  112. $writer->setPreCalculateFormulas(false);
  113. $writer->save($path);
  114. return $path;
  115. }
  116. private function prepareItemRows(Worksheet $sheet, int $itemCount): void
  117. {
  118. if ($itemCount > self::TEMPLATE_ITEM_ROWS) {
  119. $extraRows = $itemCount - self::TEMPLATE_ITEM_ROWS;
  120. $insertBefore = self::ITEM_START_ROW + self::TEMPLATE_ITEM_ROWS;
  121. $sheet->insertNewRowBefore($insertBefore, $extraRows);
  122. for ($row = $insertBefore; $row < $insertBefore + $extraRows; $row++) {
  123. $sheet->duplicateStyle($sheet->getStyle('A' . ($insertBefore - 1) . ':F' . ($insertBefore - 1)), 'A' . $row . ':F' . $row);
  124. $sheet->getRowDimension($row)->setRowHeight($sheet->getRowDimension($insertBefore - 1)->getRowHeight());
  125. }
  126. return;
  127. }
  128. if ($itemCount < self::TEMPLATE_ITEM_ROWS) {
  129. $sheet->removeRow(self::ITEM_START_ROW + $itemCount, self::TEMPLATE_ITEM_ROWS - $itemCount);
  130. }
  131. }
  132. private function buildItems($skus, Contractor $contractor): array
  133. {
  134. $grouped = [];
  135. foreach ($skus as $sku) {
  136. if (!$sku->product) {
  137. continue;
  138. }
  139. $article = $sku->product->article;
  140. $grouped[$article] ??= [
  141. 'product' => $sku->product,
  142. 'quantity' => 0,
  143. ];
  144. $grouped[$article]['quantity']++;
  145. }
  146. $items = [];
  147. $missing = [];
  148. foreach ($grouped as $article => $group) {
  149. $product = $group['product'];
  150. $price = ContractorInstallationPrice::query()
  151. ->where('contractor_id', $contractor->id)
  152. ->where('product_id', $product->id)
  153. ->where('catalog_year', $product->year)
  154. ->first();
  155. if (!$price || $price->price <= 0) {
  156. $missing[] = $article;
  157. continue;
  158. }
  159. $items[] = [
  160. 'article' => $article,
  161. 'name' => $price->name_in_spec ?: $product->name_tz,
  162. 'price' => $price->price,
  163. 'unit' => $product->unit,
  164. 'quantity' => $group['quantity'],
  165. ];
  166. }
  167. if ($missing) {
  168. throw ValidationException::withMessages([
  169. 'skus' => 'Нет цены монтажа у подрядчика для артикулов: ' . implode(', ', array_unique($missing)),
  170. ]);
  171. }
  172. return $items;
  173. }
  174. private function calculateVat(float $total, string $taxRate): ?float
  175. {
  176. if ($taxRate === Contractor::TAX_WITHOUT_VAT) {
  177. return null;
  178. }
  179. $rate = (float) $taxRate;
  180. return round($total / (1 + $rate / 100) * ($rate / 100), 2);
  181. }
  182. private function formatAmountWithWords(float $amount): string
  183. {
  184. $rubles = (int) floor($amount);
  185. $kopecks = (int) round(($amount - $rubles) * 100);
  186. return number_format($rubles, 0, ',', ' ') . ' рублей ' . sprintf('%02d', $kopecks) . ' копеек (' .
  187. $this->numberToWords($rubles) . ' рублей ' . sprintf('%02d', $kopecks) . ' копеек)';
  188. }
  189. private function numberToWords(int $number): string
  190. {
  191. if ($number === 0) {
  192. return 'Ноль';
  193. }
  194. $ones = [
  195. ['', 'один', 'два', 'три', 'четыре', 'пять', 'шесть', 'семь', 'восемь', 'девять'],
  196. ['', 'одна', 'две', 'три', 'четыре', 'пять', 'шесть', 'семь', 'восемь', 'девять'],
  197. ];
  198. $teens = ['десять', 'одиннадцать', 'двенадцать', 'тринадцать', 'четырнадцать', 'пятнадцать', 'шестнадцать', 'семнадцать', 'восемнадцать', 'девятнадцать'];
  199. $tens = ['', '', 'двадцать', 'тридцать', 'сорок', 'пятьдесят', 'шестьдесят', 'семьдесят', 'восемьдесят', 'девяносто'];
  200. $hundreds = ['', 'сто', 'двести', 'триста', 'четыреста', 'пятьсот', 'шестьсот', 'семьсот', 'восемьсот', 'девятьсот'];
  201. $units = [
  202. ['', '', '', 0],
  203. ['тысяча', 'тысячи', 'тысяч', 1],
  204. ['миллион', 'миллиона', 'миллионов', 0],
  205. ['миллиард', 'миллиарда', 'миллиардов', 0],
  206. ];
  207. $parts = [];
  208. $groups = array_reverse(str_split(str_pad((string) $number, (int) ceil(strlen((string) $number) / 3) * 3, '0', STR_PAD_LEFT), 3));
  209. foreach ($groups as $index => $group) {
  210. $value = (int) $group;
  211. if ($value === 0) {
  212. continue;
  213. }
  214. $gender = $units[$index][3] ?? 0;
  215. $hundred = intdiv($value, 100);
  216. $ten = intdiv($value % 100, 10);
  217. $one = $value % 10;
  218. $words = [];
  219. if ($hundred > 0) {
  220. $words[] = $hundreds[$hundred];
  221. }
  222. if ($ten === 1) {
  223. $words[] = $teens[$one];
  224. } else {
  225. if ($ten > 1) {
  226. $words[] = $tens[$ten];
  227. }
  228. if ($one > 0) {
  229. $words[] = $ones[$gender][$one];
  230. }
  231. }
  232. if ($index > 0) {
  233. $words[] = $this->plural($value, $units[$index][0], $units[$index][1], $units[$index][2]);
  234. }
  235. array_unshift($parts, implode(' ', $words));
  236. }
  237. $result = implode(' ', $parts);
  238. return mb_strtoupper(mb_substr($result, 0, 1)) . mb_substr($result, 1);
  239. }
  240. private function plural(int $number, string $one, string $two, string $many): string
  241. {
  242. $number = abs($number) % 100;
  243. $last = $number % 10;
  244. if ($number > 10 && $number < 20) {
  245. return $many;
  246. }
  247. return match ($last) {
  248. 1 => $one,
  249. 2, 3, 4 => $two,
  250. default => $many,
  251. };
  252. }
  253. }