chengwl 1 день назад
Родитель
Сommit
0e204550e0

Разница между файлами не показана из-за своего большого размера
+ 1070 - 37
app/Console/Commands/MigrateAsteriaOrders.php


+ 246 - 0
app/Services/Asteria/Magento1OrderReader.php

@@ -39,7 +39,18 @@ class Magento1OrderReader
         'base_shipping_tax_amount',
         'shipping_method',
         'shipping_description',
+        'shipping_discount_amount',
+        'base_shipping_discount_amount',
+        'remote_ip',
+        'x_forwarded_for',
         'coupon_code',
+        'giftcard_id',
+        'giftcard_amount',
+        'base_giftcard_amount',
+        'member_amount',
+        'base_member_amount',
+        'member_free_amount',
+        'base_member_free_amount',
         'mw_rewardpoint',
         'mw_rewardpoint_discount',
         'mw_rewardpoint_discount_show',
@@ -100,6 +111,43 @@ class Magento1OrderReader
         'discount_percent',
         'weight',
         'row_weight',
+        'product_options',
+        'created_at',
+    ];
+
+    private const SHIPMENT_COLUMNS = [
+        'entity_id',
+        'order_id',
+        'increment_id',
+        'total_qty',
+        'total_weight',
+        'email_sent',
+        'customer_id',
+        'created_at',
+        'updated_at',
+    ];
+
+    private const SHIPMENT_ITEM_COLUMNS = [
+        'entity_id',
+        'parent_id',
+        'order_item_id',
+        'product_id',
+        'sku',
+        'name',
+        'description',
+        'qty',
+        'weight',
+        'price',
+        'row_total',
+    ];
+
+    private const SHIPMENT_TRACK_COLUMNS = [
+        'entity_id',
+        'parent_id',
+        'order_id',
+        'track_number',
+        'title',
+        'carrier_code',
         'created_at',
     ];
 
@@ -128,6 +176,10 @@ class Magento1OrderReader
         'cc_last4',
         'amount_ordered',
         'base_amount_ordered',
+        'additional_information',
+        'protection_eligibility',
+        'account_status',
+        'address_status',
     ];
 
     private Magento1Schema $schema;
@@ -186,11 +238,100 @@ class Magento1OrderReader
             $item['parent_item_id'] = isset($row->parent_item_id) && $row->parent_item_id !== null && $row->parent_item_id !== ''
                 ? (int) $row->parent_item_id
                 : null;
+            $item['product_id'] = isset($row->product_id) && $row->product_id !== null && $row->product_id !== ''
+                ? (int) $row->product_id
+                : null;
+
+            return $item;
+        })->values();
+    }
+
+    /**
+     * @param  array<int, int>  $itemIds
+     * @return Collection<int, array<string, mixed>>
+     */
+    public function fetchItemsByItemIds(array $itemIds): Collection
+    {
+        $itemIds = array_values(array_unique(array_filter(array_map('intval', $itemIds))));
+
+        if ($itemIds === []) {
+            return collect();
+        }
+
+        $columns = $this->schema->existingColumns('sales_flat_order_item', self::ITEM_COLUMNS);
+
+        if ($columns === [] || ! in_array('item_id', $columns, true)) {
+            return collect();
+        }
+
+        $rows = collect();
+
+        foreach (array_chunk($itemIds, 500) as $chunk) {
+            $rows = $rows->concat(
+                DB::connection($this->connection)
+                    ->table('sales_flat_order_item')
+                    ->whereIn('item_id', $chunk)
+                    ->orderBy('item_id')
+                    ->get($columns)
+            );
+        }
+
+        return $rows->map(function ($row) {
+            $item = $this->toArray($row, 'item_id');
+            $item['order_id'] = (int) ($row->order_id ?? 0);
+            $item['parent_item_id'] = isset($row->parent_item_id) && $row->parent_item_id !== null && $row->parent_item_id !== ''
+                ? (int) $row->parent_item_id
+                : null;
+            $item['product_id'] = isset($row->product_id) && $row->product_id !== null && $row->product_id !== ''
+                ? (int) $row->product_id
+                : null;
 
             return $item;
         })->values();
     }
 
