ContractorPriceService.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. <?php
  2. namespace App\Services;
  3. use App\Models\Contractor;
  4. use App\Models\ContractorInstallationPrice;
  5. use App\Models\Product;
  6. use App\Models\Scopes\YearScope;
  7. use Illuminate\Support\Arr;
  8. use Illuminate\Http\UploadedFile;
  9. use Illuminate\Support\Collection;
  10. use stdClass;
  11. use PhpOffice\PhpSpreadsheet\IOFactory;
  12. use PhpOffice\PhpSpreadsheet\Spreadsheet;
  13. use PhpOffice\PhpSpreadsheet\Style\Border;
  14. use PhpOffice\PhpSpreadsheet\Worksheet\Drawing;
  15. use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
  16. class ContractorPriceService
  17. {
  18. public function rowsForContractor(
  19. Contractor $contractor,
  20. int $year,
  21. array $filters = [],
  22. string $search = '',
  23. string $sortBy = 'article',
  24. string $orderBy = 'asc'
  25. ): Collection
  26. {
  27. return $this->sortRows(
  28. $this->applySearch($this->applyFilters($this->baseRowsForContractor($contractor, $year), $filters), $search),
  29. $sortBy,
  30. $orderBy,
  31. )->values();
  32. }
  33. public function filterOptionsForRows(Collection $rows, array $header): array
  34. {
  35. return [
  36. 'article' => [
  37. 'title' => $header['article'],
  38. 'values' => $this->uniqueFilterValues($rows, 'article'),
  39. ],
  40. 'nomenclature_number' => [
  41. 'title' => $header['nomenclature_number'],
  42. 'values' => $this->uniqueFilterValues($rows, 'nomenclature_number'),
  43. ],
  44. 'name_in_spec' => [
  45. 'title' => $header['name_in_spec'],
  46. 'values' => $this->uniqueFilterValues($rows, 'name_in_spec'),
  47. ],
  48. 'installation_price_txt' => [
  49. 'title' => $header['installation_price_txt'],
  50. 'values' => $this->uniqueFilterValues($rows, 'installation_price_filter'),
  51. ],
  52. 'status_name' => [
  53. 'title' => $header['status_name'],
  54. 'values' => [
  55. 'Доступен' => 'Доступен',
  56. 'МАФ недоступен' => 'МАФ недоступен',
  57. 'С ценой' => 'С ценой',
  58. 'Без цены' => 'Без цены',
  59. ],
  60. ],
  61. ];
  62. }
  63. private function baseRowsForContractor(Contractor $contractor, int $year): Collection
  64. {
  65. $currentProducts = Product::query()
  66. ->where('year', $year)
  67. ->orderBy('article')
  68. ->get()
  69. ->keyBy('id');
  70. $prices = $contractor->prices()
  71. ->where('catalog_year', $year)
  72. ->with('product')
  73. ->get()
  74. ->keyBy('product_id');
  75. $rows = collect();
  76. foreach ($currentProducts as $product) {
  77. $rows->push($this->buildRow($product, $prices->get($product->id), true));
  78. }
  79. foreach ($prices as $price) {
  80. if ($currentProducts->has($price->product_id) || !$price->product) {
  81. continue;
  82. }
  83. $rows->push($this->buildRow($price->product, $price, false));
  84. }
  85. return $rows;
  86. }
  87. private function uniqueFilterValues(Collection $rows, string $field): array
  88. {
  89. return $rows
  90. ->pluck($field)
  91. ->map(fn($value) => $value === null || $value === '' ? '-пусто-' : (string) $value)
  92. ->unique()
  93. ->sort(SORT_NATURAL)
  94. ->mapWithKeys(fn($value) => [$value => $value])
  95. ->all();
  96. }
  97. public function updatePrice(Contractor $contractor, Product $product, int $year, ?string $nameInSpec, float $price): ContractorInstallationPrice
  98. {
  99. return ContractorInstallationPrice::query()->updateOrCreate(
  100. [
  101. 'contractor_id' => $contractor->id,
  102. 'product_id' => $product->id,
  103. 'catalog_year' => $year,
  104. ],
  105. [
  106. 'name_in_spec' => $nameInSpec ?: null,
  107. 'price' => max(0, $price),
  108. ],
  109. );
  110. }
  111. public function import(Contractor $contractor, UploadedFile $file, int $year): array
  112. {
  113. $spreadsheet = IOFactory::load($file->getRealPath());
  114. $sheet = $spreadsheet->getActiveSheet();
  115. $highestRow = $sheet->getHighestDataRow();
  116. $updated = 0;
  117. $unchanged = 0;
  118. $errors = [];
  119. for ($row = 2; $row <= $highestRow; $row++) {
  120. $article = trim((string) $sheet->getCell('B' . $row)->getValue());
  121. if ($article === '') {
  122. continue;
  123. }
  124. $product = Product::query()
  125. ->where('year', $year)
  126. ->where('article', $article)
  127. ->first();
  128. if (!$product) {
  129. $errors[] = "Строка {$row}: артикул {$article} не найден в каталоге {$year} года";
  130. continue;
  131. }
  132. $nameInSpec = trim((string) $sheet->getCell('D' . $row)->getValue());
  133. $price = $this->parsePrice($sheet->getCell('E' . $row)->getCalculatedValue());
  134. $current = ContractorInstallationPrice::query()
  135. ->where('contractor_id', $contractor->id)
  136. ->where('product_id', $product->id)
  137. ->where('catalog_year', $year)
  138. ->first();
  139. $currentName = (string) ($current?->name_in_spec ?? '');
  140. $currentPrice = (float) ($current?->price ?? 0);
  141. if ($current && $currentName === $nameInSpec && abs($currentPrice - $price) < 0.001) {
  142. $unchanged++;
  143. continue;
  144. }
  145. $this->updatePrice($contractor, $product, $year, $nameInSpec, $price);
  146. $updated++;
  147. }
  148. return [
  149. 'updated' => $updated,
  150. 'unchanged' => $unchanged,
  151. 'errors' => $errors,
  152. ];
  153. }
  154. public function export(Contractor $contractor, int $year): string
  155. {
  156. $spreadsheet = new Spreadsheet();
  157. $sheet = $spreadsheet->getActiveSheet();
  158. $sheet->setTitle('Цены монтажа');
  159. $headers = [
  160. 'A' => 'Картинка МАФ',
  161. 'B' => 'Артикул МАФ',
  162. 'C' => 'Номер номенклатуры',
  163. 'D' => 'Наименование по спецификации',
  164. 'E' => 'Цена монтажа',
  165. ];
  166. foreach ($headers as $column => $title) {
  167. $sheet->setCellValue($column . '1', $title);
  168. }
  169. $rowNumber = 2;
  170. foreach ($this->rowsForContractor($contractor, $year) as $row) {
  171. $this->insertProductImage($sheet, $row->product, $rowNumber);
  172. $sheet->setCellValue('B' . $rowNumber, $row->product->article);
  173. $sheet->setCellValue('C' . $rowNumber, $row->product->nomenclature_number);
  174. $sheet->setCellValue('D' . $rowNumber, $row->price?->name_in_spec ?? '');
  175. $sheet->setCellValue('E' . $rowNumber, $row->price?->price ?? 0);
  176. $sheet->getRowDimension($rowNumber)->setRowHeight(55);
  177. $rowNumber++;
  178. }
  179. $sheet->getStyle('A1:E1')->getFont()->setBold(true);
  180. $sheet->getStyle('A1:E' . max(1, $rowNumber - 1))->getBorders()->getAllBorders()->setBorderStyle(Border::BORDER_THIN);
  181. $sheet->getStyle('E2:E' . max(2, $rowNumber))->getNumberFormat()->setFormatCode('#,##0.00');
  182. foreach (range('A', 'E') as $column) {
  183. $sheet->getColumnDimension($column)->setAutoSize(true);
  184. }
  185. $sheet->getColumnDimension('A')->setWidth(18);
  186. $path = storage_path('app/contractor-prices-' . $contractor->id . '-' . $year . '-' . time() . '.xlsx');
  187. (new Xlsx($spreadsheet))->save($path);
  188. return $path;
  189. }
  190. private function buildRow(Product $product, ?ContractorInstallationPrice $price, bool $available): stdClass
  191. {
  192. $row = new stdClass();
  193. $row->id = $product->id;
  194. $row->product = $product;
  195. $row->price = $price;
  196. $row->product_id = $product->id;
  197. $row->image = $product->image;
  198. $row->article = $product->article;
  199. $row->nomenclature_number = $product->nomenclature_number;
  200. $row->name_in_spec = $price?->name_in_spec ?? '';
  201. $row->installation_price = (float) ($price?->price ?? 0);
  202. $row->installation_price_txt = $price?->price_txt ?? '0.00₽';
  203. $row->installation_price_filter = str_replace('&nbsp;', ' ', $row->installation_price_txt);
  204. $row->available = $available && is_null($product->deleted_at);
  205. $row->status_name = $row->available ? 'Доступен' : 'МАФ недоступен';
  206. return $row;
  207. }
  208. private function applyFilters(Collection $rows, array $filters): Collection
  209. {
  210. $article = trim((string) ($filters['article'] ?? ''));
  211. $nomenclatureNumber = trim((string) ($filters['nomenclature_number'] ?? ''));
  212. $nameInSpec = trim((string) ($filters['name_in_spec'] ?? ''));
  213. $installationPrice = trim((string) ($filters['installation_price_txt'] ?? ''));
  214. $status = (string) ($filters['status_name'] ?? '');
  215. return $rows->filter(function (stdClass $row) use ($article, $nomenclatureNumber, $nameInSpec, $installationPrice, $status): bool {
  216. if (!$this->matchesListFilter((string) $row->article, $article)) {
  217. return false;
  218. }
  219. if (!$this->matchesListFilter((string) $row->nomenclature_number, $nomenclatureNumber)) {
  220. return false;
  221. }
  222. if (!$this->matchesListFilter((string) $row->name_in_spec, $nameInSpec)) {
  223. return false;
  224. }
  225. if (!$this->matchesListFilter((string) $row->installation_price_filter, $installationPrice)) {
  226. return false;
  227. }
  228. return match ($status) {
  229. 'С ценой' => $row->installation_price > 0,
  230. 'Без цены' => $row->installation_price <= 0,
  231. 'Доступен' => (bool) $row->available,
  232. 'МАФ недоступен' => !$row->available,
  233. default => true,
  234. };
  235. });
  236. }
  237. private function matchesListFilter(string $value, string $filter): bool
  238. {
  239. if ($filter === '') {
  240. return true;
  241. }
  242. $normalizedValue = $value === '' ? '-пусто-' : $value;
  243. $values = explode('||', $filter);
  244. return in_array($normalizedValue, $values, true);
  245. }
  246. private function applySearch(Collection $rows, string $search): Collection
  247. {
  248. $search = trim(mb_strtolower($search));
  249. if ($search === '') {
  250. return $rows;
  251. }
  252. return $rows->filter(fn(stdClass $row) => str_contains(mb_strtolower((string) $row->article), $search)
  253. || str_contains(mb_strtolower((string) $row->nomenclature_number), $search)
  254. || str_contains(mb_strtolower((string) $row->name_in_spec), $search));
  255. }
  256. private function sortRows(Collection $rows, string $sortBy, string $orderBy): Collection
  257. {
  258. $sortField = match ($sortBy) {
  259. 'installation_price_txt' => 'installation_price',
  260. 'status_name' => 'status_name',
  261. 'nomenclature_number' => 'nomenclature_number',
  262. 'name_in_spec' => 'name_in_spec',
  263. default => 'article',
  264. };
  265. return $rows->sortBy(
  266. fn(stdClass $row) => Arr::get((array) $row, $sortField),
  267. SORT_REGULAR,
  268. $orderBy === 'desc',
  269. );
  270. }
  271. private function parsePrice(mixed $value): float
  272. {
  273. if ($value === null || $value === '') {
  274. return 0;
  275. }
  276. $normalized = str_replace([' ', "\xc2\xa0", '₽'], '', (string) $value);
  277. $normalized = str_replace(',', '.', $normalized);
  278. return is_numeric($normalized) ? (float) $normalized : 0;
  279. }
  280. private function insertProductImage($sheet, Product $product, int $row): void
  281. {
  282. $path = public_path('images/main/' . $product->article . '.0000.0000.jpg');
  283. if (!file_exists($path)) {
  284. return;
  285. }
  286. $drawing = new Drawing();
  287. $drawing->setPath($path);
  288. $drawing->setCoordinates('A' . $row);
  289. $drawing->setHeight(60);
  290. $drawing->setOffsetX(4);
  291. $drawing->setOffsetY(4);
  292. $drawing->setWorksheet($sheet);
  293. }
  294. }