Kaynağa Gözat

backfill for paid status

Alexander Musikhin 1 hafta önce
ebeveyn
işleme
87b8a97442

+ 64 - 0
app/Console/Commands/BackfillPaidOrderStatuses.php

@@ -0,0 +1,64 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Console\Commands;
+
+use App\Jobs\BackfillPaidOrderStatusesJob;
+use App\Services\OrderPaymentStatusService;
+use Illuminate\Console\Command;
+
+class BackfillPaidOrderStatuses extends Command
+{
+    protected $signature = 'orders:backfill-paid-statuses
+                            {--year= : Год площадок для обработки}
+                            {--chunk=500 : Размер чанка}
+                            {--sync : Выполнить сразу без постановки в очередь}';
+
+    protected $description = 'Запускает backfill статуса "Оплачено" для площадок с заполненными ведомостью и УПД по всем МАФ';
+
+    public function handle(OrderPaymentStatusService $paymentStatusService): int
+    {
+        $year = $this->yearOption();
+        $chunkSize = $this->chunkSizeOption();
+
+        if ($chunkSize < 1) {
+            $this->error('Размер чанка должен быть больше 0.');
+            return self::FAILURE;
+        }
+
+        if ((bool) $this->option('sync')) {
+            (new BackfillPaidOrderStatusesJob($year, $chunkSize))->handle($paymentStatusService);
+            $this->info('Backfill статуса "Оплачено" выполнен.');
+
+            return self::SUCCESS;
+        }
+
+        BackfillPaidOrderStatusesJob::dispatch($year, $chunkSize);
+        $this->info('Backfill статуса "Оплачено" поставлен в очередь.');
+
+        return self::SUCCESS;
+    }
+
+    private function yearOption(): ?int
+    {
+        $year = $this->option('year');
+
+        if ($year === null || $year === '') {
+            return null;
+        }
+
+        return (int) $year;
+    }
+
+    private function chunkSizeOption(): int
+    {
+        $chunk = $this->option('chunk');
+
+        if (!is_numeric($chunk)) {
+            return 0;
+        }
+
+        return (int) $chunk;
+    }
+}

+ 49 - 0
app/Jobs/BackfillPaidOrderStatusesJob.php

@@ -0,0 +1,49 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Services\OrderPaymentStatusService;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Queue\Queueable;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\Log;
+
+class BackfillPaidOrderStatusesJob implements ShouldQueue
+{
+    use Queueable;
+
+    public int $timeout = 1200;
+
+    public int $tries = 1;
+
+    public function __construct(
+        private readonly ?int $year = null,
+        private readonly int $chunkSize = 500,
+    ) {
+    }
+
+    public function handle(OrderPaymentStatusService $paymentStatusService): void
+    {
+        $checked = 0;
+        $markedPaid = 0;
+
+        $paymentStatusService
+            ->paidBackfillCandidatesQuery($this->year)
+            ->select('orders.id')
+            ->chunkById($this->chunkSize, function (Collection $orders) use ($paymentStatusService, &$checked, &$markedPaid): void {
+                foreach ($orders as $order) {
+                    $checked++;
+
+                    if ($paymentStatusService->markPaidIfAllMafsHavePaymentData((int) $order->id)) {
+                        $markedPaid++;
+                    }
+                }
+            }, 'orders.id', 'id');
+
+        Log::info('Paid order statuses backfill finished.', [
+            'year' => $this->year,
+            'checked' => $checked,
+            'marked_paid' => $markedPaid,
+        ]);
+    }
+}

+ 23 - 0
app/Services/OrderPaymentStatusService.php

@@ -5,9 +5,32 @@ namespace App\Services;
 use App\Models\Order;
 use App\Models\ProductSKU;
 use App\Models\Scopes\YearScope;