+    /**
+     * Magento catalog SKUs keyed by catalog_product_entity.entity_id.
+     *
+     * @param  array<int, int>  $productIds
+     * @return array<int, string>
+     */
+    public function fetchCatalogSkus(array $productIds): array
+    {
+        $productIds = array_values(array_unique(array_filter(array_map('intval', $productIds))));
+
+        if ($productIds === [] || ! $this->schema->hasTable('catalog_product_entity')) {
+            return [];
+        }
+
+        $columns = $this->schema->existingColumns('catalog_product_entity', ['entity_id', 'sku']);
+
+        if ($columns === [] || ! in_array('entity_id', $columns, true) || ! in_array('sku', $columns, true)) {
+            return [];
+        }
+
+        $map = [];
+
+        foreach (array_chunk($productIds, 500) as $chunk) {
+            foreach (
+                DB::connection($this->connection)
+                    ->table('catalog_product_entity')
+                    ->whereIn('entity_id', $chunk)
+                    ->get($columns) as $row
+            ) {
+                $sku = trim((string) ($row->sku ?? ''));
+
+                if ($sku === '') {
+                    continue;
+                }
+
+                $map[(int) $row->entity_id] = $sku;
+            }
+        }
+
+        return $map;
+    }
+
     /**
      * @param  array<int, int>  $orderEntityIds
      * @return Collection<int, array<string, mixed>>
@@ -288,6 +429,111 @@ class Magento1OrderReader
         })->values();
     }
 
+    /**
+     * @param  array<int, int>  $orderEntityIds
+     * @return Collection<int, array<string, mixed>>
+     */
+    public function fetchShipments(array $orderEntityIds): Collection
+    {
+        if ($orderEntityIds === [] || ! $this->schema->hasTable('sales_flat_shipment')) {
+            return collect();
+        }
+
+        $columns = $this->schema->existingColumns('sales_flat_shipment', self::SHIPMENT_COLUMNS);
+
+        if ($columns === [] || ! in_array('order_id', $columns, true)) {
+            return collect();
+        }
+
+        $rows = DB::connection($this->connection)
+            ->table('sales_flat_shipment')
+            ->whereIn('order_id', $orderEntityIds)
+            ->orderBy('entity_id')
+            ->get($columns);
+
+        return $rows->map(function ($row) {
+            $shipment = $this->toArray($row, 'entity_id');
+            $shipment['order_id'] = (int) ($row->order_id ?? 0);
+
+            return $shipment;
+        })->values();
+    }
+
+    /**
+     * @param  array<int, int>  $shipmentIds
+     * @return Collection<int, array<string, mixed>>
+     */
+    public function fetchShipmentItems(array $shipmentIds): Collection
+    {
+        if ($shipmentIds === [] || ! $this->schema->hasTable('sales_flat_shipment_item')) {
+            return collect();
+        }
+
+        $columns = $this->schema->existingColumns('sales_flat_shipment_item', self::SHIPMENT_ITEM_COLUMNS);
+
+        if ($columns === [] || ! in_array('parent_id', $columns, true)) {
+            return collect();
+        }
+
+        $rows = collect();
+
+        foreach (array_chunk($shipmentIds, 500) as $chunk) {
+            $rows = $rows->concat(
+                DB::connection($this->connection)
+                    ->table('sales_flat_shipment_item')
+                    ->whereIn('parent_id', $chunk)
+                    ->orderBy('entity_id')
+                    ->get($columns)
+            );
+        }
+
+        return $rows->map(function ($row) {
+            $item = $this->toArray($row, 'entity_id');
+            $item['parent_id'] = (int) ($row->parent_id ?? 0);
+            $item['order_item_id'] = isset($row->order_item_id) && $row->order_item_id !== null && $row->order_item_id !== ''
+                ? (int) $row->order_item_id
+                : null;
+
+            return $item;
+        })->values();
+    }
+
+    /**
+     * @param  array<int, int>  $shipmentIds
+     * @return Collection<int, array<string, mixed>>
+     */
+    public function fetchShipmentTracks(array $shipmentIds): Collection
+    {
+        if ($shipmentIds === [] || ! $this->schema->hasTable('sales_flat_shipment_track')) {
+            return collect();
+        }
+
+        $columns = $this->schema->existingColumns('sales_flat_shipment_track', self::SHIPMENT_TRACK_COLUMNS);
+
+        if ($columns === [] || ! in_array('parent_id', $columns, true)) {
+            return collect();
+        }
+
+        $rows = collect();
+
+        foreach (array_chunk($shipmentIds, 500) as $chunk) {
+            $rows = $rows->concat(
+                DB::connection($this->connection)
+                    ->table('sales_flat_shipment_track')
+                    ->whereIn('parent_id', $chunk)
+                    ->orderBy('entity_id')
+                    ->get($columns)
+            );
+        }
+
+        return $rows->map(function ($row) {
+            $track = $this->toArray($row, 'entity_id');
+            $track['parent_id'] = (int) ($row->parent_id ?? 0);
+
+            return $track;
+        })->values();
+    }
+
     /**
      * @return array<string, mixed>
      */

