Alexander Musikhin 2 дней назад
Родитель
Сommit
e3c6e997b2

+ 21 - 0
app/Http/Controllers/ProductSKUController.php

@@ -5,9 +5,11 @@ namespace App\Http\Controllers;
 use App\Helpers\DateHelper;
 use App\Http\Requests\ProductSKUStoreRequest;
 use App\Jobs\ExportMafJob;
+use App\Jobs\ExportMafRegistryJob;
 use App\Models\File;
 use App\Models\MafView;
 use App\Models\ProductSKU;
+use App\Models\Role;
 use App\Services\FileService;
 use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
@@ -188,6 +190,25 @@ class ProductSKUController extends Controller
         return redirect()->route('product_sku.index', session('gp_product_sku'))->with(['success' => 'Задача экспорта успешно создана!']);
     }
 
+    public function exportMafRegistry(Request $request)
+    {
+        abort_unless($request->user()?->hasRole([Role::ADMIN, Role::ASSISTANT_HEAD]), 403);
+
+        $validated = $request->validate([
+            'upd_number' => 'required|string|max:255',
+        ]);
+
+        ExportMafRegistryJob::dispatch(
+            $request->user()->id,
+            trim($validated['upd_number']),
+            (int) $request->session()->get('year', date('Y'))
+        );
+
+        return redirect()
+            ->route('product_sku.index', session('gp_product_sku'))
+            ->with(['success' => 'Задача формирования реестра на оплату успешно создана!']);
+    }
+
     public function importMaf()
     {
 

+ 34 - 0
app/Jobs/ExportMafRegistryJob.php

@@ -0,0 +1,34 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Events\SendWebSocketMessageEvent;
+use App\Services\ExportMafRegistryService;
+use Exception;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Queue\Queueable;
+use Illuminate\Support\Facades\Log;
+
+class ExportMafRegistryJob implements ShouldQueue
+{
+    use Queueable;
+
+    public function __construct(
+        private readonly int $userId,
+        private readonly string $updNumber,
+        private readonly int $year,
+    ) {
+    }
+
+    public function handle(): void
+    {
+        try {
+            $link = (new ExportMafRegistryService())->handle($this->userId, $this->updNumber, $this->year);
+            Log::info('ExportMafRegistry job done!');
+            event(new SendWebSocketMessageEvent('Реестр на оплату сформирован!', $this->userId, ['success' => true, 'link' => $link]));
+        } catch (Exception $e) {
+            Log::error('ExportMafRegistry job failed! ' . $e->getMessage());
+            event(new SendWebSocketMessageEvent('Ошибка формирования реестра на оплату! ', $this->userId, ['error' => $e->getMessage()]));
+        }
+    }
+}

+ 170 - 0
app/Services/ExportMafRegistryService.php

@@ -0,0 +1,170 @@
+<?php
+
+namespace App\Services;
+
+use App\Helpers\DateHelper;
+use App\Models\File;
+use App\Models\ProductSKU;
+use App\Models\Scopes\YearScope;
+use Exception;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Storage;
+use PhpOffice\PhpSpreadsheet\IOFactory;
+use PhpOffice\PhpSpreadsheet\Style\Border;
+use PhpOffice\PhpSpreadsheet\Style\Color;
+use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
+use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
+use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
+
+class ExportMafRegistryService
+{
+    private const TEMPLATE = './templates/MafRegistry.xlsx';
+    private const SHEET_TITLE = 'НАШ ДВОР';
+    private const FIRST_DATA_ROW = 4;
+
+    /**
+     * @throws Exception
+     */
+    public function handle(int $userId, string $updNumber, int $year): string
+    {
+        $updNumber = trim($updNumber);
+
+        if ($updNumber === '') {
+            throw new Exception('Укажите номер УПД.');
+        }
+
+        return DB::transaction(function () use ($userId, $updNumber, $year): string {
+            $mafs = $this->getMafsForRegistry($year);
+
+            if ($mafs->isEmpty()) {
+                throw new Exception('Нет МАФ с заполненным № ведомости и пустым № УПД.');
+            }
+
+            $reader = IOFactory::createReader('Xlsx');
+            $spreadsheet = $reader->load(self::TEMPLATE);
+            $sheet = $spreadsheet->getActiveSheet();
+            $sheet->setTitle(self::SHEET_TITLE);
+
+            $this->prepareTemplateRows($sheet, $mafs->count());
+            $this->fillHeader($sheet, $updNumber, $mafs->count());
+            $this->fillRows($sheet, $mafs, $updNumber);
+
+            $lastRow = self::FIRST_DATA_ROW + $mafs->count() - 1;
+            $sheet->getStyle('A1:P' . $lastRow)
+                ->getBorders()
+                ->getAllBorders()
+                ->setBorderStyle(Border::BORDER_THIN)
+                ->setColor(new Color('777777'));
+
+            $sheet->getStyle('H' . self::FIRST_DATA_ROW . ':H' . $lastRow)
+                ->getNumberFormat()
+                ->setFormatCode(NumberFormat::FORMAT_DATE_DDMMYYYY);
+            $sheet->getStyle('O' . self::FIRST_DATA_ROW . ':O' . $lastRow)
+                ->getNumberFormat()
+                ->setFormatCode(NumberFormat::FORMAT_NUMBER_00);
+
+            $fileName = fileName('Реестр на оплату УПД ' . $updNumber . ' ' . date('Y-m-d H-i-s') . '.xlsx');
+            $directory = 'export/maf-registry';
+            $path = $directory . '/' . $fileName;
+
+            Storage::disk('public')->makeDirectory($directory);
+            Storage::disk('public')->delete($path);
+
+            (new Xlsx($spreadsheet))->save(Storage::disk('public')->path($path));
+
+            ProductSKU::query()
+                ->withoutGlobalScope(YearScope::class)
+                ->whereIn('id', $mafs->pluck('id'))
+                ->update(['upd_number' => $updNumber]);
+
+            File::query()->create([
+                'link' => url('/storage/' . $path),
+                'path' => $path,
+                'user_id' => $userId,
+                'original_name' => $fileName,
+                'mime_type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+                'is_generated' => true,
+            ]);
+
+            return url('/storage/' . $path);
+        });
+    }
+
+    private function getMafsForRegistry(int $year): Collection
+    {
+        return ProductSKU::query()
+            ->withoutGlobalScope(YearScope::class)
+            ->where('year', $year)
+            ->whereNotNull('statement_number')
+            ->whereRaw("TRIM(statement_number) <> ''")
+            ->where(function ($query): void {
+                $query->whereNull('upd_number')
+                    ->orWhereRaw("TRIM(upd_number) = ''");
+            })
+            ->with([
+                'product' => fn ($query) => $query->withoutGlobalScope(YearScope::class),
+                'order' => fn ($query) => $query->withoutGlobalScope(YearScope::class)->with(['district', 'area', 'objectType']),
+            ])
+            ->orderBy('statement_number')
+            ->orderBy('id')
+            ->lockForUpdate()
+            ->get();
+    }
+
+    private function prepareTemplateRows(Worksheet $sheet, int $rowsCount): void
+    {
+        $highestRow = $sheet->getHighestRow();
+        if ($highestRow > self::FIRST_DATA_ROW) {
+            $sheet->removeRow(self::FIRST_DATA_ROW + 1, $highestRow - self::FIRST_DATA_ROW);
+        }
+
+        if ($rowsCount > 1) {
+            $sheet->insertNewRowBefore(self::FIRST_DATA_ROW + 1, $rowsCount - 1);
+        }
+
+        $templateStyle = $sheet->getStyle('A' . self::FIRST_DATA_ROW . ':P' . self::FIRST_DATA_ROW);
+        for ($row = self::FIRST_DATA_ROW; $row < self::FIRST_DATA_ROW + $rowsCount; $row++) {
+            $sheet->duplicateStyle($templateStyle, 'A' . $row . ':P' . $row);
+        }
+    }
+
+    private function fillHeader(Worksheet $sheet, string $updNumber, int $rowsCount): void
+    {
+        $lastRow = self::FIRST_DATA_ROW + $rowsCount - 1;
+
+        $sheet->setCellValue('M1', '=SUM(M' . self::FIRST_DATA_ROW . ':M' . $lastRow . ')');
+        $sheet->setCellValue('N1', 0);
+        $sheet->setCellValue('O1', '=SUM(O' . self::FIRST_DATA_ROW . ':O' . $lastRow . ')');
+        $sheet->setCellValue('C2', 'Реестр оборудования и ведомостей "технической приемки" к УПД ' . $updNumber);
+    }
+
+    private function fillRows(Worksheet $sheet, Collection $mafs, string $updNumber): void
+    {
+        $row = self::FIRST_DATA_ROW;
+
+        foreach ($mafs as $maf) {
+            $product = $maf->product;
+            $order = $maf->order;
+
+            $sheet->setCellValue('A' . $row, self::SHEET_TITLE);
+            $sheet->setCellValue('B' . $row, '4');
+            $sheet->setCellValue('C' . $row, $updNumber);
+            $sheet->setCellValue('D' . $row, $product?->nomenclature_number);
+            $sheet->setCellValue('E' . $row, $product?->statement_name);
+            $sheet->setCellValue('F' . $row, $product?->article);
+            $sheet->setCellValue('G' . $row, $maf->statement_number);
+            $sheet->setCellValue('H' . $row, $maf->statement_date ? DateHelper::ISODateToExcelDate((string) $maf->statement_date) : '');
+            $sheet->setCellValue('I' . $row, $order?->name);
+            $sheet->setCellValue('J' . $row, $order?->objectType?->name);
+            $sheet->setCellValue('K' . $row, $order?->area?->name);
+            $sheet->setCellValue('L' . $row, $order?->district?->shortname);
+            $sheet->setCellValue('M' . $row, 1);
+            $sheet->setCellValue('N' . $row, 0);
+            $sheet->setCellValue('O' . $row, $product?->total_price ?? 0);
+            $sheet->setCellValue('P' . $row, 'С бетоном');
+
+            $row++;
+        }
+    }
+}

+ 25 - 0
resources/views/products_sku/index.blade.php

@@ -20,6 +20,12 @@
                     <span class="page-action-btn__label">Экспорт</span>
                 </button>
             @endif
+            @if(hasRole('admin,assistant_head'))
+                <button type="button" class="btn btn-sm mb-1 btn-primary page-action-btn" data-bs-toggle="modal" data-bs-target="#registryModal" aria-label="Реестр на оплату">
+                    <i class="bi bi-file-earmark-spreadsheet page-action-btn__icon"></i>
+                    <span class="page-action-btn__label">Реестр на оплату</span>
+                </button>
+            @endif
         </div>
     </div>
     @include('partials.table', [
@@ -79,6 +85,25 @@
             </div>
         </div>
     </div>
+
+    <!-- Модальное окно реестра на оплату-->
+    <div class="modal fade" id="registryModal" tabindex="-1" aria-labelledby="registryModalLabel" aria-hidden="true">
+        <div class="modal-dialog modal-fullscreen-sm-down">
+            <div class="modal-content">
+                <div class="modal-header">
+                    <h1 class="modal-title fs-5" id="registryModalLabel">Реестр на оплату</h1>
+                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
+                </div>
+                <div class="modal-body">
+                    <form action="{{ route('mafs.registry') }}" method="post">
+                        @csrf
+                        @include('partials.input', ['name' => 'upd_number', 'title' => 'Номер УПД', 'required' => true])
+                        @include('partials.submit', ['name' => 'Сформировать'])
+                    </form>
+                </div>
+            </div>
+        </div>
+    </div>
 @endsection
 @push('scripts')
     <script type="module">

+ 4 - 1
routes/web.php

@@ -216,7 +216,6 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
             ->name('product_sku.inline-update');
         Route::get('product_sku/{product_sku}', [ProductSKUController::class, 'show'])->name('product_sku.show');
         Route::post('product_sku/update/{product_sku}', [ProductSKUController::class, 'update'])->name('product_sku.update');
-
         // рекламации
         Route::post('reclamations/create/{order}', [ReclamationController::class, 'create'])->name('reclamations.create');
         Route::post('reclamations/update/{reclamation}', [ReclamationController::class, 'update'])->name('reclamations.update');
@@ -236,6 +235,10 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
 
     });
 
+    Route::post('mafs-registry', [ProductSKUController::class, 'exportMafRegistry'])
+        ->name('mafs.registry')
+        ->middleware('role:admin,' . Role::ASSISTANT_HEAD);
+
 
     // orders for all
     Route::get('order', [OrderController::class, 'index'])->name('order.index');

BIN
templates/MafRegistry.xlsx


+ 33 - 0
tests/Feature/ProductSKUControllerTest.php

@@ -3,6 +3,7 @@
 namespace Tests\Feature;
 
 use App\Jobs\ExportMafJob;
+use App\Jobs\ExportMafRegistryJob;
 use App\Models\Order;
 use App\Models\Permission;
 use App\Models\Product;
@@ -92,6 +93,14 @@ class ProductSKUControllerTest extends TestCase
         $response->assertStatus(403);
     }
 
