Browse Source

Обновить формирование и нумерацию ТН

Alexander Musikhin 4 days ago
parent
commit
e48a51a2c1

+ 9 - 0
app/Http/Controllers/Admin/AdminSettingsController.php

@@ -29,6 +29,10 @@ class AdminSettingsController extends Controller
             'reclamationActRepresentativeUserId' => Setting::getInt(
                 Setting::KEY_RECLAMATION_ACT_REPRESENTATIVE_USER_ID
             ),
+            'ttnNextNumber' => Setting::getInt(
+                Setting::KEY_TTN_NEXT_NUMBER,
+                \App\Models\Ttn::getNextTtnNumber()
+            ),
         ]);
     }
 
@@ -37,6 +41,7 @@ class AdminSettingsController extends Controller
         $data = $request->validate([
             'default_maf_order_user_id' => ['nullable', 'integer', 'exists:users,id'],
             'reclamation_act_representative_user_id' => ['nullable', 'integer', 'exists:users,id'],
+            'ttn_next_number' => ['required', 'integer', 'min:1'],
         ]);
 
         Setting::set(
@@ -47,6 +52,10 @@ class AdminSettingsController extends Controller
             Setting::KEY_RECLAMATION_ACT_REPRESENTATIVE_USER_ID,
             $data['reclamation_act_representative_user_id'] ?? null
         );
+        Setting::set(
+            Setting::KEY_TTN_NEXT_NUMBER,
+            $data['ttn_next_number']
+        );
 
         return back()->with('success', 'Настройки сохранены.');
     }

+ 2 - 1
app/Http/Controllers/OrderController.php