+ 26 - 0
database/migrations/2026_09_07_155700_add_remote_ip_to_orders_table.php

@@ -0,0 +1,26 @@
+<?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
+    {
+        if (! Schema::hasColumn('orders', 'remote_ip')) {
+            Schema::table('orders', function (Blueprint $table) {
+                $table->string('remote_ip', 45)->nullable()->after('customer_last_name');
+            });
+        }
+    }
+
+    public function down(): void
+    {
+        if (Schema::hasColumn('orders', 'remote_ip')) {
+            Schema::table('orders', function (Blueprint $table) {
+                $table->dropColumn('remote_ip');
+            });
+        }
+    }
+};

+ 28 - 0
database/migrations/2026_09_07_155800_add_asteria_migration_column_to_shipments_table.php

@@ -0,0 +1,28 @@
+<?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
+    {
+        if (! Schema::hasColumn('shipments', 'migrated_from_asteria_id')) {
+            Schema::table('shipments', function (Blueprint $table) {
+                $table->unsignedBigInteger('migrated_from_asteria_id')->nullable()->after('id');
+                $table->unique('migrated_from_asteria_id', 'uq_shipments_migrated_asteria_id');
+            });
+        }
+    }
+
+    public function down(): void
+    {
+        if (Schema::hasColumn('shipments', 'migrated_from_asteria_id')) {
+            Schema::table('shipments', function (Blueprint $table) {
+                $table->dropUnique('uq_shipments_migrated_asteria_id');
+                $table->dropColumn('migrated_from_asteria_id');
+            });
+        }
+    }
+};

+ 14 - 3
packages/Longyi/Gift/src/Resources/views/sales/orders/view.blade.php

@@ -149,12 +149,17 @@
                                     @endif
 
                                     <div class="grid place-content-start gap-1.5">
+                                        
                                         <p
                                             class="text-base font-semibold text-gray-800 break-all dark:text-white"
                                             v-pre
                                         >
                                             {{ $item->name }}
                                         </p>
+                                        <p> 
+                                            Product ID:({{ $item->product_id??'' }})
+                                           
+                                        </p>
 
                                         <div class="flex flex-col place-items-start gap-1.5">
                                             <p class="text-gray-600 dark:text-gray-300">
@@ -235,14 +240,14 @@
                                             </p>
                                         @endif
 
-                                        <p class="text-gray-600 dark:text-gray-300">
+                                        <!-- <p class="text-gray-600 dark:text-gray-300">
                                             @lang('admin::app.sales.orders.view.tax', [
                                                 'percent' => number_format($item->tax_percent, 2) . '%',
                                                 'tax'     => core()->formatBasePrice($item->base_tax_amount)
                                             ])
-                                        </p>
+                                        </p> -->
 
-                                        @if ($order->base_discount_amount > 0)
+                                        @if ($item->base_discount_amount > 0)
                                             <p class="text-gray-600 dark:text-gray-300">
                                                 @lang('admin::app.sales.orders.view.discount', ['discount' => core()->formatBasePrice($item->base_discount_amount)])
                                             </p>
@@ -397,6 +402,9 @@
                             <div class="flex justify-between w-full gap-x-5">
                                 <p class="!leading-5 text-gray-600 dark:text-gray-300">
                                     @lang('admin::app.sales.orders.view.summary-discount')
+                                    <span>
+                                        ({{ $order->coupon_code ?? '' }})
+                                    </span>
                                 </p>
 
                                 <p class="!leading-5 text-gray-600 dark:text-gray-300">
@@ -623,6 +631,9 @@
                                 >
                                     @lang('admin::app.sales.orders.view.customer-group') : {{ $order->is_guest ? core()->getGuestCustomerGroup()?->name : ($order->customer->group->name ?? '') }}
                                 </p>
+                                <p class="text-gray-600 dark:text-gray-300"> 
+                                    {{ $order->is_guest ? 'not login':'login' }}
+                                </p>
 
                                 {!! view_render_event('bagisto.admin.sales.order.customer_group.after', ['order' => $order]) !!}
                             </div>