+    public function test_manager_cannot_export_maf_registry(): void
+    {
+        $response = $this->actingAs($this->managerUser)
+            ->post(route('mafs.registry'), ['upd_number' => 'UPD-001']);
+
+        $response->assertStatus(403);
+    }
+
     // ==================== Index ====================
 
     public function test_admin_can_access_product_sku_index(): void
@@ -520,4 +529,28 @@ class ProductSKUControllerTest extends TestCase
         $response->assertSessionHas('success');
         Bus::assertDispatched(ExportMafJob::class);
     }
+
+    public function test_admin_can_export_maf_registry(): void
+    {
+        Bus::fake();
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('mafs.registry'), ['upd_number' => 'UPD-001']);
+
+        $response->assertRedirect(route('product_sku.index'));
+        $response->assertSessionHas('success');
+        Bus::assertDispatched(ExportMafRegistryJob::class);
+    }
+
+    public function test_assistant_head_can_export_maf_registry(): void
+    {
+        Bus::fake();
+
+        $response = $this->actingAs($this->assistantHeadUser)
+            ->post(route('mafs.registry'), ['upd_number' => 'UPD-002']);
+
+        $response->assertRedirect(route('product_sku.index'));
+        $response->assertSessionHas('success');
+        Bus::assertDispatched(ExportMafRegistryJob::class);
+    }
 }