@@ -671,10 +671,11 @@ class OrderController extends Controller
     {
         $ttn = Ttn::query()->create([
             'year' => date('Y'),
-            'ttn_number'    => Ttn::getTtnNumber() + 1,
+            'ttn_number'    => Ttn::reserveNextTtnNumber(),
             'ttn_number_suffix' => 'И',
             'order_number'  => $request->order_number,
             'order_date'    => $request->order_date,
+            'departure_date' => $request->departure_date,
             'order_sum'     => $request->order_sum,
             'skus'          => json_encode($request->skus),
         ]);

+ 1 - 0
app/Http/Requests/CreteTtnRequest.php

@@ -24,6 +24,7 @@ class CreteTtnRequest extends FormRequest
         return [
             'order_number'  => ['required', 'string'],
             'order_date'    => ['required', 'date'],
+            'departure_date' => ['required', 'date'],
             'order_sum'     => ['required', 'string'],
             'skus'          => ['required', 'array'],
         ];

+ 1 - 0
app/Models/Setting.php

@@ -8,6 +8,7 @@ class Setting extends Model
 {
     public const KEY_DEFAULT_MAF_ORDER_USER_ID = 'default_maf_order_user_id';
     public const KEY_RECLAMATION_ACT_REPRESENTATIVE_USER_ID = 'reclamation_act_representative_user_id';
+    public const KEY_TTN_NEXT_NUMBER = 'ttn_next_number';
 
     protected $fillable = [
         'key',

+ 27 - 2
app/Models/Ttn.php

@@ -4,6 +4,7 @@ namespace App\Models;
 
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Support\Facades\DB;
 
 class Ttn extends Model
 {
@@ -13,6 +14,7 @@ class Ttn extends Model
         'ttn_number_suffix',
         'order_number',
         'order_date',
+        'departure_date',
         'order_sum',
         'skus',
         'file_id',
@@ -23,8 +25,31 @@ class Ttn extends Model
         return $this->belongsTo(File::class);
     }
 
-    public static function getTtnNumber(): int
+    public static function getNextTtnNumber(): int
     {
-        return Ttn::query()->where('year', now()->year)->orderBy('ttn_number', 'desc')->first()?->ttn_number ?? 0;
+        return Setting::getInt(Setting::KEY_TTN_NEXT_NUMBER, static::detectNextTtnNumber()) ?? 1;
+    }
+
+    public static function reserveNextTtnNumber(): int
+    {
+        return DB::transaction(static function (): int {
+            $setting = Setting::query()
+                ->lockForUpdate()
+                ->firstOrNew(['key' => Setting::KEY_TTN_NEXT_NUMBER]);
+
+            $nextNumber = (int) ($setting->value ?: static::detectNextTtnNumber());
+
+            $setting->value = (string) ($nextNumber + 1);
+            $setting->save();
+
+            return $nextNumber;
+        });
+    }
+
+    private static function detectNextTtnNumber(): int
+    {
+        return (int) static::query()
+            ->where('year', now()->year)
+            ->max('ttn_number') + 1;
     }
 }

+ 2 - 1
app/Services/Export/ExportYearDataService.php

@@ -532,7 +532,7 @@ class ExportYearDataService
 
         $headers = [
             'id', 'ttn_number', 'ttn_number_suffix', 'order_number', 'order_date',
-            'order_sum', 'skus', 'file_path', 'created_at', 'updated_at'
+            'departure_date', 'order_sum', 'skus', 'file_path', 'created_at', 'updated_at'
         ];
 
         $this->writeRow($sheet, 1, $headers);
@@ -550,6 +550,7 @@ class ExportYearDataService
                 $ttn->ttn_number_suffix,
                 $ttn->order_number,
                 $ttn->order_date,
+                $ttn->departure_date,
                 $ttn->order_sum,
                 $ttn->skus,
                 $filePath,

+ 9 - 2
app/Services/GenerateDocumentsService.php

@@ -729,7 +729,8 @@ class GenerateDocumentsService
             $places += $sku->product->places;
         }
 
-        $installationDate = ($order->installation_date) ? DateHelper::getHumanDate($order->installation_date, true) : '-';
+        $departureDate = $ttn->departure_date ?? $order->installation_date;
+        $installationDate = $departureDate ? DateHelper::getHumanDate($departureDate, true) : '-';
         $ttnNumber = ($ttn->ttn_number_suffix) ? $ttn->ttn_number . '-' . $ttn->ttn_number_suffix : $ttn->ttn_number;
 
         $inputFileType = 'Xlsx';
@@ -774,7 +775,13 @@ class GenerateDocumentsService
         $sheet->setCellValue('AS89', $ttn->order_sum);
 
         // save file
-        $fileName = 'ТН №' . $ttn->ttn_number . ' от ' . DateHelper::getHumanDate($ttn->order_date) . '.xlsx';
+        $addressSuffix = trim((string) ($order->object_address ?? ''));
+        $safeAddressSuffix = str_replace(['\\', '/', ':', '*', '?', '"', '<', '>', '|'], ' ', $addressSuffix);
+        $fileName = 'ТН №' . $ttn->ttn_number . ' от ' . DateHelper::getHumanDate($departureDate ?? $ttn->order_date);
+        if ($safeAddressSuffix !== '') {
+            $fileName .= ' (' . preg_replace('/\s+/', ' ', $safeAddressSuffix) . ')';
+        }
+        $fileName .= '.xlsx';
         $writer = new Xlsx($spreadsheet);
         $fd = 'ttn/' . $ttn->year;
         Storage::disk('public')->makeDirectory($fd);

+ 1 - 0
app/Services/Import/ImportYearDataService.php

@@ -1011,6 +1011,7 @@ class ImportYearDataService
                 'ttn_number_suffix' => $this->getStringValue($row, $headerMap, 'ttn_number_suffix', ''),
                 'order_number' => $this->getStringValue($row, $headerMap, 'order_number', ''),
                 'order_date' => $this->getValue($row, $headerMap, 'order_date'),
+                'departure_date' => $this->getValue($row, $headerMap, 'departure_date'),
                 'order_sum' => $this->getNumericValue($row, $headerMap, 'order_sum', 0),
                 'skus' => $this->getValue($row, $headerMap, 'skus'),
                 'file_id' => $fileId,

+ 22 - 0
database/migrations/2026_04_21_120000_add_departure_date_to_ttns_table.php

@@ -0,0 +1,22 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::table('ttns', function (Blueprint $table) {
+            $table->date('departure_date')->nullable()->after('order_date');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('ttns', function (Blueprint $table) {
+            $table->dropColumn('departure_date');
+        });
+    }
+};

+ 14 - 0
resources/views/admin/settings/index.blade.php

@@ -23,6 +23,20 @@
             'value' => old('reclamation_act_representative_user_id', $reclamationActRepresentativeUserId),
             'first_empty' => true,
         ])
+        @include('partials.input', [
+            'name' => 'ttn_next_number',
+            'title' => 'Следующий номер ТН',
+            'type' => 'number',
+            'min' => 1,
+            'step' => 1,
+            'required' => true,
+            'value' => old('ttn_next_number', $ttnNextNumber),
+        ])
+        <div class="row mb-3">
+            <div class="offset-md-4 col-md-8">
+                <div class="form-text">Следующая созданная ТН получит именно этот номер.</div>
+            </div>
+        </div>
 
         @include('partials.submit', ['name' => 'Сохранить'])
     </form>