+ 2 - 1
packages/Webkul/Admin/src/Resources/views/sales/orders/view.blade.php

@@ -391,13 +391,14 @@
                             {!! view_render_event('bagisto.admin.sales.order.view.discount.before') !!}
 
                             <!-- Discount -->
-                            <div class="flex justify-between w-full gap-x-5">
+                            <div class="flex justify-between w-full gap-x-5 222">
                                 <p class="!leading-5 text-gray-600 dark:text-gray-300">
                                     @lang('admin::app.sales.orders.view.summary-discount')
                                 </p>
 
                                 <p class="!leading-5 text-gray-600 dark:text-gray-300">
                                     {{ core()->formatBasePrice($order->base_discount_amount) }}
+                                    ({{ $order->coupon_code ?? '' }})
                                 </p>
                             </div>
 

+ 78 - 0
packages/Webkul/Admin/tests/Feature/Sales/OrderPaypalPaymentDetailsTest.php

@@ -0,0 +1,78 @@
+<?php
+
+use Webkul\Paypal\Helpers\PaypalPaymentDetails;
+use Webkul\Sales\Models\Order;
+use Webkul\Sales\Models\OrderPayment;
+
+use function Pest\Laravel\get;
+
+it('maps magento paypal additional labels for the admin order view', function () {
+    $rows = PaypalPaymentDetails::rows([
+        'paypal' => [
+            'Payer ID'                        => 'S856CN296A3NY',
+            'Payer Email'                     => 'ms.karlotta@gmail.com',
+            'Payer Status'                    => 'unverified',
+            'Payer Address Status'            => 'confirmed',
+            'Merchant Protection Eligibility' => 'Eligible',
+            'Last Correlation ID'             => '4daa479620025',
+            'Last Transaction ID'             => '9L903472H6583463Y',
+        ],
+    ]);
+
+    expect($rows)->toHaveCount(7)
+        ->and($rows[0])->toMatchArray([
+            'label' => trans('paypal::app.order.payer-id'),
+            'value' => 'S856CN296A3NY',
+        ])
+        ->and($rows[1]['value'])->toBe('ms.karlotta@gmail.com')
+        ->and($rows[6]['value'])->toBe('9L903472H6583463Y');
+});
+
+it('falls back to paypal_* keys when the labeled paypal map is missing', function () {
+    $rows = PaypalPaymentDetails::rows([
+        'paypal_payer_id'    => 'S856CN296A3NY',
+        'paypal_payer_email' => 'ms.karlotta@gmail.com',
+        'last_trans_id'      => '9L903472H6583463Y',
+        'asteria_payment_id' => 99,
+    ]);
+
+    expect(collect($rows)->pluck('value')->all())->toBe([
+        'S856CN296A3NY',
+        'ms.karlotta@gmail.com',
+        '9L903472H6583463Y',
+    ]);
+});
+
+it('should display paypal additional payment details on the admin order view', function () {
+    $order = Order::factory()->create();
+
+    OrderPayment::factory()->create([
+        'order_id'   => $order->id,
+        'method'     => 'paypal_standard',
+        'additional' => [
+            'magento_method' => 'paypal_express',
+            'last_trans_id'  => '9L903472H6583463Y',
+            'paypal'         => [
+                'Payer ID'                        => 'S856CN296A3NY',
+                'Payer Email'                     => 'ms.karlotta@gmail.com',
+                'Payer Status'                    => 'unverified',
+                'Payer Address Status'            => 'confirmed',
+                'Merchant Protection Eligibility' => 'Eligible',
+                'Last Correlation ID'             => '4daa479620025',
+                'Last Transaction ID'             => '9L903472H6583463Y',
+            ],
+        ],
+    ]);
+
+    $this->loginAsAdmin();
+
+    get(route('admin.sales.orders.view', $order->id))
+        ->assertOk()
+        ->assertSeeText(trans('paypal::app.order.payer-id'))
+        ->assertSeeText('S856CN296A3NY')
+        ->assertSeeText('ms.karlotta@gmail.com')
+        ->assertSeeText('unverified')
+        ->assertSeeText('Eligible')
+        ->assertSeeText('4daa479620025')
+        ->assertSeeText('9L903472H6583463Y');
+});

+ 2 - 2
packages/Webkul/BagistoApi/tests/Unit/Migration/Magento1CustomerReaderTest.php