+ 95 - 0
tests/Unit/Services/Export/ExportMafRegistryServiceTest.php

@@ -0,0 +1,95 @@
+<?php
+
+namespace Tests\Unit\Services\Export;
+
+use App\Models\File;
+use App\Models\Order;
+use App\Models\Product;
+use App\Models\ProductSKU;
+use App\Models\User;
+use App\Services\ExportMafRegistryService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Storage;
+use PhpOffice\PhpSpreadsheet\IOFactory;
+use Tests\TestCase;
+
+class ExportMafRegistryServiceTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected $seed = true;
+
+    public function test_handle_exports_matching_mafs_and_sets_upd_number(): void
+    {
+        if (!file_exists('./templates/MafRegistry.xlsx')) {
+            $this->markTestSkipped('Excel template MafRegistry.xlsx not found');
+        }
+
+        Storage::fake('public');
+
+        $user = User::factory()->create();
+        $year = (int) date('Y');
+        $order = Order::factory()->create([
+            'year' => $year,
+            'name' => 'Площадка 1',
+        ]);
+        $product = Product::factory()->create([
+            'year' => $year,
+            'article' => 'ART-001',
+            'nomenclature_number' => 'NOM-001',
+            'statement_name' => 'Наименование в ведомости',
+            'total_price' => 1234.56,
+        ]);
+
+        $targetSku = ProductSKU::factory()
+            ->forOrder($order)
+            ->forProduct($product)
+            ->create([
+                'year' => $year,
+                'statement_number' => 'STAT-001',
+                'statement_date' => '2026-05-19',
+                'upd_number' => null,
+            ]);
+        $blankStatementSku = ProductSKU::factory()->create([
+            'year' => $year,
+            'statement_number' => '   ',
+            'upd_number' => null,
+        ]);
+        $alreadyExportedSku = ProductSKU::factory()->create([
+            'year' => $year,
+            'statement_number' => 'STAT-002',
+            'upd_number' => 'OLD-UPD',
+        ]);
+
+        $link = (new ExportMafRegistryService())->handle($user->id, 'UPD-001', $year);
+
+        $this->assertStringContainsString('/storage/export/maf-registry/', $link);
+        $this->assertSame('UPD-001', $targetSku->fresh()->upd_number);
+        $this->assertNull($blankStatementSku->fresh()->upd_number);
+        $this->assertSame('OLD-UPD', $alreadyExportedSku->fresh()->upd_number);
+
+        $file = File::query()->where('user_id', $user->id)->latest()->firstOrFail();
+        $this->assertTrue($file->is_generated);
+        Storage::disk('public')->assertExists($file->path);
+
+        $spreadsheet = IOFactory::load(Storage::disk('public')->path($file->path));
+        $sheet = $spreadsheet->getActiveSheet();
+
+        $this->assertSame('НАШ ДВОР', $sheet->getTitle());
+        $this->assertSame('Реестр оборудования и ведомостей "технической приемки" к УПД UPD-001', $sheet->getCell('C2')->getValue());
+        $this->assertSame('НАШ ДВОР', $sheet->getCell('A4')->getValue());
+        $this->assertSame('UPD-001', $sheet->getCell('C4')->getValue());
+        $this->assertSame('NOM-001', $sheet->getCell('D4')->getValue());
+        $this->assertSame('Наименование в ведомости', $sheet->getCell('E4')->getValue());
+        $this->assertSame('ART-001', $sheet->getCell('F4')->getValue());
+        $this->assertSame('STAT-001', $sheet->getCell('G4')->getValue());
+        $this->assertSame('Площадка 1', $sheet->getCell('I4')->getValue());
+        $this->assertSame(1, $sheet->getCell('M4')->getValue());
+        $this->assertSame(0, $sheet->getCell('N4')->getValue());
+        $this->assertSame(1234.56, $sheet->getCell('O4')->getValue());
+        $this->assertSame('С бетоном', $sheet->getCell('P4')->getValue());
+        $this->assertSame('=SUM(M4:M4)', $sheet->getCell('M1')->getValue());
+        $this->assertSame(0, $sheet->getCell('N1')->getValue());
+        $this->assertSame('=SUM(O4:O4)', $sheet->getCell('O1')->getValue());
+    }
+}