| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454 |
- <?php
- declare(strict_types=1);
- namespace App\Services\Import;
- use App\Enums\ProductionOrderExecutionType;
- use App\Enums\ProductionOrderSource;
- use App\Enums\ProductionOrderStatus;
- use App\Models\CommonCatalogItem;
- use App\Models\Import;
- use App\Models\ProductionOrder;
- use App\Models\ProductionOrderItem;
- use App\Models\Role;
- use App\Models\User;
- use App\Services\Export\ExportProductionOrdersService;
- use App\Services\ProductionOrderService;
- use DateTimeImmutable;
- use Illuminate\Support\Facades\DB;
- use Illuminate\Support\Facades\Storage;
- use Illuminate\Support\Facades\Validator;
- use Illuminate\Validation\Rule;
- use PhpOffice\PhpSpreadsheet\Cell\Cell;
- use PhpOffice\PhpSpreadsheet\Reader\Xlsx;
- use PhpOffice\PhpSpreadsheet\Shared\Date as SpreadsheetDate;
- use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
- use RuntimeException;
- use Throwable;
- final class ImportProductionOrdersService
- {
- public function __construct(
- private readonly Import $import,
- private readonly int $userId,
- private readonly ProductionOrderService $orderService,
- ) {}
- public function handle(): bool
- {
- $spreadsheet = null;
- try {
- $actor = User::query()->findOrFail($this->userId);
- $spreadsheet = (new Xlsx)->load(
- Storage::disk('upload')->path((string) $this->import->filename),
- );
- $ordersSheet = $spreadsheet->getSheetByName(ExportProductionOrdersService::ORDERS_SHEET);
- $itemsSheet = $spreadsheet->getSheetByName(ExportProductionOrdersService::ITEMS_SHEET);
- if ($ordersSheet === null || $itemsSheet === null) {
- throw new RuntimeException('В файле должны быть листы «График заказов» и «МАФ».');
- }
- $this->assertHeaders($ordersSheet, $itemsSheet);
- $orders = $this->readOrders($ordersSheet);
- $this->readItems($itemsSheet, $orders);
- $created = 0;
- $updated = 0;
- DB::transaction(function () use ($orders, $actor, &$created, &$updated): void {
- foreach ($orders as $orderData) {
- $payload = $orderData['data'];
- $payload['items'] = $orderData['items'];
- if ($orderData['order'] instanceof ProductionOrder) {
- $this->orderService->update(
- $orderData['order'],
- $payload,
- $actor,
- true,
- );
- $updated++;
- } else {
- $this->orderService->create(
- $payload,
- $actor,
- ProductionOrderSource::Spreadsheet,
- );
- $created++;
- }
- }
- });
- $this->import->log("Создано заказов: {$created}; обновлено заказов: {$updated}.");
- $this->import->update(['status' => 'DONE']);
- return true;
- } catch (Throwable $exception) {
- $this->import->log($exception->getMessage(), 'ERROR');
- $this->import->update(['status' => 'ERROR']);
- throw $exception;
- } finally {
- $spreadsheet?->disconnectWorksheets();
- }
- }
- private function assertHeaders(Worksheet $ordersSheet, Worksheet $itemsSheet): void
- {
- $orderHeaders = $ordersSheet->rangeToArray('A1:R1', null, true, true, false)[0];
- $itemHeaders = $itemsSheet->rangeToArray('A1:H1', null, true, true, false)[0];
- if ($this->normalizedHeaders($orderHeaders) !== $this->normalizedHeaders(ExportProductionOrdersService::HEADERS)) {
- throw new RuntimeException('Некорректные заголовки листа «График заказов». Используйте экспорт графика заказов.');
- }
- if ($this->normalizedHeaders($itemHeaders) !== $this->normalizedHeaders(ExportProductionOrdersService::ITEM_HEADERS)) {
- throw new RuntimeException('Некорректные заголовки листа «МАФ». Используйте экспорт графика заказов.');
- }
- }
- /**
- * @return array<int, array{
- * order: ProductionOrder|null,
- * data: array<string, mixed>,
- * items: list<array<string, mixed>>
- * }>
- */
- private function readOrders(Worksheet $sheet): array
- {
- $orders = [];
- $seenIds = [];
- $seenNumbers = [];
- for ($row = 2; $row <= $sheet->getHighestDataRow(); $row++) {
- if (! $this->rowHasValues($sheet, $row, 'A', 'R')) {
- continue;
- }
- $id = $this->integerValue($sheet->getCell("A{$row}"), 'ID заказа', 1, true);
- if ($id !== null && isset($seenIds[$id])) {
- throw new RuntimeException("Лист «График заказов», строка {$row}: ID {$id} указан повторно.");
- }
- $order = $id === null ? null : ProductionOrder::query()->find($id);
- if ($id !== null && $order === null) {
- throw new RuntimeException("Лист «График заказов», строка {$row}: заказ с ID {$id} не найден.");
- }
- $orderNumber = $this->requiredString($sheet->getCell("B{$row}"), 'Номер заказа');
- $orderYear = $this->integerValue($sheet->getCell("C{$row}"), 'Год', 2000, false, 2100);
- $numberKey = $this->normalize($orderNumber).'|'.$orderYear;
- if (isset($seenNumbers[$numberKey])) {
- throw new RuntimeException(
- "Лист «График заказов», строка {$row}: заказ {$orderNumber} за {$orderYear} год указан повторно.",
- );
- }
- $data = [
- 'order_number' => $orderNumber,
- 'order_year' => $orderYear,
- 'customer_name' => $this->requiredString($sheet->getCell("D{$row}"), 'Заказчик'),
- 'object_address' => $this->requiredString($sheet->getCell("E{$row}"), 'Адрес объекта'),
- 'invoice_number' => $this->requiredString($sheet->getCell("F{$row}"), 'Номер счёта'),
- 'invoice_date' => $this->dateValue($sheet->getCell("G{$row}"), 'Дата счёта', false),
- 'contract_number' => $this->nullableString($sheet->getCell("H{$row}")),
- 'contract_date' => $this->dateValue($sheet->getCell("I{$row}"), 'Дата договора', false),
- 'payment_date' => $this->dateValue($sheet->getCell("J{$row}"), 'Дата оплаты', true),
- 'supply_working_days' => $this->integerValue(
- $sheet->getCell("K{$row}"),
- 'Срок поставки',
- 0,
- false,
- 2000,
- ),
- 'application_shipment_date' => $this->dateValue(
- $sheet->getCell("M{$row}"),
- 'Дата отгрузки по заявке',
- false,
- ),
- 'status' => $this->statusValue($sheet->getCell("N{$row}")),
- 'execution_type' => $this->executionTypeValue($sheet->getCell("O{$row}")),
- 'manager_id' => $this->managerId($sheet->getCell("P{$row}")),
- 'note' => $this->nullableString($sheet->getCell("R{$row}")),
- ];
- $validator = Validator::make($data, [
- 'order_number' => [
- 'required',
- 'string',
- 'max:100',
- Rule::unique('production_orders', 'order_number')
- ->where('order_year', $orderYear)
- ->ignore($order?->id),
- ],
- 'order_year' => ['required', 'integer', 'min:2000', 'max:2100'],
- 'customer_name' => ['required', 'string', 'max:500'],
- 'object_address' => ['required', 'string', 'max:5000'],
- 'invoice_number' => ['required', 'string', 'max:100'],
- 'invoice_date' => ['nullable', 'date'],
- 'contract_number' => ['nullable', 'string', 'max:100'],
- 'contract_date' => ['nullable', 'date'],
- 'payment_date' => ['required', 'date'],
- 'supply_working_days' => ['required', 'integer', 'min:0', 'max:2000'],
- 'application_shipment_date' => ['nullable', 'date'],
- 'status' => ['required', Rule::enum(ProductionOrderStatus::class)],
- 'execution_type' => ['required', Rule::enum(ProductionOrderExecutionType::class)],
- 'manager_id' => [
- 'required',
- 'integer',
- Rule::exists('users', 'id')->where('role', Role::MANAGER),
- ],
- 'note' => ['nullable', 'string', 'max:10000'],
- ]);
- if ($validator->fails()) {
- throw new RuntimeException(
- "Лист «График заказов», строка {$row}: ".implode(' ', $validator->errors()->all()),
- );
- }
- $orders[$row] = [
- 'order' => $order,
- 'data' => $validator->validated(),
- 'items' => [],
- ];
- if ($id !== null) {
- $seenIds[$id] = true;
- }
- $seenNumbers[$numberKey] = true;
- }
- if ($orders === []) {
- throw new RuntimeException('Лист «График заказов» не содержит заказов для импорта.');
- }
- return $orders;
- }
- /**
- * @param array<int, array{
- * order: ProductionOrder|null,
- * data: array<string, mixed>,
- * items: list<array<string, mixed>>
- * }> $orders
- */
- private function readItems(Worksheet $sheet, array &$orders): void
- {
- $seenIds = [];
- for ($row = 2; $row <= $sheet->getHighestDataRow(); $row++) {
- if (! $this->rowHasValues($sheet, $row, 'A', 'H')) {
- continue;
- }
- $orderRow = $this->integerValue($sheet->getCell("A{$row}"), 'Строка заказа', 2);
- if (! isset($orders[$orderRow])) {
- throw new RuntimeException(
- "Лист «МАФ», строка {$row}: строка заказа {$orderRow} отсутствует на листе «График заказов».",
- );
- }
- $itemId = $this->integerValue($sheet->getCell("B{$row}"), 'ID МАФ', 1, true);
- $existingItem = null;
- if ($itemId !== null) {
- if (isset($seenIds[$itemId])) {
- throw new RuntimeException("Лист «МАФ», строка {$row}: ID МАФ {$itemId} указан повторно.");
- }
- $existingItem = ProductionOrderItem::query()->find($itemId);
- if ($existingItem === null) {
- throw new RuntimeException("Лист «МАФ», строка {$row}: МАФ с ID {$itemId} не найден.");
- }
- $targetOrder = $orders[$orderRow]['order'];
- if ($targetOrder === null || $existingItem->production_order_id !== $targetOrder->id) {
- throw new RuntimeException(
- "Лист «МАФ», строка {$row}: МАФ с ID {$itemId} не принадлежит указанному заказу.",
- );
- }
- $seenIds[$itemId] = true;
- }
- $catalogItemId = $this->integerValue(
- $sheet->getCell("C{$row}"),
- 'ID позиции каталога',
- 1,
- );
- if (! CommonCatalogItem::query()->whereKey($catalogItemId)->exists()) {
- throw new RuntimeException(
- "Лист «МАФ», строка {$row}: позиция общего каталога с ID {$catalogItemId} не найдена.",
- );
- }
- $itemData = [
- 'id' => $existingItem?->id,
- 'common_catalog_item_id' => $catalogItemId,
- 'order_item_number' => $this->requiredString(
- $sheet->getCell("F{$row}"),
- 'Номер заказа МАФ',
- ),
- 'factory_number' => $this->nullableString($sheet->getCell("G{$row}")),
- 'manufacture_date' => $this->dateValue(
- $sheet->getCell("H{$row}"),
- 'Дата производства',
- false,
- ),
- ];
- $validator = Validator::make($itemData, [
- 'id' => ['nullable', 'integer'],
- 'common_catalog_item_id' => ['required', 'integer'],
- 'order_item_number' => ['required', 'string', 'max:100'],
- 'factory_number' => ['nullable', 'string', 'max:100'],
- 'manufacture_date' => ['nullable', 'date'],
- ]);
- if ($validator->fails()) {
- throw new RuntimeException(
- "Лист «МАФ», строка {$row}: ".implode(' ', $validator->errors()->all()),
- );
- }
- $orders[$orderRow]['items'][] = array_filter(
- $validator->validated(),
- static fn (mixed $value, string $key): bool => $key !== 'id' || $value !== null,
- ARRAY_FILTER_USE_BOTH,
- );
- }
- foreach ($orders as $row => $order) {
- if ($order['items'] === []) {
- throw new RuntimeException(
- "Лист «График заказов», строка {$row}: на листе «МАФ» не указано оборудование заказа.",
- );
- }
- }
- }
- /** @param list<mixed> $headers */
- private function normalizedHeaders(array $headers): array
- {
- return array_map($this->normalize(...), $headers);
- }
- private function normalize(mixed $value): string
- {
- return mb_strtolower(trim((string) preg_replace('/\s+/u', ' ', (string) $value)));
- }
- private function rowHasValues(Worksheet $sheet, int $row, string $from, string $to): bool
- {
- $values = $sheet->rangeToArray("{$from}{$row}:{$to}{$row}", null, true, true, false)[0];
- return collect($values)->contains(static fn (mixed $value): bool => trim((string) $value) !== '');
- }
- private function requiredString(Cell $cell, string $label): string
- {
- $value = $this->nullableString($cell);
- if ($value === null) {
- throw new RuntimeException("Не заполнено поле «{$label}».");
- }
- return $value;
- }
- private function nullableString(Cell $cell): ?string
- {
- $value = trim((string) $cell->getFormattedValue());
- return $value === '' ? null : $value;
- }
- private function integerValue(
- Cell $cell,
- string $label,
- int $minimum,
- bool $nullable = false,
- ?int $maximum = null,
- ): ?int {
- $raw = $cell->getCalculatedValue();
- if ($raw === null || trim((string) $raw) === '') {
- if ($nullable) {
- return null;
- }
- throw new RuntimeException("Не заполнено поле «{$label}».");
- }
- $value = filter_var($raw, FILTER_VALIDATE_INT);
- if ($value === false || $value < $minimum || ($maximum !== null && $value > $maximum)) {
- throw new RuntimeException("Поле «{$label}» содержит недопустимое целое число.");
- }
- return $value;
- }
- private function dateValue(Cell $cell, string $label, bool $required): ?string
- {
- $raw = $cell->getCalculatedValue();
- if ($raw === null || trim((string) $raw) === '') {
- if ($required) {
- throw new RuntimeException("Не заполнено поле «{$label}».");
- }
- return null;
- }
- if (is_numeric($raw)) {
- try {
- return SpreadsheetDate::excelToDateTimeObject((float) $raw)->format('Y-m-d');
- } catch (Throwable) {
- throw new RuntimeException("Поле «{$label}» содержит некорректную дату.");
- }
- }
- $value = trim((string) $raw);
- foreach (['d.m.Y', 'Y-m-d', 'd/m/Y'] as $format) {
- $date = DateTimeImmutable::createFromFormat('!'.$format, $value);
- $errors = DateTimeImmutable::getLastErrors();
- if ($date !== false && ($errors === false || ($errors['warning_count'] === 0 && $errors['error_count'] === 0))) {
- return $date->format('Y-m-d');
- }
- }
- throw new RuntimeException("Поле «{$label}» содержит некорректную дату.");
- }
- private function managerId(Cell $cell): int
- {
- $name = $this->requiredString($cell, 'Менеджер');
- $managers = User::query()
- ->where('role', Role::MANAGER)
- ->where('name', $name)
- ->limit(2)
- ->get(['id']);
- if ($managers->isEmpty()) {
- throw new RuntimeException("Менеджер «{$name}» не найден.");
- }
- if ($managers->count() > 1) {
- throw new RuntimeException("Имя менеджера «{$name}» неоднозначно.");
- }
- return (int) $managers->first()->id;
- }
- private function statusValue(Cell $cell): string
- {
- $value = $this->normalize($this->requiredString($cell, 'Статус'));
- foreach (ProductionOrderStatus::cases() as $status) {
- if (in_array($value, [$this->normalize($status->value), $this->normalize($status->label())], true)) {
- return $status->value;
- }
- }
- throw new RuntimeException("Неизвестный статус «{$cell->getFormattedValue()}».");
- }
- private function executionTypeValue(Cell $cell): string
- {
- $value = $this->normalize($this->requiredString($cell, 'Тип исполнения'));
- foreach (ProductionOrderExecutionType::cases() as $type) {
- if (in_array($value, [$this->normalize($type->value), $this->normalize($type->label())], true)) {
- return $type->value;
- }
- }
- throw new RuntimeException("Неизвестный тип исполнения «{$cell->getFormattedValue()}».");
- }
- }
|