+ 12 - 3
resources/views/orders/show.blade.php

@@ -406,9 +406,17 @@
                         <form action="{{ route('order.create-ttn') }}" method="post" id="ttnForm">
                             @csrf
                             <div>
-                                <input type="text" class="form-control mb-2" name="order_number" placeholder="Номер заказа">
-                                <input type="date" class="form-control mb-2" name="order_date" placeholder="Дата заказа" value="{{ date('Y-m-d') }}">
-                                <input type="number" class="form-control mb-2" name="order_sum" placeholder="Сумма заказа" value="0">
+                                <label for="ttn_order_number" class="form-label">Номер заявки</label>
+                                <input type="text" class="form-control mb-3" id="ttn_order_number" name="order_number" placeholder="Номер заявки">
+
+                                <label for="ttn_order_date" class="form-label">Дата заявки</label>
+                                <input type="date" class="form-control mb-3" id="ttn_order_date" name="order_date" value="{{ date('Y-m-d') }}">
+
+                                <label for="ttn_departure_date" class="form-label">Дата выезда</label>
+                                <input type="date" class="form-control mb-3" id="ttn_departure_date" name="departure_date" value="{{ $order->installation_date ?? '' }}">
+
+                                <label for="ttn_order_sum" class="form-label">Сумма</label>
+                                <input type="number" class="form-control mb-3" id="ttn_order_sum" name="order_sum" placeholder="Сумма" value="0">
                                 <button type="button" class="btn btn-primary" id="createTtn">Создать ТН</button>
                             </div>
                         </form>
@@ -529,6 +537,7 @@
             });
 
             $('#createTtn').on('click', function () {
+                $('#ttnForm input[name="skus[]"]').remove();
                 let ids = Array();
                 $('.check-maf').each(function () {
                     if ($(this).prop('checked')) {

+ 51 - 0
tests/Feature/AdminSettingsControllerTest.php

@@ -0,0 +1,51 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Models\Role;
+use App\Models\Setting;
+use App\Models\User;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
+
+class AdminSettingsControllerTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected $seed = true;
+
+    private User $adminUser;
+    private User $managerUser;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->adminUser = User::factory()->create(['role' => Role::ADMIN]);
+        $this->managerUser = User::factory()->create(['role' => Role::MANAGER]);
+    }
+
+    public function test_admin_can_store_ttn_next_number_setting(): void
+    {
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('admin.settings.store'), [
+                'default_maf_order_user_id' => null,
+                'reclamation_act_representative_user_id' => null,
+                'ttn_next_number' => 25,
+            ]);
+
+        $response->assertRedirect();
+        $response->assertSessionHas('success');
+        $this->assertSame(25, Setting::getInt(Setting::KEY_TTN_NEXT_NUMBER));
+    }
+
+    public function test_manager_cannot_store_admin_settings(): void
+    {
+        $response = $this->actingAs($this->managerUser)
+            ->post(route('admin.settings.store'), [
+                'ttn_next_number' => 25,
+            ]);
+
+        $response->assertStatus(403);
+    }
+}

+ 44 - 0
tests/Feature/OrderControllerTest.php

@@ -3,6 +3,7 @@
 namespace Tests\Feature;
 
 use App\Jobs\GenerateInstallationPack;
+use App\Jobs\GenerateTtnPack;
 use App\Models\Dictionary\Area;
 use App\Models\Dictionary\District;
 use App\Models\File;
@@ -13,6 +14,8 @@ use App\Models\OrderStatus;
 use App\Models\Product;
 use App\Models\ProductSKU;
 use App\Models\Role;
+use App\Models\Setting;
+use App\Models\Ttn;
 use App\Models\User;
 use Illuminate\Foundation\Testing\RefreshDatabase;
 use Illuminate\Http\UploadedFile;
@@ -879,6 +882,47 @@ class OrderControllerTest extends TestCase
         Bus::assertNotDispatched(GenerateInstallationPack::class);
     }
 
+    public function test_admin_can_create_ttn_with_departure_date_and_increment_counter(): void
+    {
+        Bus::fake();
+
+        Setting::set(Setting::KEY_TTN_NEXT_NUMBER, 10);
+
+        $product = Product::factory()->create();
+        $order = Order::factory()->create([
+            'installation_date' => '2026-04-15',
+        ]);
+        $sku = ProductSKU::factory()->create([
+            'order_id' => $order->id,
+            'product_id' => $product->id,
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('order.create-ttn'), [
+                'order_number' => 'З-100',
+                'order_date' => '2026-04-07',
+                'departure_date' => '2026-04-08',
+                'order_sum' => '150000',
+                'skus' => [$sku->id],
+            ]);
+
+        $response->assertRedirect();
+        $response->assertSessionHas('success');
+
+        $this->assertDatabaseHas('ttns', [
+            'ttn_number' => 10,
+            'order_number' => 'З-100',
+            'order_date' => '2026-04-07',
+            'departure_date' => '2026-04-08',
+            'order_sum' => '150000',
+        ]);
+        $this->assertSame(11, Setting::getInt(Setting::KEY_TTN_NEXT_NUMBER));
+
+        $ttn = Ttn::query()->where('ttn_number', 10)->firstOrFail();
+        $this->assertSame([(string) $sku->id], array_map('strval', json_decode($ttn->skus, true)));
+        Bus::assertDispatched(GenerateTtnPack::class);
+    }
+
     // ==================== Export ====================
 
     public function test_can_export_orders(): void

+ 42 - 0
tests/Unit/Models/TtnTest.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace Tests\Unit\Models;
+
+use App\Models\Setting;
+use App\Models\Ttn;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
+
+class TtnTest extends TestCase
+{
+    use RefreshDatabase;
+
+    public function test_reserve_next_ttn_number_uses_existing_setting_and_increments_it(): void
+    {
+        Setting::set(Setting::KEY_TTN_NEXT_NUMBER, 10);
+
+        $reservedNumber = Ttn::reserveNextTtnNumber();
+
+        $this->assertSame(10, $reservedNumber);
+        $this->assertSame(11, Setting::getInt(Setting::KEY_TTN_NEXT_NUMBER));
+    }
+
+    public function test_reserve_next_ttn_number_falls_back_to_max_number_for_current_year(): void
+    {
+        Ttn::query()->create([
+            'year' => now()->year,
+            'ttn_number' => 13,
+            'ttn_number_suffix' => 'И',
+            'order_number' => 'З-13',
+            'order_date' => '2026-04-01',
+            'departure_date' => '2026-04-02',
+            'order_sum' => '1000',
+            'skus' => json_encode([1], JSON_THROW_ON_ERROR),
+        ]);
+
+        $reservedNumber = Ttn::reserveNextTtnNumber();
+
+        $this->assertSame(14, $reservedNumber);
+        $this->assertSame(15, Setting::getInt(Setting::KEY_TTN_NEXT_NUMBER));
+    }
+}

+ 43 - 0
tests/Unit/Services/GenerateDocumentsServiceTest.php

@@ -8,6 +8,7 @@ use App\Models\Order;
 use App\Models\Product;
 use App\Models\ProductSKU;
 use App\Models\Reclamation;
+use App\Models\Ttn;
 use App\Models\User;
 use App\Services\GenerateDocumentsService;
 use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -222,4 +223,46 @@ class GenerateDocumentsServiceTest extends TestCase
             $this->assertTrue(true);
         }
     }