@@ -40,7 +40,7 @@ class Magento1CustomerReaderTest extends TestCase
             'password_hash' => md5('abcsecret12').':abc',
             'is_active'     => 1,
             'group_id'      => 99,
-            'source'        => 20,
+            'source'        => 'popup',
         ]);
         MagentoSchema::seedAddress($this->connection, [
             'entity_id'  => 21,
@@ -84,7 +84,7 @@ class Magento1CustomerReaderTest extends TestCase
         $this->assertSame('1', (string) $customer['gender']);
         $this->assertSame('1990-05-01 00:00:00', $customer['dob']);
         $this->assertSame(md5('abcsecret12').':abc', $customer['password_hash']);
-        $this->assertSame('20', (string) $customer['source']);
+        $this->assertSame('popup', (string) $customer['source']);
 
         $addresses = $reader->fetchAddresses([10]);
         $this->assertCount(1, $addresses);

+ 48 - 0
packages/Webkul/BagistoApi/tests/Unit/Migration/Magento1OrderReaderTest.php

@@ -102,12 +102,17 @@ class Magento1OrderReaderTest extends TestCase
         $this->assertSame('jane@example.com', $order['customer_email']);
         $this->assertSame('complete', $order['status']);
         $this->assertEquals(110, $order['grand_total']);
+        $this->assertArrayHasKey('remote_ip', $order);
+        $this->assertArrayHasKey('giftcard_amount', $order);
+        $this->assertArrayHasKey('member_amount', $order);
+        $this->assertArrayHasKey('member_free_amount', $order);
 
         $items = $reader->fetchItems([10]);
         $this->assertCount(1, $items);
         $this->assertSame(101, $items->first()['item_id']);
         $this->assertSame('WIG-001', $items->first()['sku']);
         $this->assertNull($items->first()['parent_item_id']);
+        $this->assertArrayHasKey('product_options', $items->first());
 
         $addresses = $reader->fetchAddresses([10]);
         $this->assertCount(2, $addresses);
@@ -117,6 +122,7 @@ class Magento1OrderReaderTest extends TestCase
         $this->assertCount(1, $payments);
         $this->assertSame('paypal_express', $payments->first()['method']);
         $this->assertSame('ABC123', $payments->first()['last_trans_id']);
+        $this->assertArrayHasKey('additional_information', $payments->first());
     }
 
     public function test_it_pages_from_the_last_entity_id(): void
@@ -133,4 +139,46 @@ class Magento1OrderReaderTest extends TestCase
         $this->assertCount(1, $page);
         $this->assertSame('100000011', $page->first()['increment_id']);
     }
+
+    public function test_it_reads_shipments_items_and_tracks(): void
+    {
+        MagentoSchema::seedShipment($this->connection, [
+            'entity_id'    => 501,
+            'order_id'     => 10,
+            'increment_id' => '100000501',
+            'total_qty'    => 1,
+            'items'        => [
+                [
+                    'entity_id'     => 601,
+                    'order_item_id' => 101,
+                    'sku'           => 'WIG-001',
+                    'qty'           => 1,
+                ],
+            ],
+            'tracks' => [
+                [
+                    'entity_id'    => 701,
+                    'track_number' => 'JD014',
+                    'title'        => 'DHL',
+                    'carrier_code' => 'dhlint',
+                ],
+            ],
+        ]);
+
+        $reader = new Magento1OrderReader($this->connection);
+        $shipments = $reader->fetchShipments([10]);
+        $this->assertCount(1, $shipments);
+        $this->assertSame(501, $shipments->first()['entity_id']);
+        $this->assertSame(10, $shipments->first()['order_id']);
+
+        $items = $reader->fetchShipmentItems([501]);
+        $this->assertCount(1, $items);
+        $this->assertSame(101, $items->first()['order_item_id']);
+        $this->assertSame('WIG-001', $items->first()['sku']);
+
+        $tracks = $reader->fetchShipmentTracks([501]);
+        $this->assertCount(1, $tracks);
+        $this->assertSame('JD014', $tracks->first()['track_number']);
+        $this->assertSame('dhlint', $tracks->first()['carrier_code']);
+    }
 }

+ 181 - 0
packages/Webkul/BagistoApi/tests/Unit/Migration/Magento1ProductReaderTest.php