+use Illuminate\Database\Eloquent\Builder;
 
 class OrderPaymentStatusService
 {
+    public function paidBackfillCandidatesQuery(?int $year = null): Builder
+    {
+        return Order::query()
+            ->withoutGlobalScope(YearScope::class)
+            ->when($year !== null, fn (Builder $query): Builder => $query->where('year', $year))
+            ->where('order_status_id', '<>', Order::STATUS_PAID)
+            ->whereHas('products_sku', function (Builder $query): void {
+                $query->withoutGlobalScope(YearScope::class);
+            })
+            ->whereDoesntHave('products_sku', function (Builder $query): void {
+                $query
+                    ->withoutGlobalScope(YearScope::class)
+                    ->where(function (Builder $query): void {
+                        $query
+                            ->whereNull('statement_number')
+                            ->orWhereRaw("TRIM(statement_number) = ''")
+                            ->orWhereNull('upd_number')
+                            ->orWhereRaw("TRIM(upd_number) = ''");
+                    });
+            });
+    }
+
     public function markPaidIfAllMafsHavePaymentData(Order|int|null $order): bool
     {
         if ($order === null) {

+ 64 - 0
tests/Feature/BackfillPaidOrderStatusesCommandTest.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Jobs\BackfillPaidOrderStatusesJob;
+use App\Models\Order;
+use App\Models\ProductSKU;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Artisan;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class BackfillPaidOrderStatusesCommandTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected $seed = true;
+
+    public function test_command_dispatches_backfill_job(): void
+    {
+        Queue::fake();
+
+        $exitCode = Artisan::call('orders:backfill-paid-statuses', [
+            '--year' => 2026,
+            '--chunk' => 250,
+        ]);
+
+        $this->assertSame(0, $exitCode);
+        Queue::assertPushed(BackfillPaidOrderStatusesJob::class);
+    }
+
+    public function test_command_can_run_backfill_synchronously(): void
+    {
+        $order = Order::factory()->create([
+            'year' => 2026,
+            'order_status_id' => Order::STATUS_HANDED_OVER,
+        ]);
+        ProductSKU::factory()->forOrder($order)->create([
+            'year' => 2026,
+            'statement_number' => 'ST-1',
+            'upd_number' => 'UPD-1',
+        ]);
+
+        $exitCode = Artisan::call('orders:backfill-paid-statuses', [
+            '--year' => 2026,
+            '--sync' => true,
+        ]);
+
+        $this->assertSame(0, $exitCode);
+        $this->assertSame(Order::STATUS_PAID, $order->refresh()->order_status_id);
+    }
+
+    public function test_command_fails_when_chunk_size_is_invalid(): void
+    {
+        Queue::fake();
+
+        $exitCode = Artisan::call('orders:backfill-paid-statuses', [
+            '--chunk' => 0,
+        ]);
+
+        $this->assertSame(1, $exitCode);
+        Queue::assertNothingPushed();
+    }
+}

+ 98 - 0
tests/Unit/Jobs/BackfillPaidOrderStatusesJobTest.php

@@ -0,0 +1,98 @@
+<?php
+
+namespace Tests\Unit\Jobs;
+
+use App\Jobs\BackfillPaidOrderStatusesJob;
+use App\Models\Order;
+use App\Models\ProductSKU;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Bus;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class BackfillPaidOrderStatusesJobTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected $seed = true;
+
+    public function test_job_can_be_dispatched(): void
+    {
+        Bus::fake();
+
+        BackfillPaidOrderStatusesJob::dispatch(2026);
+
+        Bus::assertDispatched(BackfillPaidOrderStatusesJob::class);
+    }
+
+    public function test_job_is_queued_via_queue_fake(): void
+    {
+        Queue::fake();
+
+        BackfillPaidOrderStatusesJob::dispatch();
+
+        Queue::assertPushed(BackfillPaidOrderStatusesJob::class);
+    }
+
+    public function test_job_marks_only_eligible_orders_as_paid(): void
+    {
+        $eligibleOrder = Order::factory()->create([
+            'year' => 2026,
+            'order_status_id' => Order::STATUS_HANDED_OVER,
+        ]);
+        ProductSKU::factory()->forOrder($eligibleOrder)->create([
+            'year' => 2026,
+            'statement_number' => 'ST-1',
+            'upd_number' => 'UPD-1',
+        ]);
+
+        $incompleteOrder = Order::factory()->create([
+            'year' => 2026,
+            'order_status_id' => Order::STATUS_HANDED_OVER,
+        ]);
+        ProductSKU::factory()->forOrder($incompleteOrder)->create([
+            'year' => 2026,
+            'statement_number' => 'ST-2',
+            'upd_number' => null,
+        ]);
+
+        $emptyOrder = Order::factory()->create([
+            'year' => 2026,
+            'order_status_id' => Order::STATUS_HANDED_OVER,
+        ]);
+
+        (new BackfillPaidOrderStatusesJob(year: 2026, chunkSize: 1))->handle(app(\App\Services\OrderPaymentStatusService::class));
+
+        $this->assertSame(Order::STATUS_PAID, $eligibleOrder->refresh()->order_status_id);
+        $this->assertSame(Order::STATUS_HANDED_OVER, $incompleteOrder->refresh()->order_status_id);
+        $this->assertSame(Order::STATUS_HANDED_OVER, $emptyOrder->refresh()->order_status_id);
+    }
+
+    public function test_job_respects_year_filter(): void
+    {
+        $currentYearOrder = Order::factory()->create([
+            'year' => 2026,
+            'order_status_id' => Order::STATUS_HANDED_OVER,
+        ]);
+        ProductSKU::factory()->forOrder($currentYearOrder)->create([
+            'year' => 2026,
+            'statement_number' => 'ST-1',
+            'upd_number' => 'UPD-1',
+        ]);
+
+        $otherYearOrder = Order::factory()->create([
+            'year' => 2025,
+            'order_status_id' => Order::STATUS_HANDED_OVER,
+        ]);
+        ProductSKU::factory()->forOrder($otherYearOrder)->create([
+            'year' => 2025,
+            'statement_number' => 'ST-2',
+            'upd_number' => 'UPD-2',
+        ]);
+
+        (new BackfillPaidOrderStatusesJob(year: 2026))->handle(app(\App\Services\OrderPaymentStatusService::class));
+
+        $this->assertSame(Order::STATUS_PAID, $currentYearOrder->refresh()->order_status_id);
+        $this->assertSame(Order::STATUS_HANDED_OVER, $otherYearOrder->refresh()->order_status_id);
+    }
+}

+ 104 - 0
tests/Unit/Services/OrderPaymentStatusServiceTest.php

@@ -0,0 +1,104 @@
+<?php
+
+namespace Tests\Unit\Services;
+
+use App\Models\Order;
+use App\Models\ProductSKU;
+use App\Services\OrderPaymentStatusService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
+
+class OrderPaymentStatusServiceTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected $seed = true;
+
+    public function test_marks_order_paid_when_all_mafs_have_payment_data(): void
+    {
+        $order = Order::factory()->create(['order_status_id' => Order::STATUS_HANDED_OVER]);
+
+        ProductSKU::factory()->forOrder($order)->create([
+            'statement_number' => ' ST-1 ',
+            'upd_number' => ' UPD-1 ',
+        ]);
+        ProductSKU::factory()->forOrder($order)->create([
+            'statement_number' => 'ST-2',
+            'upd_number' => 'UPD-2',
+        ]);
+
+        $result = app(OrderPaymentStatusService::class)->markPaidIfAllMafsHavePaymentData($order);
+
+        $this->assertTrue($result);
+        $this->assertSame(Order::STATUS_PAID, $order->refresh()->order_status_id);
+    }
+
+    public function test_does_not_mark_paid_when_any_payment_field_is_blank(): void
+    {
+        $order = Order::factory()->create(['order_status_id' => Order::STATUS_HANDED_OVER]);
+
+        ProductSKU::factory()->forOrder($order)->create([
+            'statement_number' => 'ST-1',
+            'upd_number' => 'UPD-1',
+        ]);
+        ProductSKU::factory()->forOrder($order)->create([
+            'statement_number' => '   ',
+            'upd_number' => 'UPD-2',
+        ]);
+
+        $result = app(OrderPaymentStatusService::class)->markPaidIfAllMafsHavePaymentData($order);
+
+        $this->assertFalse($result);
+        $this->assertSame(Order::STATUS_HANDED_OVER, $order->refresh()->order_status_id);
+    }
+
+    public function test_does_not_mark_paid_without_mafs(): void
+    {
+        $order = Order::factory()->create(['order_status_id' => Order::STATUS_HANDED_OVER]);
+
+        $result = app(OrderPaymentStatusService::class)->markPaidIfAllMafsHavePaymentData($order);
+
+        $this->assertFalse($result);
+        $this->assertSame(Order::STATUS_HANDED_OVER, $order->refresh()->order_status_id);
+    }
+
+    public function test_backfill_candidates_query_returns_only_eligible_orders(): void
+    {
+        $eligibleOrder = Order::factory()->create([
+            'year' => 2026,
+            'order_status_id' => Order::STATUS_HANDED_OVER,
+        ]);
+        ProductSKU::factory()->forOrder($eligibleOrder)->create([
+            'year' => 2026,
+            'statement_number' => 'ST-1',
+            'upd_number' => 'UPD-1',
+        ]);
+
+        $paidOrder = Order::factory()->create([
+            'year' => 2026,
+            'order_status_id' => Order::STATUS_PAID,
+        ]);
+        ProductSKU::factory()->forOrder($paidOrder)->create([
+            'year' => 2026,
+            'statement_number' => 'ST-2',
+            'upd_number' => 'UPD-2',
+        ]);
+
+        $incompleteOrder = Order::factory()->create([
+            'year' => 2026,
+            'order_status_id' => Order::STATUS_HANDED_OVER,
+        ]);
+        ProductSKU::factory()->forOrder($incompleteOrder)->create([
+            'year' => 2026,
+            'statement_number' => 'ST-3',
+            'upd_number' => null,
+        ]);
+
+        $candidateIds = app(OrderPaymentStatusService::class)
+            ->paidBackfillCandidatesQuery(2026)
+            ->pluck('id')
+            ->all();
+
+        $this->assertSame([$eligibleOrder->id], $candidateIds);
+    }
+}