+
+    public function test_generate_ttn_pack_uses_departure_date_and_address_in_filename(): void
+    {
+        if (!file_exists('./templates/Ttn.xlsx')) {
+            $this->markTestSkipped('Excel template Ttn.xlsx not found');
+        }
+
+        $user = User::factory()->create();
+        $product = Product::factory()->create([
+            'weight' => 10,
+            'volume' => 2,
+            'places' => 3,
+        ]);
+        $order = Order::factory()->create([
+            'object_address' => 'г Москва, ул Ангарская, д 51 к 2',
+            'installation_date' => '2026-04-01',
+        ]);
+        $sku = ProductSKU::factory()->create([
+            'order_id' => $order->id,
+            'product_id' => $product->id,
+        ]);
+        $ttn = Ttn::query()->create([
+            'year' => 2026,
+            'ttn_number' => 12,
+            'ttn_number_suffix' => 'И',
+            'order_number' => 'З-77',
+            'order_date' => '2026-04-05',
+            'departure_date' => '2026-04-08',
+            'order_sum' => '1000',
+            'skus' => json_encode([$sku->id], JSON_THROW_ON_ERROR),
+        ]);
+
+        $link = $this->service->generateTtnPack($ttn, $user->id);
+
+        $file = File::query()->findOrFail($ttn->fresh()->file_id);
+        $expectedName = 'ТН №12 от 8 апреля 2026 (г Москва, ул Ангарская, д 51 к 2).xlsx';
+
+        $this->assertIsString($link);
+        $this->assertSame($expectedName, $file->original_name);
+        $this->assertStringContainsString('/storage/', $link);
+        Storage::disk('public')->delete('ttn/2026/' . $expectedName);
+    }
 }