@@ -0,0 +1,181 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Unit\Migration;
+
+use App\Services\Asteria\Magento1ProductReader;
+use Illuminate\Support\Facades\DB;
+use Tests\TestCase;
+
+class Magento1ProductReaderTest extends TestCase
+{
+    private string $sqlitePath;
+
+    private string $connection = 'asteria_test';
+
+    /** @var array<string, mixed> */
+    private array $readerConfig = [
+        'media_base_url'           => 'https://img.example.com/media/catalog/product',
+        'store_media_url'          => 'https://img.example.com/media/',
+        'base_attributes'          => ['name', 'price', 'status', 'description', 'short_description', 'url_key'],
+        'include_eav_attributes'   => ['hair_colorr'],
+        'variant_option_titles'    => [],
+        'skip_option_titles'       => ['Free Gift'],
+        'enabled_status'           => 1,
+    ];
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->sqlitePath = sys_get_temp_dir().'/asteria_product_reader_'.uniqid('', true).'.sqlite';
+        touch($this->sqlitePath);
+
+        config()->set('database.connections.'.$this->connection, [
+            'driver'                  => 'sqlite',
+            'database'                => $this->sqlitePath,
+            'prefix'                  => '',
+            'foreign_key_constraints' => false,
+        ]);
+
+        DB::purge($this->connection);
+        MagentoSchema::create($this->connection);
+        $this->seedEnabledProduct(10, 'WIG-001');
+    }
+
+    protected function tearDown(): void
+    {
+        DB::purge($this->connection);
+
+        if (is_file($this->sqlitePath)) {
+            @unlink($this->sqlitePath);
+        }
+
+        parent::tearDown();
+    }
+
+    public function test_it_hydrates_eav_images_stock_categories_and_variants(): void
+    {
+        $reader = new Magento1ProductReader($this->connection, $this->readerConfig);
+        $products = $reader->fetchProducts(0, 50);
+
+        $this->assertCount(1, $products);
+
+        $product = $products->first();
+        $this->assertSame(10, $product['entity_id']);
+        $this->assertSame('WIG-001', $product['sku']);
+        $this->assertSame('Lace Front Wig', $product['name']);
+        $this->assertEquals(99.0, (float) $product['price']);
+        $this->assertSame('Natural Black', $product['hair_colorr']);
+        $this->assertSame('Nice wig', $product['short_description']);
+        $this->assertStringContainsString('https://img.example.com/media/wysiwyg/wigs/18.jpg', $product['description']);
+        $this->assertSame('https://img.example.com/media/catalog/product/w/i/wig.jpg', $product['base_image']);
+        $this->assertSame('https://img.example.com/media/catalog/product/w/i/wig-2.jpg', $product['additional_images']);
+        $this->assertSame(['Lace Wigs'], $product['categories']);
+        $this->assertEquals(12, (float) $product['qty']);
+        $this->assertSame('hair_length,hair_color', $product['super_attributes']);
+        $this->assertCount(2, $product['options']);
+        $this->assertCount(4, $product['variants']);
+        $this->assertSame('WIG-001-1-1', $product['variants'][0]['sku']);
+        $this->assertSame('12"', $product['variants'][0]['Hair Length']);
+        $this->assertSame('12"', $product['variants'][0]['hair_length']);
+    }
+
+    public function test_it_pages_from_the_last_entity_id_and_skips_disabled_products(): void
+    {
+        MagentoSchema::seedProduct($this->connection, [
+            'entity_id' => 11,
+            'sku'       => 'WIG-002',
+            'name'      => 'Second Wig',
+            'price'     => 50,
+        ]);
+        MagentoSchema::seedProduct($this->connection, [
+            'entity_id' => 12,
+            'sku'       => 'WIG-OFF',
+            'name'      => 'Disabled',
+            'price'     => 10,
+            'status'    => 2,
+        ]);
+
+        $reader = new Magento1ProductReader($this->connection, $this->readerConfig);
+
+        $page = $reader->fetchProducts(10, 50);
+        $this->assertCount(1, $page);
+        $this->assertSame('WIG-002', $page->first()['sku']);
+
+        $skus = $reader->fetchProducts(0, 50)->pluck('sku')->all();
+        $this->assertSame(['WIG-001', 'WIG-002'], $skus);
+    }
+
+    public function test_it_exports_select_attribute_definitions_with_options(): void
+    {
+        $reader = new Magento1ProductReader($this->connection, $this->readerConfig);
+        $attrs = $reader->fetchAttributeDefinitions();
+
+        $this->assertCount(1, $attrs);
+        $attr = $attrs->first();
+        $this->assertSame('hair_colorr', $attr['code']);
+        $this->assertSame('select', $attr['type']);
+        $this->assertSame('Natural Black', $attr['options'][0]['label']);
+    }
+
+    public function test_it_omits_skipped_option_titles_from_variant_dimensions(): void
+    {
+        $reader = new Magento1ProductReader($this->connection, $this->readerConfig);
+        $product = $reader->fetchProducts(0, 1)->first();
+
+        $titles = array_column($product['options'], 'title');
+        $this->assertNotContains('Free Gift', $titles);
+        $this->assertContains('Hair Length', $titles);
+        $this->assertContains('Hair Color', $titles);
+    }
+
+    private function seedEnabledProduct(int $entityId, string $sku): void
+    {
+        MagentoSchema::seedProduct($this->connection, [
+            'entity_id'         => $entityId,
+            'sku'               => $sku,
+            'name'              => 'Lace Front Wig',
+            'price'             => '99.00',
+            'short_description' => '<p>Nice   wig</p>',
+            'description'       => '<p>{{media url="wysiwyg/wigs/18.jpg"}}</p>',
+            'hair_colorr'       => MagentoSchema::HAIR_COLORR_OPTION_ID,
+            'qty'               => 12,
+            'is_in_stock'       => 1,
+            'images'            => ['/w/i/wig.jpg', '/w/i/wig-2.jpg'],
+            'categories'        => [
+                ['entity_id' => 5, 'name' => 'Lace Wigs', 'path' => '1/2/5'],
+            ],
+            'options' => [
+                [
+                    'title'      => 'Hair Length',
+                    'type'       => 'drop_down',
+                    'is_require' => 1,
+                    'sort_order' => 1,
+                    'values'     => [
+                        ['title' => '12"', 'price' => 0, 'sort_order' => 1],
+                        ['title' => '14"', 'price' => 10, 'sort_order' => 2],
+                    ],
+                ],
+                [
+                    'title'      => 'Hair Color',
+                    'type'       => 'drop_down',
+                    'is_require' => 1,
+                    'sort_order' => 2,
+                    'values'     => [
+                        ['title' => 'Natural Black', 'price' => 0, 'sort_order' => 1],
+                        ['title' => 'Brown', 'price' => 5, 'sort_order' => 2],
+                    ],
+                ],
+                [
+                    'title'      => 'Free Gift',
+                    'type'       => 'drop_down',
+                    'is_require' => 0,
+                    'sort_order' => 3,
+                    'values'     => [
+                        ['title' => 'Yes', 'price' => 0, 'sort_order' => 1],
+                    ],
+                ],
+            ],
+        ]);
+    }
+}

+ 94 - 0
packages/Webkul/Paypal/src/Helpers/PaypalPaymentDetails.php

@@ -0,0 +1,94 @@
+<?php
+
+namespace Webkul\Paypal\Helpers;
+
+class PaypalPaymentDetails
+{
+    /**
+     * Magento admin labels stored on order_payment.additional.paypal.
+     *
+     * @var array<string, string>
+     */
+    private const MAGENTO_LABELS = [
+        'Payer ID'                          => 'paypal::app.order.payer-id',
+        'Payer Email'                       => 'paypal::app.order.payer-email',
+        'Payer Status'                      => 'paypal::app.order.payer-status',
+        'Payer Address Status'              => 'paypal::app.order.payer-address-status',
+        'Merchant Protection Eligibility'   => 'paypal::app.order.protection-eligibility',
+        'Last Correlation ID'               => 'paypal::app.order.last-correlation-id',
+        'Last Transaction ID'               => 'paypal::app.order.last-transaction-id',
+    ];
+
+    /**
+     * Fallback keys when additional.paypal is missing.
+     *
+     * @var array<string, string>
+     */
+    private const ADDITIONAL_KEYS = [
+        'paypal_payer_id'               => 'paypal::app.order.payer-id',
+        'paypal_payer_email'            => 'paypal::app.order.payer-email',
+        'paypal_payer_status'           => 'paypal::app.order.payer-status',
+        'paypal_address_status'         => 'paypal::app.order.payer-address-status',
+        'paypal_protection_eligibility' => 'paypal::app.order.protection-eligibility',
+        'paypal_correlation_id'         => 'paypal::app.order.last-correlation-id',
+        'last_trans_id'                 => 'paypal::app.order.last-transaction-id',
+    ];
+
+    /**
+     * @param  array<string, mixed>|null  $additional
+     * @return array<int, array{label: string, value: string}>
+     */
+    public static function rows(?array $additional): array
+    {
+        if (! is_array($additional) || $additional === []) {
+            return [];
+        }
+
+        $paypal = $additional['paypal'] ?? null;
+
+        if (is_array($paypal) && $paypal !== []) {
+            $rows = [];
+
+            foreach (self::MAGENTO_LABELS as $storedLabel => $langKey) {
+                $value = self::stringValue($paypal[$storedLabel] ?? null);
+
+                if ($value === null) {
+                    continue;
+                }
+
+                $rows[] = [
+                    'label' => trans($langKey),
+                    'value' => $value,
+                ];
+            }
+
+            if ($rows !== []) {
+                return $rows;
+            }
+        }
+
+        $rows = [];
+
+        foreach (self::ADDITIONAL_KEYS as $key => $langKey) {
+            $value = self::stringValue($additional[$key] ?? null);
+
+            if ($value === null) {
+                continue;
+            }
+
+            $rows[] = [
+                'label' => trans($langKey),
+                'value' => $value,
+            ];
+        }
+
+        return $rows;
+    }
+
+    private static function stringValue(mixed $value): ?string
+    {
+        $value = trim((string) $value);
+
+        return $value === '' ? null : $value;
+    }
+}

+ 4 - 0
packages/Webkul/Paypal/src/Providers/EventServiceProvider.php

@@ -19,6 +19,10 @@ class EventServiceProvider extends ServiceProvider
             $viewRenderEventManager->addTemplate('paypal::checkout.onepage.paypal-smart-button');
         });
 
+        Event::listen('bagisto.admin.sales.order.payment-method.after', static function (ViewRenderEventManager $viewRenderEventManager) {
+            $viewRenderEventManager->addTemplate('paypal::admin.sales.orders.payment-additional');
+        });
+
         Event::listen('sales.invoice.save.after', 'Webkul\Paypal\Listeners\Transaction@saveTransaction');
     }
 }

+ 10 - 0
packages/Webkul/Paypal/src/Resources/lang/en/app.php

@@ -5,4 +5,14 @@ return [
         'invalid-configs'      => 'It seems there is a configuration issue with the PayPal payment method. Please contact the store owner for assistance.',
         'something-went-wrong' => 'Something went wrong with the PayPal payment method. Please contact the store owner for assistance.',
     ],
+
+    'order' => [
+        'payer-id'                 => 'Payer ID',
+        'payer-email'              => 'Payer Email',
+        'payer-status'             => 'Payer Status',
+        'payer-address-status'     => 'Payer Address Status',
+        'protection-eligibility'   => 'Merchant Protection Eligibility',
+        'last-correlation-id'      => 'Last Correlation ID',
+        'last-transaction-id'      => 'Last Transaction ID',
+    ],
 ];

+ 10 - 0
packages/Webkul/Paypal/src/Resources/lang/zh_CN/app.php

@@ -5,4 +5,14 @@ return [
         'invalid-configs'      => 'PayPal支付方式的配置似乎存在问题。请联系店主以获取帮助。',
         'something-went-wrong' => 'PayPal支付方式出现问题。请联系店主以获取帮助。',
     ],
+
+    'order' => [
+        'payer-id'                 => '付款人 ID',
+        'payer-email'              => '付款人邮箱',
+        'payer-status'             => '付款人状态',
+        'payer-address-status'     => '付款人地址状态',
+        'protection-eligibility'   => '商家保障资格',
+        'last-correlation-id'      => '最后关联 ID',
+        'last-transaction-id'      => '最后交易号',
+    ],
 ];

+ 19 - 0
packages/Webkul/Paypal/src/Resources/views/admin/sales/orders/payment-additional.blade.php

@@ -0,0 +1,19 @@
+@php
+    $paymentRows = \Webkul\Paypal\Helpers\PaypalPaymentDetails::rows(
+        $order->payment->additional ?? null
+    );
+@endphp
+
+@if (! empty($paymentRows))
+    <div class="pt-4 space-y-1">
+        @foreach ($paymentRows as $row)
+            <p
+                class="text-sm text-gray-800 dark:text-white"
+                v-pre
+            >
+                <span class="text-gray-600 dark:text-gray-300">{{ $row['label'] }}:</span>
+                <span class="font-semibold">{{ $row['value'] }}</span>
+            </p>
+        @endforeach
+    </div>
+@endif