Explorar o código

Merge branch 'dev' into dev-rewardPoints

bianjunhui hai 3 días
pai
achega
ca271410f0
Modificáronse 100 ficheiros con 9093 adicións e 270 borrados
  1. 15 0
      app/Console/Commands/MigrateAsteriaCustomers.php
  2. 27 0
      app/Console/Commands/MigrateAsteriaOrders.php
  3. 38 1
      app/Console/Commands/MigrateAsteriaProducts.php
  4. 597 0
      app/Console/Commands/RepairMigratedVariantSkus.php
  5. 22 5
      app/Jobs/MigrateReviewJob.php
  6. 36 0
      app/Providers/HorizonServiceProvider.php
  7. 46 3
      app/Services/Asteria/Magento1ProductReader.php
  8. 55 0
      app/Services/Asteria/MagentoPrimaryKey.php
  9. 96 92
      bootstrap/cache/services.php
  10. 2 0
      bootstrap/providers.php
  11. 7 1
      composer.json
  12. 15 2
      config/api-platform.php
  13. 254 0
      config/horizon.php
  14. 2 2
      config/purify.php
  15. 31 14
      docs/asteria-migration.md
  16. 20 0
      packages/Longyi/Core/src/Config/acl.php
  17. 11 0
      packages/Longyi/Core/src/Config/admin-menu.php
  18. 36 0
      packages/Longyi/Core/src/Database/Migrations/2026_09_16_000001_create_product_sync_imports_table.php
  19. 42 0
      packages/Longyi/Core/src/Database/Migrations/2026_09_16_000002_add_generic_fields_to_notifications_table.php
  20. 140 0
      packages/Longyi/Core/src/Exports/ProductBasicInfoExport.php
  21. 27 12
      packages/Longyi/Core/src/Helpers/Indexers/Price/FlexibleVariant.php
  22. 101 0
      packages/Longyi/Core/src/Http/Controllers/Admin/ProductSyncController.php
  23. 10 0
      packages/Longyi/Core/src/Imports/ProductBasicInfoSpreadsheet.php
  24. 52 0
      packages/Longyi/Core/src/Jobs/ImportProductBasicInfoJob.php
  25. 66 0
      packages/Longyi/Core/src/Models/AdminNotification.php
  26. 70 0
      packages/Longyi/Core/src/Models/ProductSyncImport.php
  27. 27 9
      packages/Longyi/Core/src/Providers/LongyiCoreServiceProvider.php
  28. 112 65
      packages/Longyi/Core/src/Resources/lang/en/app.php
  29. 110 63
      packages/Longyi/Core/src/Resources/lang/zh_CN/app.php
  30. 9 0
      packages/Longyi/Core/src/Resources/views/admin/catalog/products/sync-button.blade.php
  31. 159 0
      packages/Longyi/Core/src/Resources/views/admin/catalog/products/sync.blade.php
  32. 9 1
      packages/Longyi/Core/src/Routes/admin-routes.php
  33. 7 0
      packages/Longyi/Core/src/Services/ProductBasicInfoSyncException.php
  34. 26 0
      packages/Longyi/Core/src/Services/ProductBasicInfoSyncResult.php
  35. 580 0
      packages/Longyi/Core/src/Services/ProductBasicInfoSyncService.php
  36. 159 0
      packages/Longyi/Core/src/Services/ProductSyncImportService.php
  37. 70 0
      packages/Longyi/Core/src/Support/ProductBasicInfoColumns.php
  38. 4 0
      packages/Longyi/Core/src/Type/FlexibleVariant.php
  39. 184 0
      packages/Longyi/Core/tests/TestCase.php
  40. 268 0
      packages/Longyi/Core/tests/Unit/ProductBasicInfoSyncServiceTest.php
  41. 124 0
      packages/Longyi/Core/tests/Unit/ProductSyncImportServiceTest.php
  42. 26 0
      packages/Longyi/Feishu/composer.json
  43. 108 0
      packages/Longyi/Feishu/src/Config/feishu.php
  44. 44 0
      packages/Longyi/Feishu/src/Jobs/SendFeishuMessageJob.php
  45. 193 0
      packages/Longyi/Feishu/src/Listeners/PaymentAlertSubscriber.php
  46. 132 0
      packages/Longyi/Feishu/src/Messages/AlertCard.php
  47. 19 0
      packages/Longyi/Feishu/src/Providers/EventServiceProvider.php
  48. 32 0
      packages/Longyi/Feishu/src/Providers/FeishuServiceProvider.php
  49. 99 0
      packages/Longyi/Feishu/src/Services/FeishuBot.php
  50. 36 0
      packages/Longyi/FreeGift/src/ApiResource/AddGift.php
  51. 28 0
      packages/Longyi/FreeGift/src/ApiResource/AddGiftInput.php
  52. 30 0
      packages/Longyi/FreeGift/src/ApiResource/GiftActionResult.php
  53. 51 0
      packages/Longyi/FreeGift/src/ApiResource/GiftBannerData.php
  54. 69 0
      packages/Longyi/FreeGift/src/ApiResource/GiftGroupData.php
  55. 91 0
      packages/Longyi/FreeGift/src/ApiResource/GiftProductData.php
  56. 36 0
      packages/Longyi/FreeGift/src/ApiResource/RemoveGift.php
  57. 18 0
      packages/Longyi/FreeGift/src/ApiResource/RemoveGiftInput.php
  58. 8 0
      packages/Longyi/FreeGift/src/ApiResource/RestoreGiftInput.php
  59. 36 0
      packages/Longyi/FreeGift/src/ApiResource/RestoreGifts.php
  60. 84 0
      packages/Longyi/FreeGift/src/Config/Gift.php
  61. 44 0
      packages/Longyi/FreeGift/src/Config/system.php
  62. 7 0
      packages/Longyi/FreeGift/src/Contracts/FreeGiftItem.php
  63. 7 0
      packages/Longyi/FreeGift/src/Contracts/FreeGiftRule.php
  64. 49 0
      packages/Longyi/FreeGift/src/Database/Migrations/2026_09_17_000001_create_free_gift_rules_table.php
  65. 53 0
      packages/Longyi/FreeGift/src/Database/Migrations/2026_09_17_000002_create_free_gift_items_table.php
  66. 127 0
      packages/Longyi/FreeGift/src/Http/Controllers/Admin/ProductPickerController.php
  67. 247 0
      packages/Longyi/FreeGift/src/Http/Controllers/Shop/GiftController.php
  68. 34 0
      packages/Longyi/FreeGift/src/Http/Requests/GiftCartRuleRequest.php
  69. 30 0
      packages/Longyi/FreeGift/src/Listeners/AdminCartRuleForm.php
  70. 31 0
      packages/Longyi/FreeGift/src/Listeners/Cart.php
  71. 39 0
      packages/Longyi/FreeGift/src/Listeners/CartItemDelete.php
  72. 33 0
      packages/Longyi/FreeGift/src/Listeners/CartItemUpdate.php
  73. 79 0
      packages/Longyi/FreeGift/src/Listeners/OrderPlaced.php
  74. 118 0
      packages/Longyi/FreeGift/src/Models/FreeGiftItem.php
  75. 119 0
      packages/Longyi/FreeGift/src/Models/FreeGiftRule.php
  76. 44 0
      packages/Longyi/FreeGift/src/Providers/EventServiceProvider.php
  77. 74 0
      packages/Longyi/FreeGift/src/Providers/FreeGiftServiceProvider.php
  78. 83 0
      packages/Longyi/FreeGift/src/Repositories/FreeGiftItemRepository.php
  79. 58 0
      packages/Longyi/FreeGift/src/Repositories/FreeGiftRuleRepository.php
  80. 146 0
      packages/Longyi/FreeGift/src/Repositories/GiftCartRuleRepository.php
  81. 63 0
      packages/Longyi/FreeGift/src/Resources/lang/en/admin.php
  82. 27 0
      packages/Longyi/FreeGift/src/Resources/lang/en/app.php
  83. 63 0
      packages/Longyi/FreeGift/src/Resources/lang/zh_CN/admin.php
  84. 27 0
      packages/Longyi/FreeGift/src/Resources/lang/zh_CN/app.php
  85. 1245 0
      packages/Longyi/FreeGift/src/Resources/views/admin/cart-rules/gift-settings.blade.php
  86. 342 0
      packages/Longyi/FreeGift/src/Resources/views/shop/cart/gift-container.blade.php
  87. 18 0
      packages/Longyi/FreeGift/src/Resources/views/shop/cart/gift-item-badge.blade.php
  88. 15 0
      packages/Longyi/FreeGift/src/Routes/admin-routes.php
  89. 21 0
      packages/Longyi/FreeGift/src/Routes/shop-routes.php
  90. 131 0
      packages/Longyi/FreeGift/src/Services/Actions/AbstractGiftAction.php
  91. 22 0
      packages/Longyi/FreeGift/src/Services/Actions/CartAction.php
  92. 38 0
      packages/Longyi/FreeGift/src/Services/Actions/EachNAction.php
  93. 40 0
      packages/Longyi/FreeGift/src/Services/Actions/GiftActionPool.php
  94. 26 0
      packages/Longyi/FreeGift/src/Services/Actions/ProductAction.php
  95. 69 0
      packages/Longyi/FreeGift/src/Services/Actions/SameProductAction.php
  96. 27 0
      packages/Longyi/FreeGift/src/Services/Actions/SpentAction.php
  97. 235 0
      packages/Longyi/FreeGift/src/Services/GiftCartSummary.php
  98. 77 0
      packages/Longyi/FreeGift/src/Services/GiftDiscountCalculator.php
  99. 379 0
      packages/Longyi/FreeGift/src/Services/GiftItem.php
  100. 0 0
      packages/Longyi/FreeGift/src/Services/GiftItemsGroup.php

+ 15 - 0
app/Console/Commands/MigrateAsteriaCustomers.php

@@ -3,6 +3,7 @@
 namespace App\Console\Commands;
 
 use App\Services\Asteria\Magento1CustomerReader;
+use App\Services\Asteria\MagentoPrimaryKey;
 use Illuminate\Console\Command;
 use Illuminate\Support\Collection;
 use Illuminate\Support\Facades\Cache;
@@ -182,6 +183,10 @@ class MigrateAsteriaCustomers extends Command
         $this->newLine();
         $this->info("Done. Batches: {$batchNumber}, created: {$created}, linked: {$linked}, skipped: {$skipped}, addresses: {$addressesImported}, subscriptions: {$subscriptions}.");
 
+        if (! $dryRun) {
+            MagentoPrimaryKey::bumpAutoIncrement('customers');
+        }
+
         return self::SUCCESS;
     }
 
@@ -210,6 +215,7 @@ class MigrateAsteriaCustomers extends Command
             ->all();
 
         [$byAsteriaId, $byEmail] = $this->loadExistingCustomers($asteriaIds, $emails);
+        $occupiedIds = MagentoPrimaryKey::occupiedIds('customers', $asteriaIds);
 
         $insertCustomers = [];
         $linkUpdates = [];
@@ -251,11 +257,20 @@ class MigrateAsteriaCustomers extends Command
                 continue;
             }
 
+            if (isset($occupiedIds[$asteriaId])) {
+                $skipped++;
+                $this->warn("Customer {$email} skipped: customers.id {$asteriaId} is already occupied.");
+                Log::warning("MigrateAsteriaCustomers: customers.id {$asteriaId} already occupied; skipping Magento customer {$asteriaId} ({$email}).");
+
+                continue;
+            }
+
             $seenEmails[$email] = true;
             $phone = $this->uniquePhone((string) ($row['telephone'] ?? ''), $this->usedPhones);
             $subscriber = $subscribersByCustomerId[$asteriaId] ?? $subscribersByEmail[$email] ?? null;
 
             $insertCustomers[$asteriaId] = [
+                'id'                        => $asteriaId,
                 'migrated_from_asteria_id'  => $asteriaId,
                 'first_name'                => $this->requiredName($row['firstname'] ?? null, $email),
                 'last_name'                 => trim((string) ($row['lastname'] ?? '')) ?: '-',

+ 27 - 0
app/Console/Commands/MigrateAsteriaOrders.php

@@ -3,6 +3,7 @@
 namespace App\Console\Commands;
 
 use App\Services\Asteria\Magento1OrderReader;
+use App\Services\Asteria\MagentoPrimaryKey;
 use Illuminate\Console\Command;
 use Illuminate\Support\Collection;
 use Illuminate\Support\Facades\Cache;
@@ -239,6 +240,12 @@ class MigrateAsteriaOrders extends Command
             if ($linked > 0) {
                 $this->info("Backfilled product_id on {$linked} existing order items.");
             }
+
+            MagentoPrimaryKey::bumpAutoIncrement('orders');
+
+            if (Schema::hasColumn('shipments', 'migrated_from_asteria_id')) {
+                MagentoPrimaryKey::bumpAutoIncrement('shipments');
+            }
         }
 
         return self::SUCCESS;
@@ -293,6 +300,8 @@ class MigrateAsteriaOrders extends Command
                 ->flip()
                 ->all();
 
+        $occupiedIds = MagentoPrimaryKey::occupiedIds('orders', $asteriaIds);
+
         $now = now()->format('Y-m-d H:i:s');
         $orderInserts = [];
         $pending = [];
@@ -312,6 +321,14 @@ class MigrateAsteriaOrders extends Command
                 continue;
             }
 
+            if (isset($occupiedIds[$asteriaId])) {
+                $skipped++;
+                $this->warn("Order {$incrementId} skipped: orders.id {$asteriaId} is already occupied.");
+                Log::warning("MigrateAsteriaOrders: orders.id {$asteriaId} already occupied; skipping Magento order {$asteriaId}.");
+
+                continue;
+            }
+
             $seenIncrements[$incrementId] = true;
             $orderInserts[] = $this->buildOrderRow(
                 $row,
@@ -738,6 +755,8 @@ class MigrateAsteriaOrders extends Command
                 ->flip()
                 ->all();
 
+        $occupiedIds = MagentoPrimaryKey::occupiedIds('shipments', $asteriaShipmentIds);
+
         $ordersByAsteriaId = $bagistoOrders->keyBy(fn ($order) => (int) $order->migrated_from_asteria_id);
         $shippingAddressIds = DB::table('addresses')
             ->whereIn('order_id', array_values($orderIdMap))
@@ -761,10 +780,17 @@ class MigrateAsteriaOrders extends Command
                 continue;
             }
 
+            if (isset($occupiedIds[$asteriaId])) {
+                Log::warning("MigrateAsteriaOrders: shipments.id {$asteriaId} already occupied; skipping Magento shipment {$asteriaId}.");
+
+                continue;
+            }
+
             $tracks = $shipmentTracks->get($asteriaId, collect());
             [$carrierCode, $carrierTitle, $trackNumber] = $this->mapShipmentTracks($tracks);
 
             $inserts[] = [
+                'id'                       => $asteriaId,
                 'migrated_from_asteria_id' => $asteriaId,
                 'status'                   => null,
                 'total_qty'                => $this->qty($row['total_qty'] ?? 0),
@@ -985,6 +1011,7 @@ class MigrateAsteriaOrders extends Command
             : $baseShippingAmount + $baseShippingTaxAmount;
 
         $insert = [
+            'id'                            => (int) $row['entity_id'],
             'migrated_from_asteria_id'      => (int) $row['entity_id'],
             'increment_id'                  => trim((string) $row['increment_id']),
             'status'                        => $this->mapStatus($row),

+ 38 - 1
app/Console/Commands/MigrateAsteriaProducts.php

@@ -3,6 +3,7 @@
 namespace App\Console\Commands;
 
 use App\Services\Asteria\Magento1ProductReader;
+use App\Services\Asteria\MagentoPrimaryKey;
 use Illuminate\Console\Command;
 use Illuminate\Support\Collection;
 use Illuminate\Support\Facades\Cache;
@@ -203,7 +204,9 @@ class MigrateAsteriaProducts extends Command
             $lastId = (int) $products->max('entity_id');
 
             if ($dryRun) {
-                $created += $products->count();
+                $simpleCount = $products->filter(fn (array $row) => $this->isSimpleMagentoProduct($row))->count();
+                $created += $simpleCount;
+                $skipped += $products->count() - $simpleCount;
                 $this->line(sprintf(
                     '  Batch #%d: %d products (last entity_id=%d) [skipped – dry-run] (%.1fs)',
                     $batchNumber,
@@ -251,6 +254,10 @@ class MigrateAsteriaProducts extends Command
         $this->newLine();
         $this->info("Done. Batches: {$batchNumber}, created: {$created}, updated: {$updated}, skipped: {$skipped}.");
 
+        if (! $dryRun && ! $this->option('attributes-only')) {
+            MagentoPrimaryKey::bumpAutoIncrement('products');
+        }
+
         return self::SUCCESS;
     }
 
@@ -357,6 +364,15 @@ class MigrateAsteriaProducts extends Command
                 continue;
             }
 
+            if (! $this->isSimpleMagentoProduct($row)) {
+                $typeId = (string) ($row['type_id'] ?? '');
+                $skipped++;
+                $this->warn("SKU {$sku} skipped: Magento type_id '{$typeId}' is not simple.");
+                Log::warning("MigrateAsteriaProducts: skipping non-simple SKU {$sku} (type_id={$typeId}).");
+
+                continue;
+            }
+
             $variantCount = is_array($row['variants'] ?? null) ? count($row['variants']) : 0;
 
             if ($variantCount > $warnAt) {
@@ -396,12 +412,25 @@ class MigrateAsteriaProducts extends Command
 
         $wasNew = ! $product->exists;
 
+        if ($wasNew) {
+            if (isset(MagentoPrimaryKey::occupiedIds('products', [$asteriaId])[$asteriaId])) {
+                throw new \RuntimeException("Product id {$asteriaId} is already occupied; refusing to overwrite for SKU {$sku}.");
+            }
+
+            $product->id = $asteriaId;
+            $product->incrementing = false;
+        }
+
         $product->sku = $sku;
         $product->type = 'flexible_variant';
         $product->attribute_family_id = $this->familyId;
         $product->migrated_from_asteria_id = $asteriaId;
         $product->save();
 
+        if ($wasNew) {
+            $product->incrementing = true;
+        }
+
         if ($wasNew && ! empty($row['created_at'])) {
             DB::table('products')->where('id', $product->id)->update([
                 'created_at' => $row['created_at'],
@@ -423,6 +452,14 @@ class MigrateAsteriaProducts extends Command
         return $wasNew;
     }
 
+    /**
+     * @param  array<string, mixed>  $row
+     */
+    private function isSimpleMagentoProduct(array $row): bool
+    {
+        return strtolower(trim((string) ($row['type_id'] ?? 'simple'))) === 'simple';
+    }
+
     /**
      * @param  array<string, mixed>  $row
      */

+ 597 - 0
app/Console/Commands/RepairMigratedVariantSkus.php

@@ -0,0 +1,597 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+use Webkul\Product\Models\Product;
+
+/**
+ * Rewrites flexible-variant SKUs that were migrated with option sort_order suffixes.
+ *
+ * The new suffix is the option value SKU (`product_option_values.code`), in option position order.
+ * Variant rows, images, and ids stay in place. Order, invoice, shipment, and
+ * refund item SKUs that belong to those variants are rewritten to the same SKU.
+ */
+class RepairMigratedVariantSkus extends Command
+{
+    protected $signature = 'products:repair-variant-skus
+        {--dry-run : Print the SKU changes without writing}
+        {--sku= : Repair one parent product SKU}';
+
+    protected $description = 'Rewrite migrated variant and order item SKUs from option value SKUs';
+
+    private int $updated = 0;
+
+    private int $unchanged = 0;
+
+    private int $skipped = 0;
+
+    private int $collisions = 0;
+
+    private int $orderItems = 0;
+
+    /** @var array<int, array{0: int, 1: string, 2: string}> */
+    private array $examples = [];
+
+    public function handle(): int
+    {
+        $dryRun = (bool) $this->option('dry-run');
+        $sku = trim((string) $this->option('sku'));
+
+        $query = Product::query()
+            ->whereNotNull('migrated_from_asteria_id')
+            ->where('type', 'flexible_variant')
+            ->select('id', 'sku')
+            ->orderBy('id');
+
+        if ($sku !== '') {
+            $query->where('sku', $sku);
+        }
+
+        $query->chunkById(20, function (Collection $products) use ($dryRun) {
+            $this->repairChunk($products, $dryRun);
+        });
+
+        if ($this->examples !== []) {
+            $this->table(['variant_id', 'from', 'to'], $this->examples);
+        }
+
+        $this->info(sprintf(
+            '%s updated=%d unchanged=%d skipped=%d collisions=%d order_skus=%d',
+            $dryRun ? '[DRY RUN]' : 'Done.',
+            $this->updated,
+            $this->unchanged,
+            $this->skipped,
+            $this->collisions,
+            $this->orderItems
+        ));
+
+        return $this->collisions > 0 ? self::FAILURE : self::SUCCESS;
+    }
+
+    private function repairChunk(Collection $products, bool $dryRun): void
+    {
+        $productIds = $products->pluck('id')->map(fn ($id) => (int) $id)->all();
+        $parentSku = $products->mapWithKeys(fn ($product) => [(int) $product->id => trim((string) $product->sku)]);
+
+        $optionCounts = DB::table('product_product_options')
+            ->whereIn('product_id', $productIds)
+            ->select('product_id', DB::raw('COUNT(*) as aggregate'))
+            ->groupBy('product_id')
+            ->pluck('aggregate', 'product_id');
+
+        $rows = DB::table('product_variants as v')
+            ->join('product_variant_option_values as pvov', 'pvov.product_variant_id', '=', 'v.id')
+            ->join('product_option_values as pov', 'pov.id', '=', 'pvov.product_option_value_id')
+            ->join('product_product_options as ppo', function ($join) {
+                $join->on('ppo.product_option_id', '=', 'pov.product_option_id')
+                    ->on('ppo.product_id', '=', 'v.product_id');
+            })
+            ->whereIn('v.product_id', $productIds)
+            ->whereNull('v.deleted_at')
+            ->orderBy('v.id')
+            ->orderBy('ppo.position')
+            ->orderBy('pov.id')
+            ->join('product_options as po', 'po.id', '=', 'pov.product_option_id')
+            ->get([
+                'v.id as variant_id',
+                'v.product_id',
+                'v.sku',
+                'pov.product_option_id',
+                'po.label as option_label',
+                'pov.label as value_label',
+                'pov.code as value_sku',
+            ]);
+
+        $grouped = [];
+
+        foreach ($rows as $row) {
+            $variantId = (int) $row->variant_id;
+            $grouped[$variantId]['product_id'] = (int) $row->product_id;
+            $grouped[$variantId]['sku'] = (string) $row->sku;
+            $grouped[$variantId]['parts'][] = $row;
+        }
+
+        $liveVariantIds = DB::table('product_variants')
+            ->whereIn('product_id', $productIds)
+            ->whereNull('deleted_at')
+            ->pluck('id');
+
+        foreach ($liveVariantIds as $variantId) {
+            if (! isset($grouped[(int) $variantId])) {
+                $this->skipped++;
+            }
+        }
+
+        /** @var array<int, string> $planned */
+        $planned = [];
+        /** @var array<int, string> $previous */
+        $previous = [];
+        /** @var array<int, array{product_id: int, sku: string, pairs: array<int, array{0: string, 1: string}>}> $targets */
+        $targets = [];
+
+        foreach ($grouped as $variantId => $info) {
+            $expected = (int) ($optionCounts[$info['product_id']] ?? 0);
+            $seenOptions = [];
+            $labels = [];
+            $invalid = false;
+
+            $pairs = [];
+
+            foreach ($info['parts'] as $part) {
+                $optionId = (int) $part->product_option_id;
+
+                if (isset($seenOptions[$optionId])) {
+                    $invalid = true;
+                    break;
+                }
+
+                $seenOptions[$optionId] = true;
+                $optionLabel = trim((string) $part->option_label);
+                $valueLabel = trim((string) $part->value_label);
+                $valueSku = trim((string) ($part->value_sku ?? ''));
+
+                if ($valueSku === '') {
+                    $valueSku = $valueLabel;
+                }
+
+                if ($optionLabel === '' || $valueLabel === '' || $valueSku === '') {
+                    $invalid = true;
+                    break;
+                }
+
+                $labels[] = $valueSku;
+                $pairs[] = [$optionLabel, $valueLabel];
+            }
+
+            if ($invalid || $expected === 0 || count($labels) !== $expected) {
+                $this->skipped++;
+                continue;
+            }
+
+            $newSku = $parentSku[$info['product_id']].'-'.implode('-', $labels);
+
+            if (strlen($newSku) > 255) {
+                $this->skipped++;
+                $this->warn("Variant {$variantId} SKU exceeds 255 characters.");
+                continue;
+            }
+
+            $targets[$variantId] = [
+                'product_id' => $info['product_id'],
+                'sku'        => $newSku,
+                'pairs'      => $pairs,
+            ];
+
+            if ($newSku === $info['sku']) {
+                $this->unchanged++;
+                continue;
+            }
+
+            $planned[$variantId] = $newSku;
+            $previous[$variantId] = $info['sku'];
+        }
+
+        $this->dropCollisions($planned, $previous);
+
+        foreach ($targets as $variantId => $target) {
+            $current = (string) ($grouped[$variantId]['sku'] ?? '');
+
+            if ($target['sku'] !== $current && ! isset($planned[$variantId])) {
+                unset($targets[$variantId]);
+            }
+        }
+
+        if ($dryRun) {
+            foreach ($planned as $variantId => $newSku) {
+                $this->rememberExample($variantId, $previous[$variantId], $newSku);
+                $this->updated++;
+            }
+
+            $this->rewriteOrderSkus($targets, $productIds, true);
+
+            return;
+        }
+
+        if ($planned !== []) {
+            $this->writeSkus($planned, $previous);
+            $this->updated += count($planned);
+        }
+
+        $this->rewriteOrderSkus($targets, $productIds, false);
+    }
+
+    /**
+     * @param  array<int, string>  $planned
+     * @param  array<int, string>  $previous
+     */
+    private function dropCollisions(array &$planned, array &$previous): void
+    {
+        if ($planned === []) {
+            return;
+        }
+
+        $owners = DB::table('product_variants')
+            ->whereIn('sku', array_values($planned))
+            ->pluck('id', 'sku');
+
+        $blocked = [];
+        $seen = [];
+
+        foreach ($planned as $variantId => $newSku) {
+            if (isset($seen[$newSku])) {
+                $blocked[$variantId] = true;
+                $blocked[$seen[$newSku]] = true;
+                continue;
+            }
+
+            $seen[$newSku] = $variantId;
+            $ownerId = isset($owners[$newSku]) ? (int) $owners[$newSku] : null;
+
+            if ($ownerId === null || $ownerId === (int) $variantId) {
+                continue;
+            }
+
+            $ownerNext = $planned[$ownerId] ?? null;
+
+            if ($ownerNext === null || $ownerNext === $newSku) {
+                $blocked[$variantId] = true;
+            }
+        }
+
+        foreach (array_keys($blocked) as $variantId) {
+            $this->warn("SKU collision, left variant {$variantId} as {$previous[$variantId]}.");
+            unset($planned[$variantId], $previous[$variantId]);
+            $this->collisions++;
+        }
+    }
+
+    /**
+     * @param  array<int, string>  $planned
+     * @param  array<int, string>  $previous
+     */
+    private function writeSkus(array $planned, array $previous): void
+    {
+        $now = now()->toDateTimeString();
+
+        DB::transaction(function () use ($planned, $previous, $now) {
+            foreach (array_chunk(array_keys($planned), 200) as $ids) {
+                DB::table('product_variants')
+                    ->whereIn('id', $ids)
+                    ->update([
+                        'sku'        => DB::raw("CONCAT('__sku_repair_', id)"),
+                        'updated_at' => $now,
+                    ]);
+            }
+
+            foreach (array_chunk($planned, 200, true) as $chunk) {
+                $case = 'CASE id';
+                $bindings = [];
+                $ids = array_keys($chunk);
+
+                foreach ($chunk as $variantId => $newSku) {
+                    $case .= ' WHEN ? THEN ?';
+                    $bindings[] = $variantId;
+                    $bindings[] = $newSku;
+                    $this->rememberExample($variantId, $previous[$variantId], $newSku);
+                }
+
+                $case .= ' END';
+                $placeholders = implode(',', array_fill(0, count($chunk), '?'));
+
+                DB::update(
+                    "UPDATE product_variants SET sku = {$case}, updated_at = ? WHERE id IN ({$placeholders})",
+                    array_merge($bindings, [$now], $ids)
+                );
+            }
+        });
+    }
+
+    private function rememberExample(int $variantId, string $from, string $to): void
+    {
+        if (count($this->examples) >= 15) {
+            return;
+        }
+
+        $this->examples[] = [$variantId, $from, $to];
+    }
+
+    /**
+     * Point order documents at the variant SKU. Match variant_id first, then the
+     * option labels stored on the order item.
+     *
+     * @param  array<int, array{product_id: int, sku: string, pairs: array<int, array{0: string, 1: string}>}>  $targets
+     * @param  array<int, int>  $productIds
+     */
+    private function rewriteOrderSkus(array $targets, array $productIds, bool $dryRun): void
+    {
+        if ($targets === [] || $productIds === [] || ! Schema::hasTable('order_items')) {
+            return;
+        }
+
+        $byVariant = [];
+        $byProductAndKey = [];
+        $optionNames = [];
+
+        foreach ($targets as $variantId => $target) {
+            $byVariant[$variantId] = $target['sku'];
+            $key = $this->labelKey($target['pairs']);
+            $byProductAndKey[$target['product_id']][$key][] = $variantId;
+
+            foreach ($target['pairs'] as [$optionLabel]) {
+                $optionNames[$target['product_id']][$this->normalize($optionLabel)] = true;
+            }
+        }
+
+        DB::table('order_items')
+            ->whereIn('product_id', $productIds)
+            ->orderBy('id')
+            ->select('id', 'product_id', 'sku', 'additional')
+            ->chunkById(500, function (Collection $items) use ($byVariant, $byProductAndKey, $optionNames, $dryRun) {
+                $skuUpdates = [];
+                $additionalUpdates = [];
+
+                foreach ($items as $item) {
+                    $productId = (int) $item->product_id;
+                    $additional = $this->decodeAdditional($item->additional);
+                    $newSku = $this->resolveOrderSku($productId, $additional, $byVariant, $byProductAndKey, $optionNames);
+
+                    if ($newSku === null) {
+                        continue;
+                    }
+
+                    if ((string) $item->sku !== $newSku) {
+                        $skuUpdates[(int) $item->id] = $newSku;
+                    }
+
+                    if ($this->additionalVariantSku($additional, $newSku)) {
+                        $additionalUpdates[(int) $item->id] = $additional;
+                    }
+                }
+
+                $this->orderItems += count($skuUpdates);
+                $now = now()->toDateTimeString();
+                $this->syncRelatedItemSkus($skuUpdates, $dryRun, $now);
+
+                if ($dryRun || ($skuUpdates === [] && $additionalUpdates === [])) {
+                    return;
+                }
+
+                $this->writeColumnSkus('order_items', $skuUpdates, $now);
+                $this->writeAdditional($additionalUpdates, $now);
+            });
+    }
+
+    /**
+     * @param  array<string, mixed>  $additional
+     * @param  array<int, string>  $byVariant
+     * @param  array<int, array<string, array<int, int>>>  $byProductAndKey
+     * @param  array<int, array<string, true>>  $optionNames
+     */
+    private function resolveOrderSku(
+        int $productId,
+        array $additional,
+        array $byVariant,
+        array $byProductAndKey,
+        array $optionNames
+    ): ?string {
+        $variantId = (int) ($additional['variant_id'] ?? 0);
+
+        if ($variantId > 0 && isset($byVariant[$variantId])) {
+            return $byVariant[$variantId];
+        }
+
+        $pairs = $this->orderLabelPairs($additional, $optionNames[$productId] ?? []);
+
+        if ($pairs === []) {
+            return null;
+        }
+
+        $ids = $byProductAndKey[$productId][$this->labelKey($pairs)] ?? [];
+
+        if (count($ids) !== 1) {
+            return null;
+        }
+
+        return $byVariant[$ids[0]] ?? null;
+    }
+
+    /**
+     * @param  array<string, mixed>  $additional
+     * @param  array<string, true>  $optionNames
+     * @return array<int, array{0: string, 1: string}>
+     */
+    private function orderLabelPairs(array $additional, array $optionNames): array
+    {
+        $attributes = $additional['attributes'] ?? null;
+
+        if (! is_array($attributes) || $attributes === []) {
+            return [];
+        }
+
+        $pairs = [];
+        $list = array_is_list($attributes);
+
+        foreach ($attributes as $row) {
+            if (! is_array($row)) {
+                continue;
+            }
+
+            if ($list) {
+                $option = trim((string) ($row['attribute_name'] ?? $row['label'] ?? ''));
+                $value = trim((string) ($row['option_label'] ?? $row['value_label'] ?? ''));
+            } else {
+                $option = trim((string) ($row['attribute_name'] ?? $row['option_label'] ?? ''));
+                $value = trim((string) ($row['value_label'] ?? ''));
+            }
+
+            if ($option === '' || $value === '' || ! isset($optionNames[$this->normalize($option)])) {
+                continue;
+            }
+
+            $pairs[] = [$option, $value];
+        }
+
+        return $pairs;
+    }
+
+    /**
+     * @param  array<string, mixed>  $additional
+     */
+    private function additionalVariantSku(array &$additional, string $newSku): bool
+    {
+        $hasVariant = ! empty($additional['variant_id']) || array_key_exists('variant_sku', $additional);
+
+        if (! $hasVariant || (string) ($additional['variant_sku'] ?? '') === $newSku) {
+            return false;
+        }
+
+        $additional['variant_sku'] = $newSku;
+
+        return true;
+    }
+
+    /**
+     * @param  array<int, array{0: string, 1: string}>  $pairs
+     */
+    private function labelKey(array $pairs): string
+    {
+        $parts = [];
+
+        foreach ($pairs as [$option, $value]) {
+            $parts[] = $this->normalize($option)."\n".$value;
+        }
+
+        sort($parts);
+
+        return implode("\n---\n", $parts);
+    }
+
+    private function normalize(string $value): string
+    {
+        return mb_strtolower(trim($value));
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    private function decodeAdditional(mixed $value): array
+    {
+        if (is_array($value)) {
+            return $value;
+        }
+
+        if (is_string($value) && $value !== '') {
+            $decoded = json_decode($value, true);
+
+            return is_array($decoded) ? $decoded : [];
+        }
+
+        return [];
+    }
+
+    /**
+     * @param  array<int, string>  $skuUpdates
+     */
+    private function syncRelatedItemSkus(array $skuUpdates, bool $dryRun, string $now): void
+    {
+        if ($skuUpdates === []) {
+            return;
+        }
+
+        foreach (['invoice_items', 'shipment_items', 'refund_items'] as $table) {
+            if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'sku') || ! Schema::hasColumn($table, 'order_item_id')) {
+                continue;
+            }
+
+            $rows = DB::table($table)
+                ->whereIn('order_item_id', array_keys($skuUpdates))
+                ->get(['id', 'order_item_id', 'sku']);
+
+            $updates = [];
+
+            foreach ($rows as $row) {
+                $newSku = $skuUpdates[(int) $row->order_item_id] ?? null;
+
+                if ($newSku === null || (string) $row->sku === $newSku) {
+                    continue;
+                }
+
+                $updates[(int) $row->id] = $newSku;
+            }
+
+            $this->orderItems += count($updates);
+
+            if (! $dryRun && $updates !== []) {
+                $this->writeColumnSkus($table, $updates, $now);
+            }
+        }
+    }
+
+    /**
+     * @param  array<int, string>  $updates
+     */
+    private function writeColumnSkus(string $table, array $updates, string $now): void
+    {
+        foreach (array_chunk($updates, 200, true) as $chunk) {
+            $case = 'CASE id';
+            $bindings = [];
+
+            foreach ($chunk as $id => $sku) {
+                $case .= ' WHEN ? THEN ?';
+                $bindings[] = $id;
+                $bindings[] = $sku;
+            }
+
+            $case .= ' END';
+            $placeholders = implode(',', array_fill(0, count($chunk), '?'));
+
+            DB::update(
+                "UPDATE {$table} SET sku = {$case}, updated_at = ? WHERE id IN ({$placeholders})",
+                array_merge($bindings, [$now], array_keys($chunk))
+            );
+        }
+    }
+
+    /**
+     * @param  array<int, array<string, mixed>>  $updates
+     */
+    private function writeAdditional(array $updates, string $now): void
+    {
+        foreach ($updates as $id => $additional) {
+            $encoded = json_encode($additional, JSON_UNESCAPED_UNICODE);
+
+            if ($encoded === false) {
+                continue;
+            }
+
+            DB::table('order_items')->where('id', $id)->update([
+                'additional' => $encoded,
+                'updated_at' => $now,
+            ]);
+        }
+    }
+}

+ 22 - 5
app/Jobs/MigrateReviewJob.php

@@ -2,6 +2,7 @@
 
 namespace App\Jobs;
 
+use App\Services\Asteria\MagentoPrimaryKey;
 use Illuminate\Bus\Queueable;
 use Illuminate\Contracts\Queue\ShouldQueue;
 use Illuminate\Foundation\Bus\Dispatchable;
@@ -63,8 +64,15 @@ class MigrateReviewJob implements ShouldQueue
             }
 
             // Deduplicate: skip if already migrated (keyed on migrated_from_asteria_id).
+            $reviewId = (int) $row['review_id'];
+
+            if ($reviewId < 1) {
+                $skipped++;
+                continue;
+            }
+
             $exists = DB::table('product_reviews')
-                ->where('migrated_from_asteria_id', $row['review_id'])
+                ->where('migrated_from_asteria_id', $reviewId)
                 ->exists();
 
             if ($exists) {
@@ -72,6 +80,12 @@ class MigrateReviewJob implements ShouldQueue
                 continue;
             }
 
+            if (isset(MagentoPrimaryKey::occupiedIds('product_reviews', [$reviewId])[$reviewId])) {
+                Log::warning("MigrateReviewJob: product_reviews.id {$reviewId} already occupied; skipping Magento review {$reviewId}.");
+                $skipped++;
+                continue;
+            }
+
             $rating = $this->normaliseRating($row['avg_rating']);
             $status = $statusMap[$row['status_id']] ?? 'pending';
             $customerId = $row['customer_email']
@@ -83,6 +97,7 @@ class MigrateReviewJob implements ShouldQueue
             $name    = $this->truncate((string) ($row['name'] ?? ''), 255) ?: 'Guest';
 
             DB::table('product_reviews')->insert([
+                'id'                      => $reviewId,
                 'title'                   => $title,
                 'comment'                 => $comment,
                 'rating'                  => $rating,
@@ -90,21 +105,23 @@ class MigrateReviewJob implements ShouldQueue
                 'product_id'              => $bagistoProductId,
                 'customer_id'             => $customerId,
                 'name'                    => $name,
-                'migrated_from_asteria_id' => $row['review_id'],
+                'migrated_from_asteria_id' => $reviewId,
                 'created_at'              => $row['created_at'] ?? $now,
                 'updated_at'              => $now,
             ]);
 
-            $newReviewId = DB::getPdo()->lastInsertId();
-
             // Download and store review images from review_media_image.url
             if (! empty($row['images'])) {
-                $this->migrateImages((int) $newReviewId, $row['images']);
+                $this->migrateImages($reviewId, $row['images']);
             }
 
             $inserted++;
         }
 
+        if ($inserted > 0) {
+            MagentoPrimaryKey::bumpAutoIncrement('product_reviews');
+        }
+
         Log::info("MigrateReviewJob: inserted={$inserted}, skipped={$skipped}");
     }
 

+ 36 - 0
app/Providers/HorizonServiceProvider.php

@@ -0,0 +1,36 @@
+<?php
+
+namespace App\Providers;
+
+use Illuminate\Support\Facades\Gate;
+use Laravel\Horizon\Horizon;
+use Laravel\Horizon\HorizonApplicationServiceProvider;
+
+class HorizonServiceProvider extends HorizonApplicationServiceProvider
+{
+    /**
+     * Bootstrap any application services.
+     */
+    public function boot(): void
+    {
+        parent::boot();
+
+        // Horizon::routeSmsNotificationsTo('15556667777');
+        // Horizon::routeMailNotificationsTo('example@example.com');
+        // Horizon::routeSlackNotificationsTo('slack-webhook-url', '#channel');
+    }
+
+    /**
+     * Register the Horizon gate.
+     *
+     * This gate determines who can access Horizon in non-local environments.
+     */
+    protected function gate(): void
+    {
+        Gate::define('viewHorizon', function ($user = null) {
+            return in_array(optional($user)->email, [
+                //
+            ]);
+        });
+    }
+}

+ 46 - 3
app/Services/Asteria/Magento1ProductReader.php

@@ -307,7 +307,7 @@ class Magento1ProductReader
             'cost'                  => $eav['cost'] ?? '',
             'weight'                => $eav['weight'] ?? '',
             // Magento: 1=Enabled, 2=Disabled. Keep disabled products importable for review matching.
-            'status'                => ((int) ($eav['status'] ?? 1) === 1) ? 1 : 0,
+            'status'                => $this->mapMagentoStatus($eav['status'] ?? 1),
             'featured'              => 0,
             'new'                   => 0,
             'guest_checkout'        => 1,
@@ -338,6 +338,26 @@ class Magento1ProductReader
         return $row;
     }
 
+    /**
+     * Magento 1 product status: 1=Enabled, 2=Disabled. Bagisto uses 1/0.
+     */
+    private function mapMagentoStatus(mixed $value): int
+    {
+        $enabled = (int) ($this->config['enabled_status'] ?? 1);
+
+        if (is_numeric($value)) {
+            return (int) $value === $enabled ? 1 : 0;
+        }
+
+        $normalized = strtolower(trim((string) $value));
+
+        if (in_array($normalized, ['1', 'enabled', 'enable', 'yes', 'true'], true)) {
+            return 1;
+        }
+
+        return 0;
+    }
+
     /**
      * @param  array<int, int>  $entityIds
      * @return array<int, array<string, mixed>>
@@ -403,7 +423,9 @@ class Magento1ProductReader
         foreach ($attributes as $code => $info) {
             $input = (string) ($info->frontend_input ?? '');
 
-            if (! in_array($input, ['select', 'multiselect'], true)) {
+            // Magento status is a select (1=Enabled, 2=Disabled). Keep the numeric
+            // value so mapMagentoStatus can map it; converting to "Enabled" makes (int) 0.
+            if ($code === 'status' || ! in_array($input, ['select', 'multiselect'], true)) {
                 continue;
             }
 
@@ -810,7 +832,7 @@ class Magento1ProductReader
             ];
 
             foreach ($combo as $optTitle => $valueInfo) {
-                $skuParts[] = (string) ($valueInfo['sort_order'] ?? '');
+                $skuParts[] = $this->variantSkuPart($valueInfo);
                 $price += (float) ($valueInfo['price'] ?? 0);
                 $label = (string) ($valueInfo['title'] ?? '');
                 $variant[$optTitle] = $label;
@@ -825,6 +847,27 @@ class Magento1ProductReader
         return $variants;
     }
 
+    /**
+     * Magento option-value SKU. Fall back to the value title, then sort order,
+     * when that SKU is empty.
+     *
+     * @param  array<string, mixed>  $valueInfo
+     */
+    private function variantSkuPart(array $valueInfo): string
+    {
+        $part = trim((string) ($valueInfo['sku'] ?? ''));
+
+        if ($part === '') {
+            $part = trim((string) ($valueInfo['title'] ?? ''));
+        }
+
+        if ($part === '') {
+            $part = (string) ($valueInfo['sort_order'] ?? '');
+        }
+
+        return $part;
+    }
+
     /**
      * @param  array<string, mixed>  $option
      * @return array<string, mixed>

+ 55 - 0
app/Services/Asteria/MagentoPrimaryKey.php

@@ -0,0 +1,55 @@
+<?php
+
+namespace App\Services\Asteria;
+
+use Illuminate\Support\Facades\DB;
+
+/**
+ * Empty-catalog Asteria migration writes Magento entity_id / review_id as Bagisto PK.
+ */
+class MagentoPrimaryKey
+{
+    /**
+     * @param  array<int|string>  $ids
+     * @return array<int, int> occupied id => id
+     */
+    public static function occupiedIds(string $table, array $ids): array
+    {
+        $ids = array_values(array_unique(array_filter(
+            array_map('intval', $ids),
+            fn (int $id) => $id > 0
+        )));
+
+        if ($ids === []) {
+            return [];
+        }
+
+        return DB::table($table)
+            ->whereIn('id', $ids)
+            ->pluck('id')
+            ->map(fn ($id) => (int) $id)
+            ->flip()
+            ->all();
+    }
+
+    public static function bumpAutoIncrement(string $table): void
+    {
+        $driver = DB::connection()->getDriverName();
+
+        if (! in_array($driver, ['mysql', 'mariadb'], true)) {
+            return;
+        }
+
+        // DDL implicit-commits MySQL, which would leak rows from PHPUnit DatabaseTransactions.
+        if (DB::connection()->transactionLevel() > 0) {
+            return;
+        }
+
+        $max = (int) DB::table($table)->max('id');
+        $next = max(1, $max + 1);
+        $prefix = DB::connection()->getTablePrefix();
+        $safe = str_replace(['`', '.', ' '], '', $table);
+
+        DB::statement('ALTER TABLE `'.$prefix.$safe.'` AUTO_INCREMENT = '.$next);
+    }
+}

+ 96 - 92
bootstrap/cache/services.php

@@ -56,52 +56,54 @@
     52 => 'Longyi\\Blog\\Providers\\BlogServiceProvider',
     53 => 'Longyi\\Core\\Providers\\LongyiCoreServiceProvider',
     54 => 'Longyi\\DynamicMenu\\Providers\\DynamicMenuServiceProvider',
-    55 => 'Longyi\\Email\\Providers\\EmailServiceProvider',
-    56 => 'Longyi\\ImageUpload\\Providers\\ImageUploadServiceProvider',
-    57 => 'Longyi\\RewardPoints\\Providers\\RewardPointsServiceProvider',
-    58 => 'Longyi\\Member\\Providers\\MemberServiceProvider',
-    59 => 'Longyi\\Gift\\Providers\\GiftServiceProvider',
-    60 => 'Longyi\\ShippingInsurance\\Providers\\ShippingInsuranceServiceProvider',
-    61 => 'Longyi\\Pay\\AwxAfterpay\\Providers\\AwxAfterpayServiceProvider',
-    62 => 'Longyi\\Pay\\AwxKlarna\\Providers\\AwxKlarnaServiceProvider',
-    63 => 'Longyi\\Pay\\Applepay\\Providers\\ApplepayServiceProvider',
-    64 => 'Longyi\\Pay\\Airwallex\\Providers\\AirwallexServiceProvider',
-    65 => 'Longyi\\Pay\\Afterpay\\Providers\\AfterpayServiceProvider',
-    66 => 'Longyi\\Pay\\Klarna\\Providers\\KlarnaServiceProvider',
-    67 => 'Webkul\\Attribute\\Providers\\AttributeServiceProvider',
-    68 => 'Webkul\\BookingProduct\\Providers\\BookingProductServiceProvider',
-    69 => 'Webkul\\CMS\\Providers\\CMSServiceProvider',
-    70 => 'Webkul\\CartRule\\Providers\\CartRuleServiceProvider',
-    71 => 'Webkul\\CatalogRule\\Providers\\CatalogRuleServiceProvider',
-    72 => 'Webkul\\Category\\Providers\\CategoryServiceProvider',
-    73 => 'Webkul\\Checkout\\Providers\\CheckoutServiceProvider',
-    74 => 'Webkul\\Core\\Providers\\CoreServiceProvider',
-    75 => 'Webkul\\Core\\Providers\\EnvValidatorServiceProvider',
-    76 => 'Webkul\\Customer\\Providers\\CustomerServiceProvider',
-    77 => 'Webkul\\DataGrid\\Providers\\DataGridServiceProvider',
-    78 => 'Webkul\\DataTransfer\\Providers\\DataTransferServiceProvider',
-    79 => 'Webkul\\DebugBar\\Providers\\DebugBarServiceProvider',
-    80 => 'Webkul\\FPC\\Providers\\FPCServiceProvider',
-    81 => 'Webkul\\GDPR\\Providers\\GDPRServiceProvider',
-    82 => 'Webkul\\Installer\\Providers\\InstallerServiceProvider',
-    83 => 'Webkul\\Inventory\\Providers\\InventoryServiceProvider',
-    84 => 'Webkul\\MagicAI\\Providers\\MagicAIServiceProvider',
-    85 => 'Webkul\\Marketing\\Providers\\MarketingServiceProvider',
-    86 => 'Webkul\\Notification\\Providers\\NotificationServiceProvider',
-    87 => 'Webkul\\Payment\\Providers\\PaymentServiceProvider',
-    88 => 'Webkul\\Paypal\\Providers\\PaypalServiceProvider',
-    89 => 'Webkul\\Product\\Providers\\ProductServiceProvider',
-    90 => 'Webkul\\Rule\\Providers\\RuleServiceProvider',
-    91 => 'Webkul\\Sales\\Providers\\SalesServiceProvider',
-    92 => 'Webkul\\Shipping\\Providers\\ShippingServiceProvider',
-    93 => 'Webkul\\Shop\\Providers\\ShopServiceProvider',
-    94 => 'Webkul\\Sitemap\\Providers\\SitemapServiceProvider',
-    95 => 'Webkul\\SocialLogin\\Providers\\SocialLoginServiceProvider',
-    96 => 'Webkul\\SocialShare\\Providers\\SocialShareServiceProvider',
-    97 => 'Webkul\\Tax\\Providers\\TaxServiceProvider',
-    98 => 'Webkul\\Theme\\Providers\\ThemeServiceProvider',
-    99 => 'Webkul\\User\\Providers\\UserServiceProvider',
-    100 => 'Webkul\\BagistoApi\\Providers\\BagistoApiServiceProvider',
+    55 => 'Longyi\\FrontMenu\\Providers\\FrontMenuServiceProvider',
+    56 => 'Longyi\\Email\\Providers\\EmailServiceProvider',
+    57 => 'Longyi\\ImageUpload\\Providers\\ImageUploadServiceProvider',
+    58 => 'Longyi\\RewardPoints\\Providers\\RewardPointsServiceProvider',
+    59 => 'Longyi\\Member\\Providers\\MemberServiceProvider',
+    60 => 'Longyi\\Gift\\Providers\\GiftServiceProvider',
+    61 => 'Longyi\\ShippingInsurance\\Providers\\ShippingInsuranceServiceProvider',
+    62 => 'Longyi\\Pay\\AwxAfterpay\\Providers\\AwxAfterpayServiceProvider',
+    63 => 'Longyi\\Pay\\AwxKlarna\\Providers\\AwxKlarnaServiceProvider',
+    64 => 'Longyi\\Pay\\Applepay\\Providers\\ApplepayServiceProvider',
+    65 => 'Longyi\\Pay\\Airwallex\\Providers\\AirwallexServiceProvider',
+    66 => 'Longyi\\Pay\\Afterpay\\Providers\\AfterpayServiceProvider',
+    67 => 'Longyi\\Pay\\Klarna\\Providers\\KlarnaServiceProvider',
+    68 => 'Webkul\\Attribute\\Providers\\AttributeServiceProvider',
+    69 => 'Webkul\\BookingProduct\\Providers\\BookingProductServiceProvider',
+    70 => 'Webkul\\CMS\\Providers\\CMSServiceProvider',
+    71 => 'Webkul\\CartRule\\Providers\\CartRuleServiceProvider',
+    72 => 'Longyi\\FreeGift\\Providers\\FreeGiftServiceProvider',
+    73 => 'Webkul\\CatalogRule\\Providers\\CatalogRuleServiceProvider',
+    74 => 'Webkul\\Category\\Providers\\CategoryServiceProvider',
+    75 => 'Webkul\\Checkout\\Providers\\CheckoutServiceProvider',
+    76 => 'Webkul\\Core\\Providers\\CoreServiceProvider',
+    77 => 'Webkul\\Core\\Providers\\EnvValidatorServiceProvider',
+    78 => 'Webkul\\Customer\\Providers\\CustomerServiceProvider',
+    79 => 'Webkul\\DataGrid\\Providers\\DataGridServiceProvider',
+    80 => 'Webkul\\DataTransfer\\Providers\\DataTransferServiceProvider',
+    81 => 'Webkul\\DebugBar\\Providers\\DebugBarServiceProvider',
+    82 => 'Webkul\\FPC\\Providers\\FPCServiceProvider',
+    83 => 'Webkul\\GDPR\\Providers\\GDPRServiceProvider',
+    84 => 'Webkul\\Installer\\Providers\\InstallerServiceProvider',
+    85 => 'Webkul\\Inventory\\Providers\\InventoryServiceProvider',
+    86 => 'Webkul\\MagicAI\\Providers\\MagicAIServiceProvider',
+    87 => 'Webkul\\Marketing\\Providers\\MarketingServiceProvider',
+    88 => 'Webkul\\Notification\\Providers\\NotificationServiceProvider',
+    89 => 'Webkul\\Payment\\Providers\\PaymentServiceProvider',
+    90 => 'Webkul\\Paypal\\Providers\\PaypalServiceProvider',
+    91 => 'Webkul\\Product\\Providers\\ProductServiceProvider',
+    92 => 'Webkul\\Rule\\Providers\\RuleServiceProvider',
+    93 => 'Webkul\\Sales\\Providers\\SalesServiceProvider',
+    94 => 'Webkul\\Shipping\\Providers\\ShippingServiceProvider',
+    95 => 'Webkul\\Shop\\Providers\\ShopServiceProvider',
+    96 => 'Webkul\\Sitemap\\Providers\\SitemapServiceProvider',
+    97 => 'Webkul\\SocialLogin\\Providers\\SocialLoginServiceProvider',
+    98 => 'Webkul\\SocialShare\\Providers\\SocialShareServiceProvider',
+    99 => 'Webkul\\Tax\\Providers\\TaxServiceProvider',
+    100 => 'Webkul\\Theme\\Providers\\ThemeServiceProvider',
+    101 => 'Webkul\\User\\Providers\\UserServiceProvider',
+    102 => 'Webkul\\BagistoApi\\Providers\\BagistoApiServiceProvider',
   ),
   'eager' => 
   array (
@@ -143,52 +145,54 @@
     35 => 'Longyi\\Blog\\Providers\\BlogServiceProvider',
     36 => 'Longyi\\Core\\Providers\\LongyiCoreServiceProvider',
     37 => 'Longyi\\DynamicMenu\\Providers\\DynamicMenuServiceProvider',
-    38 => 'Longyi\\Email\\Providers\\EmailServiceProvider',
-    39 => 'Longyi\\ImageUpload\\Providers\\ImageUploadServiceProvider',
-    40 => 'Longyi\\RewardPoints\\Providers\\RewardPointsServiceProvider',
-    41 => 'Longyi\\Member\\Providers\\MemberServiceProvider',
-    42 => 'Longyi\\Gift\\Providers\\GiftServiceProvider',
-    43 => 'Longyi\\ShippingInsurance\\Providers\\ShippingInsuranceServiceProvider',
-    44 => 'Longyi\\Pay\\AwxAfterpay\\Providers\\AwxAfterpayServiceProvider',
-    45 => 'Longyi\\Pay\\AwxKlarna\\Providers\\AwxKlarnaServiceProvider',
-    46 => 'Longyi\\Pay\\Applepay\\Providers\\ApplepayServiceProvider',
-    47 => 'Longyi\\Pay\\Airwallex\\Providers\\AirwallexServiceProvider',
-    48 => 'Longyi\\Pay\\Afterpay\\Providers\\AfterpayServiceProvider',
-    49 => 'Longyi\\Pay\\Klarna\\Providers\\KlarnaServiceProvider',
-    50 => 'Webkul\\Attribute\\Providers\\AttributeServiceProvider',
-    51 => 'Webkul\\BookingProduct\\Providers\\BookingProductServiceProvider',
-    52 => 'Webkul\\CMS\\Providers\\CMSServiceProvider',
-    53 => 'Webkul\\CartRule\\Providers\\CartRuleServiceProvider',
-    54 => 'Webkul\\CatalogRule\\Providers\\CatalogRuleServiceProvider',
-    55 => 'Webkul\\Category\\Providers\\CategoryServiceProvider',
-    56 => 'Webkul\\Checkout\\Providers\\CheckoutServiceProvider',
-    57 => 'Webkul\\Core\\Providers\\CoreServiceProvider',
-    58 => 'Webkul\\Core\\Providers\\EnvValidatorServiceProvider',
-    59 => 'Webkul\\Customer\\Providers\\CustomerServiceProvider',
-    60 => 'Webkul\\DataGrid\\Providers\\DataGridServiceProvider',
-    61 => 'Webkul\\DataTransfer\\Providers\\DataTransferServiceProvider',
-    62 => 'Webkul\\DebugBar\\Providers\\DebugBarServiceProvider',
-    63 => 'Webkul\\FPC\\Providers\\FPCServiceProvider',
-    64 => 'Webkul\\GDPR\\Providers\\GDPRServiceProvider',
-    65 => 'Webkul\\Installer\\Providers\\InstallerServiceProvider',
-    66 => 'Webkul\\Inventory\\Providers\\InventoryServiceProvider',
-    67 => 'Webkul\\MagicAI\\Providers\\MagicAIServiceProvider',
-    68 => 'Webkul\\Marketing\\Providers\\MarketingServiceProvider',
-    69 => 'Webkul\\Notification\\Providers\\NotificationServiceProvider',
-    70 => 'Webkul\\Payment\\Providers\\PaymentServiceProvider',
-    71 => 'Webkul\\Paypal\\Providers\\PaypalServiceProvider',
-    72 => 'Webkul\\Product\\Providers\\ProductServiceProvider',
-    73 => 'Webkul\\Rule\\Providers\\RuleServiceProvider',
-    74 => 'Webkul\\Sales\\Providers\\SalesServiceProvider',
-    75 => 'Webkul\\Shipping\\Providers\\ShippingServiceProvider',
-    76 => 'Webkul\\Shop\\Providers\\ShopServiceProvider',
-    77 => 'Webkul\\Sitemap\\Providers\\SitemapServiceProvider',
-    78 => 'Webkul\\SocialLogin\\Providers\\SocialLoginServiceProvider',
-    79 => 'Webkul\\SocialShare\\Providers\\SocialShareServiceProvider',
-    80 => 'Webkul\\Tax\\Providers\\TaxServiceProvider',
-    81 => 'Webkul\\Theme\\Providers\\ThemeServiceProvider',
-    82 => 'Webkul\\User\\Providers\\UserServiceProvider',
-    83 => 'Webkul\\BagistoApi\\Providers\\BagistoApiServiceProvider',
+    38 => 'Longyi\\FrontMenu\\Providers\\FrontMenuServiceProvider',
+    39 => 'Longyi\\Email\\Providers\\EmailServiceProvider',
+    40 => 'Longyi\\ImageUpload\\Providers\\ImageUploadServiceProvider',
+    41 => 'Longyi\\RewardPoints\\Providers\\RewardPointsServiceProvider',
+    42 => 'Longyi\\Member\\Providers\\MemberServiceProvider',
+    43 => 'Longyi\\Gift\\Providers\\GiftServiceProvider',
+    44 => 'Longyi\\ShippingInsurance\\Providers\\ShippingInsuranceServiceProvider',
+    45 => 'Longyi\\Pay\\AwxAfterpay\\Providers\\AwxAfterpayServiceProvider',
+    46 => 'Longyi\\Pay\\AwxKlarna\\Providers\\AwxKlarnaServiceProvider',
+    47 => 'Longyi\\Pay\\Applepay\\Providers\\ApplepayServiceProvider',
+    48 => 'Longyi\\Pay\\Airwallex\\Providers\\AirwallexServiceProvider',
+    49 => 'Longyi\\Pay\\Afterpay\\Providers\\AfterpayServiceProvider',
+    50 => 'Longyi\\Pay\\Klarna\\Providers\\KlarnaServiceProvider',
+    51 => 'Webkul\\Attribute\\Providers\\AttributeServiceProvider',
+    52 => 'Webkul\\BookingProduct\\Providers\\BookingProductServiceProvider',
+    53 => 'Webkul\\CMS\\Providers\\CMSServiceProvider',
+    54 => 'Webkul\\CartRule\\Providers\\CartRuleServiceProvider',
+    55 => 'Longyi\\FreeGift\\Providers\\FreeGiftServiceProvider',
+    56 => 'Webkul\\CatalogRule\\Providers\\CatalogRuleServiceProvider',
+    57 => 'Webkul\\Category\\Providers\\CategoryServiceProvider',
+    58 => 'Webkul\\Checkout\\Providers\\CheckoutServiceProvider',
+    59 => 'Webkul\\Core\\Providers\\CoreServiceProvider',
+    60 => 'Webkul\\Core\\Providers\\EnvValidatorServiceProvider',
+    61 => 'Webkul\\Customer\\Providers\\CustomerServiceProvider',
+    62 => 'Webkul\\DataGrid\\Providers\\DataGridServiceProvider',
+    63 => 'Webkul\\DataTransfer\\Providers\\DataTransferServiceProvider',
+    64 => 'Webkul\\DebugBar\\Providers\\DebugBarServiceProvider',
+    65 => 'Webkul\\FPC\\Providers\\FPCServiceProvider',
+    66 => 'Webkul\\GDPR\\Providers\\GDPRServiceProvider',
+    67 => 'Webkul\\Installer\\Providers\\InstallerServiceProvider',
+    68 => 'Webkul\\Inventory\\Providers\\InventoryServiceProvider',
+    69 => 'Webkul\\MagicAI\\Providers\\MagicAIServiceProvider',
+    70 => 'Webkul\\Marketing\\Providers\\MarketingServiceProvider',
+    71 => 'Webkul\\Notification\\Providers\\NotificationServiceProvider',
+    72 => 'Webkul\\Payment\\Providers\\PaymentServiceProvider',
+    73 => 'Webkul\\Paypal\\Providers\\PaypalServiceProvider',
+    74 => 'Webkul\\Product\\Providers\\ProductServiceProvider',
+    75 => 'Webkul\\Rule\\Providers\\RuleServiceProvider',
+    76 => 'Webkul\\Sales\\Providers\\SalesServiceProvider',
+    77 => 'Webkul\\Shipping\\Providers\\ShippingServiceProvider',
+    78 => 'Webkul\\Shop\\Providers\\ShopServiceProvider',
+    79 => 'Webkul\\Sitemap\\Providers\\SitemapServiceProvider',
+    80 => 'Webkul\\SocialLogin\\Providers\\SocialLoginServiceProvider',
+    81 => 'Webkul\\SocialShare\\Providers\\SocialShareServiceProvider',
+    82 => 'Webkul\\Tax\\Providers\\TaxServiceProvider',
+    83 => 'Webkul\\Theme\\Providers\\ThemeServiceProvider',
+    84 => 'Webkul\\User\\Providers\\UserServiceProvider',
+    85 => 'Webkul\\BagistoApi\\Providers\\BagistoApiServiceProvider',
   ),
   'deferred' => 
   array (

+ 2 - 0
bootstrap/providers.php

@@ -18,6 +18,7 @@ return [
     Longyi\ImageUpload\Providers\ImageUploadServiceProvider::class,
     Longyi\RewardPoints\Providers\RewardPointsServiceProvider::class,
     Longyi\Member\Providers\MemberServiceProvider::class,
+    Longyi\Wholesale\Providers\WholesaleServiceProvider::class,
     Longyi\Gift\Providers\GiftServiceProvider::class,
     Longyi\ShippingInsurance\Providers\ShippingInsuranceServiceProvider::class,
     Longyi\Pay\AwxAfterpay\Providers\AwxAfterpayServiceProvider::class,
@@ -30,6 +31,7 @@ return [
     Webkul\BookingProduct\Providers\BookingProductServiceProvider::class,
     Webkul\CMS\Providers\CMSServiceProvider::class,
     Webkul\CartRule\Providers\CartRuleServiceProvider::class,
+    Longyi\FreeGift\Providers\FreeGiftServiceProvider::class,
     Webkul\CatalogRule\Providers\CatalogRuleServiceProvider::class,
     Webkul\Category\Providers\CategoryServiceProvider::class,
     Webkul\Checkout\Providers\CheckoutServiceProvider::class,

+ 7 - 1
composer.json

@@ -41,6 +41,7 @@
         "khaled.alshamaa/ar-php": "^6.0.0",
         "konekt/concord": "^1.2",
         "laravel/framework": "^11.0",
+        "laravel/horizon": "^5.50",
         "laravel/octane": "^2.3",
         "laravel/sanctum": "^4.0",
         "laravel/socialite": "^5.0",
@@ -82,10 +83,13 @@
             "Longyi\\DynamicMenu\\": "packages/Longyi/DynamicMenu/src/",
             "Longyi\\FrontMenu\\": "packages/Longyi/FrontMenu/src/",
             "Longyi\\Email\\": "packages/Longyi/Email/src/",
+            "Longyi\\Feishu\\": "packages/Longyi/Feishu/src/",
             "Longyi\\ImageUpload\\": "packages/Longyi/ImageUpload/src/",
             "Longyi\\RewardPoints\\": "packages/Longyi/RewardPoints/src/",
             "Longyi\\Member\\": "packages/Longyi/Member/src/",
+            "Longyi\\Wholesale\\": "packages/Longyi/Wholesale/src/",
             "Longyi\\Gift\\": "packages/Longyi/Gift/src/",
+            "Longyi\\FreeGift\\": "packages/Longyi/FreeGift/src/",
             "Longyi\\ShippingInsurance\\": "packages/Longyi/ShippingInsurance/src/",
             "Longyi\\Pay\\Airwallex\\": "packages/Longyi/Pay/Airwallex/src/",
             "Longyi\\Pay\\Applepay\\": "packages/Longyi/Pay/Applepay/src/",
@@ -138,7 +142,9 @@
             "Webkul\\DataGrid\\Tests\\": "packages/Webkul/DataGrid/tests",
             "Webkul\\Installer\\Tests\\": "packages/Webkul/Installer/tests",
             "Webkul\\Shop\\Tests\\": "packages/Webkul/Shop/tests",
-            "Webkul\\BagistoApi\\Tests\\": "packages/Webkul/BagistoApi/tests"
+            "Webkul\\BagistoApi\\Tests\\": "packages/Webkul/BagistoApi/tests",
+            "Longyi\\Core\\Tests\\": "packages/Longyi/Core/tests/",
+            "Longyi\\Feishu\\Tests\\": "packages/Longyi/Feishu/tests/"
         }
     },
     "scripts": {

+ 15 - 2
config/api-platform.php

@@ -15,9 +15,10 @@ use ApiPlatform\Metadata\Operation\DashPathSegmentNameGenerator;
 use ApiPlatform\Metadata\UrlGeneratorInterface;
 use Illuminate\Auth\Access\AuthorizationException;
 use Illuminate\Auth\AuthenticationException;
-use Symfony\Component\Serializer\NameConverter\SnakeCaseToCamelCaseNameConverter;
 use Webkul\BagistoApi\Exception\InvalidInputException;
 use Webkul\BagistoApi\Exception\ValidationException;
+use Webkul\BagistoApi\Serializer\OutputOnlySnakeToCamelNameConverter;
+
 
 return [
     'title'       => '',
@@ -37,26 +38,38 @@ return [
         // - Caches shop pages for performance
         // - Only caches HTML, not JSON responses
         'middleware' => [
+            'Webkul\BagistoApi\Http\Middleware\ParseMultipartFormData',
+            'Webkul\BagistoApi\Http\Middleware\NormalizeEmptyJsonBody',
             'Webkul\BagistoApi\Http\Middleware\HandleInvalidInputException',
             'Webkul\BagistoApi\Http\Middleware\SecurityHeaders',
             'Webkul\BagistoApi\Http\Middleware\LogApiRequests',
             'Webkul\BagistoApi\Http\Middleware\SetLocaleChannel',
             'Webkul\BagistoApi\Http\Middleware\VerifyStorefrontKey',
+            'Webkul\BagistoApi\Http\Middleware\EnforceAdminApiAuth',
+            'Webkul\BagistoApi\Http\Middleware\ThrottleAdminApi',
+            'Webkul\BagistoApi\Http\Middleware\SetAdminApiAuditContext',
             'Webkul\BagistoApi\Http\Middleware\BagistoApiDocumentationMiddleware',
             'Webkul\BagistoApi\Http\Middleware\ForceApiJson',
             'Webkul\BagistoApi\Http\Middleware\PaginationHeaders',
             'Webkul\BagistoApi\Http\Middleware\SearchMetadataResponse',
             'Webkul\BagistoApi\Http\Middleware\WrapApiResponse',
+            'Webkul\BagistoApi\Http\Middleware\PaginationHeaders',
             'Spatie\ResponseCache\Middlewares\CacheResponse',
         ],
     ],
 
     'resources' => [
         base_path('packages/Webkul/BagistoApi/src/Models/'),
+        base_path('packages/Webkul/BagistoApi/src/Admin/Models/'),
+        base_path('packages/Webkul/BagistoApi/src/Admin/Dto/'),
         base_path('packages/Webkul/BagistoApi/src/Dto/ProductDetail/'),
         base_path('packages/Webkul/BagistoApi/src/Dto/CustomerOrder/'),
         base_path('packages/Webkul/BagistoApi/src/Dto/ProductSearch/'),
         base_path('packages/Webkul/BagistoApi/src/Dto/CategoryProducts/'),
+
+        // Longyi\FreeGift:GraphQL 赠品候选(giftCandidates)与
+        // 领取 / 移除 / 恢复(createAddGift、createRemoveGift、createRestoreGifts)
+        base_path('packages/Longyi/FreeGift/src/ApiResource/'),
     ],
 
     'formats' => [
@@ -123,7 +136,7 @@ return [
         'enabled' => true,
     ],
 
-    'name_converter' => SnakeCaseToCamelCaseNameConverter::class,
+    'name_converter' => OutputOnlySnakeToCamelNameConverter::class,
 
     'path_segment_name_generator' => DashPathSegmentNameGenerator::class,
 

+ 254 - 0
config/horizon.php

@@ -0,0 +1,254 @@
+<?php
+
+use Illuminate\Support\Str;
+
+return [
+
+    /*
+    |--------------------------------------------------------------------------
+    | Horizon Name
+    |--------------------------------------------------------------------------
+    |
+    | This name appears in notifications and in the Horizon UI. Unique names
+    | can be useful while running multiple instances of Horizon within an
+    | application, allowing you to identify the Horizon you're viewing.
+    |
+    */
+
+    'name' => env('HORIZON_NAME'),
+
+    /*
+    |--------------------------------------------------------------------------
+    | Horizon Domain
+    |--------------------------------------------------------------------------
+    |
+    | This is the subdomain where Horizon will be accessible from. If this
+    | setting is null, Horizon will reside under the same domain as the
+    | application. Otherwise, this value will serve as the subdomain.
+    |
+    */
+
+    'domain' => env('HORIZON_DOMAIN'),
+
+    /*
+    |--------------------------------------------------------------------------
+    | Horizon Path
+    |--------------------------------------------------------------------------
+    |
+    | This is the URI path where Horizon will be accessible from. Feel free
+    | to change this path to anything you like. Note that the URI will not
+    | affect the paths of its internal API that aren't exposed to users.
+    |
+    */
+
+    'path' => env('HORIZON_PATH', 'horizon'),
+
+    /*
+    |--------------------------------------------------------------------------
+    | Horizon Redis Connection
+    |--------------------------------------------------------------------------
+    |
+    | This is the name of the Redis connection where Horizon will store the
+    | meta information required for it to function. It includes the list
+    | of supervisors, failed jobs, job metrics, and other information.
+    |
+    */
+
+    'use' => 'default',
+
+    /*
+    |--------------------------------------------------------------------------
+    | Horizon Redis Prefix
+    |--------------------------------------------------------------------------
+    |
+    | This prefix will be used when storing all Horizon data in Redis. You
+    | may modify the prefix when you are running multiple installations
+    | of Horizon on the same server so that they don't have problems.
+    |
+    */
+
+    'prefix' => env(
+        'HORIZON_PREFIX',
+        Str::slug(env('APP_NAME', 'laravel'), '_').'_horizon:'
+    ),
+
+    /*
+    |--------------------------------------------------------------------------
+    | Horizon Route Middleware
+    |--------------------------------------------------------------------------
+    |
+    | These middleware will get attached onto each Horizon route, giving you
+    | the chance to add your own middleware to this list or change any of
+    | the existing middleware. Or, you can simply stick with this list.
+    |
+    */
+
+    'middleware' => ['web'],
+
+    /*
+    |--------------------------------------------------------------------------
+    | Queue Wait Time Thresholds
+    |--------------------------------------------------------------------------
+    |
+    | This option allows you to configure when the LongWaitDetected event
+    | will be fired. Every connection / queue combination may have its
+    | own, unique threshold (in seconds) before this event is fired.
+    |
+    */
+
+    'waits' => [
+        'redis:default' => 60,
+    ],
+
+    /*
+    |--------------------------------------------------------------------------
+    | Job Trimming Times
+    |--------------------------------------------------------------------------
+    |
+    | Here you can configure for how long (in minutes) you desire Horizon to
+    | persist the recent and failed jobs. Typically, recent jobs are kept
+    | for one hour while all failed jobs are stored for an entire week.
+    |
+    */
+
+    'trim' => [
+        'recent' => 60,
+        'pending' => 60,
+        'completed' => 60,
+        'recent_failed' => 10080,
+        'failed' => 10080,
+        'monitored' => 10080,
+    ],
+
+    /*
+    |--------------------------------------------------------------------------
+    | Silenced Jobs
+    |--------------------------------------------------------------------------
+    |
+    | Silencing a job will instruct Horizon to not place the job in the list
+    | of completed jobs within the Horizon dashboard. This setting may be
+    | used to fully remove any noisy jobs from the completed jobs list.
+    |
+    */
+
+    'silenced' => [
+        // App\Jobs\ExampleJob::class,
+    ],
+
+    'silenced_tags' => [
+        // 'notifications',
+    ],
+
+    /*
+    |--------------------------------------------------------------------------
+    | Metrics
+    |--------------------------------------------------------------------------
+    |
+    | Here you can configure how many snapshots should be kept to display in
+    | the metrics graph. This will get used in combination with Horizon's
+    | `horizon:snapshot` schedule to define how long to retain metrics.
+    |
+    */
+
+    'metrics' => [
+        'trim_snapshots' => [
+            'job' => 24,
+            'queue' => 24,
+        ],
+    ],
+
+    /*
+    |--------------------------------------------------------------------------
+    | Fast Termination
+    |--------------------------------------------------------------------------
+    |
+    | When this option is enabled, Horizon's "terminate" command will not
+    | wait on all of the workers to terminate unless the --wait option
+    | is provided. Fast termination can shorten deployment delay by
+    | allowing a new instance of Horizon to start while the last
+    | instance will continue to terminate each of its workers.
+    |
+    */
+
+    'fast_termination' => false,
+
+    /*
+    |--------------------------------------------------------------------------
+    | Memory Limit (MB)
+    |--------------------------------------------------------------------------
+    |
+    | This value describes the maximum amount of memory the Horizon master
+    | supervisor may consume before it is terminated and restarted. For
+    | configuring these limits on your workers, see the next section.
+    |
+    */
+
+    'memory_limit' => 64,
+
+    /*
+    |--------------------------------------------------------------------------
+    | Queue Worker Configuration
+    |--------------------------------------------------------------------------
+    |
+    | Here you may define the queue worker settings used by your application
+    | in all environments. These supervisors and settings handle all your
+    | queued jobs and will be provisioned by Horizon during deployment.
+    |
+    */
+
+    'defaults' => [
+        'supervisor-1' => [
+            'connection' => 'redis',
+            'queue' => ['default'],
+            'balance' => 'auto',
+            'autoScalingStrategy' => 'time',
+            'maxProcesses' => 1,
+            'maxTime' => 0,
+            'maxJobs' => 0,
+            'memory' => 128,
+            'tries' => 1,
+            'timeout' => 60,
+            'nice' => 0,
+        ],
+    ],
+
+    'environments' => [
+        'production' => [
+            'supervisor-1' => [
+                'maxProcesses' => 10,
+                'balanceMaxShift' => 1,
+                'balanceCooldown' => 3,
+            ],
+        ],
+
+        'local' => [
+            'supervisor-1' => [
+                'maxProcesses' => 3,
+            ],
+        ],
+    ],
+
+    /*
+    |--------------------------------------------------------------------------
+    | File Watcher Configuration
+    |--------------------------------------------------------------------------
+    |
+    | The following list of directories and files will be watched when using
+    | the `horizon:listen` command. Whenever any directories or files are
+    | changed, Horizon will automatically restart to apply all changes.
+    |
+    */
+
+    'watch' => [
+        'app',
+        'bootstrap',
+        'config/**/*.php',
+        'database/**/*.php',
+        'public/**/*.php',
+        'resources/**/*.php',
+        'routes',
+        'composer.lock',
+        'composer.json',
+        '.env',
+    ],
+];

+ 2 - 2
config/purify.php

@@ -42,9 +42,9 @@ return [
         'default' => [
             'Core.Encoding'            => 'utf-8',
             'HTML.Doctype'             => 'HTML 4.01 Transitional',
-            'HTML.Allowed'             => 'h1,h2,h3,h4,h5,h6,b,u,strong,i,em,s,del,a[href|title],ul,ol,li,p[style],br,span[style],img[width|height|alt|src],blockquote',
+            'HTML.Allowed'             => 'h1,h2,h3,h4,h5,h6,b,u,strong,i,em,s,del,a[href|title],ul,ol,li,p[style],br,span[style],img[width|height|alt|src|style],blockquote,table[style],caption,thead[style],tbody[style],tfoot[style],tr[style],td[colspan|rowspan|style],th[colspan|rowspan|style],div[style|class],video[src|controls|width|height|poster],source[src|type]',
             'HTML.ForbiddenElements'   => '',
-            'CSS.AllowedProperties'    => 'font,font-size,font-weight,font-style,font-family,text-decoration,padding-left,color,background-color,text-align',
+            'CSS.AllowedProperties'    => 'font,font-size,font-weight,font-style,font-family,text-decoration,padding-left,color,background-color,text-align,width,height,border,border-collapse,vertical-align,margin,margin-left,margin-right,float',
             'AutoFormat.AutoParagraph' => false,
             'AutoFormat.RemoveEmpty'   => false,
         ],

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 31 - 14
docs/asteria-migration.md


+ 20 - 0
packages/Longyi/Core/src/Config/acl.php

@@ -0,0 +1,20 @@
+<?php
+
+return [
+    [
+        'key'   => 'catalog.product_sync',
+        'name'  => 'longyi::app.product-sync.title',
+        'route' => 'admin.catalog.products.sync.index',
+        'sort'  => 5,
+    ], [
+        'key'   => 'catalog.product_sync.export',
+        'name'  => 'longyi::app.product-sync.export',
+        'route' => 'admin.catalog.products.sync.export',
+        'sort'  => 1,
+    ], [
+        'key'   => 'catalog.product_sync.import',
+        'name'  => 'longyi::app.product-sync.import',
+        'route' => 'admin.catalog.products.sync.import',
+        'sort'  => 2,
+    ],
+];

+ 11 - 0
packages/Longyi/Core/src/Config/admin-menu.php

@@ -0,0 +1,11 @@
+<?php
+
+return [
+    [
+        'key'   => 'catalog.product_sync',
+        'name'  => 'longyi::app.product-sync.title',
+        'route' => 'admin.catalog.products.sync.index',
+        'sort'  => 2,
+        'icon'  => '',
+    ],
+];

+ 36 - 0
packages/Longyi/Core/src/Database/Migrations/2026_09_16_000001_create_product_sync_imports_table.php

@@ -0,0 +1,36 @@
+<?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::create('product_sync_imports', function (Blueprint $table) {
+            $table->increments('id');
+            $table->unsignedInteger('admin_id')->nullable();
+            $table->string('original_filename');
+            $table->string('disk_path');
+            $table->string('status', 32)->default('pending');
+            $table->unsignedInteger('updated_count')->default(0);
+            $table->unsignedInteger('failed_count')->default(0);
+            $table->json('errors')->nullable();
+            $table->text('message')->nullable();
+            $table->string('locale', 20)->nullable();
+            $table->timestamp('started_at')->nullable();
+            $table->timestamp('finished_at')->nullable();
+            $table->timestamp('seen_at')->nullable();
+            $table->timestamps();
+
+            $table->index(['admin_id', 'status']);
+            $table->foreign('admin_id')->references('id')->on('admins')->nullOnDelete();
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('product_sync_imports');
+    }
+};

+ 42 - 0
packages/Longyi/Core/src/Database/Migrations/2026_09_16_000002_add_generic_fields_to_notifications_table.php

@@ -0,0 +1,42 @@
+<?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('notifications', function (Blueprint $table) {
+            $table->dropForeign(['order_id']);
+        });
+
+        Schema::table('notifications', function (Blueprint $table) {
+            $table->unsignedInteger('order_id')->nullable()->change();
+            $table->string('title')->nullable()->after('type');
+            $table->text('description')->nullable()->after('title');
+            $table->string('route')->nullable()->after('description');
+            $table->unsignedInteger('admin_id')->nullable()->after('route');
+
+            $table->foreign('order_id')->references('id')->on('orders')->nullOnDelete();
+            $table->foreign('admin_id')->references('id')->on('admins')->nullOnDelete();
+            $table->index(['type', 'read']);
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('notifications', function (Blueprint $table) {
+            $table->dropForeign(['order_id']);
+            $table->dropForeign(['admin_id']);
+            $table->dropIndex(['type', 'read']);
+            $table->dropColumn(['title', 'description', 'route', 'admin_id']);
+        });
+
+        Schema::table('notifications', function (Blueprint $table) {
+            $table->unsignedInteger('order_id')->nullable(false)->change();
+            $table->foreign('order_id')->references('id')->on('orders')->cascadeOnDelete();
+        });
+    }
+};

+ 140 - 0
packages/Longyi/Core/src/Exports/ProductBasicInfoExport.php

@@ -0,0 +1,140 @@
+<?php
+
+namespace Longyi\Core\Exports;
+
+use Illuminate\Support\Facades\DB;
+use Longyi\Core\Support\ProductBasicInfoColumns;
+use Maatwebsite\Excel\Concerns\FromQuery;
+use Maatwebsite\Excel\Concerns\ShouldAutoSize;
+use Maatwebsite\Excel\Concerns\WithHeadings;
+use Maatwebsite\Excel\Concerns\WithMapping;
+
+class ProductBasicInfoExport implements FromQuery, ShouldAutoSize, WithHeadings, WithMapping
+{
+    /**
+     * @var array<int, string>
+     */
+    protected array $categoryNames = [];
+
+    /**
+     * @var array<int, int>
+     */
+    protected array $quantities = [];
+
+    public function __construct(
+        protected string $locale,
+        protected ?string $channel = null,
+    ) {
+        $this->categoryNames = $this->loadCategoryNames();
+        $this->quantities = $this->loadQuantities();
+    }
+
+    public function headings(): array
+    {
+        return ProductBasicInfoColumns::HEADINGS;
+    }
+
+    public function query()
+    {
+        $query = DB::table('product_flat')
+            ->select([
+                'product_flat.product_id',
+                'product_flat.sku',
+                'product_flat.locale',
+                'product_flat.name',
+                'product_flat.url_key',
+                'product_flat.short_description',
+                'product_flat.description',
+                'product_flat.price',
+                'product_flat.special_price',
+                'product_flat.weight',
+                'product_flat.status',
+                'product_flat.meta_title',
+                'product_flat.meta_keywords',
+                'product_flat.meta_description',
+            ])
+            ->where('product_flat.locale', $this->locale)
+            ->orderBy('product_flat.id');
+
+        if ($this->channel) {
+            $query->where('product_flat.channel', $this->channel);
+        }
+
+        return $query;
+    }
+
+    /**
+     * @param  object  $row
+     */
+    public function map($row): array
+    {
+        $productId = (int) $row->product_id;
+
+        return [
+            $productId,
+            $row->sku,
+            $row->locale,
+            $row->name,
+            $row->url_key,
+            $row->short_description,
+            $row->description,
+            $row->price,
+            $row->special_price,
+            $row->weight,
+            $row->status === null ? '' : (int) $row->status,
+            $this->quantities[$productId] ?? '',
+            $this->categoryNames[$productId] ?? '',
+            $row->meta_title,
+            $row->meta_keywords,
+            $row->meta_description,
+        ];
+    }
+
+    /**
+     * @return array<int, string>
+     */
+    protected function loadCategoryNames(): array
+    {
+        $locale = $this->locale;
+
+        $rows = DB::table('product_categories as pc')
+            ->join('category_translations as ct', function ($join) use ($locale) {
+                $join->on('pc.category_id', '=', 'ct.category_id')
+                    ->where('ct.locale', $locale);
+            })
+            ->orderBy('pc.product_id')
+            ->orderBy('pc.position')
+            ->get(['pc.product_id', 'ct.name']);
+
+        $map = [];
+
+        foreach ($rows as $row) {
+            $productId = (int) $row->product_id;
+            $name = trim((string) $row->name);
+
+            if ($name === '') {
+                continue;
+            }
+
+            $map[$productId][] = $name;
+        }
+
+        return array_map(
+            fn (array $names) => implode('|', array_values(array_unique($names))),
+            $map
+        );
+    }
+
+    /**
+     * @return array<int, int>
+     */
+    protected function loadQuantities(): array
+    {
+        return DB::table('product_inventories')
+            ->select('product_id', DB::raw('SUM(qty) as qty'))
+            ->groupBy('product_id')
+            ->pluck('qty', 'product_id')
+            ->map(fn ($qty) => (int) $qty)
+            ->all();
+    }
+}

+ 27 - 12
packages/Longyi/Core/src/Helpers/Indexers/Price/FlexibleVariant.php

@@ -47,21 +47,25 @@ class FlexibleVariant extends AbstractType
      */
     public function getIndices(): array
     {
-        $variants = $this->getSaleableVariants();
+        $variantIndices = [];
 
-        $minPrices = [];
-        $maxPrices = [];
-        $regularMinPrices = [];
-        $regularMaxPrices = [];
+        foreach ($this->getSaleableVariants() as $variant) {
+            $variantIndices[] = $this->setVariant($variant)->getVariantIndices();
+        }
 
-        foreach ($variants as $variant) {
-            $vi = $this->setVariant($variant)->getVariantIndices();
+        return $this->aggregateIndices($variantIndices);
+    }
 
-            $minPrices[] = $vi['min_price'];
-            $maxPrices[] = $vi['max_price'];
-            $regularMinPrices[] = $vi['regular_min_price'];
-            $regularMaxPrices[] = $vi['regular_max_price'];
-        }
+    /**
+     * Roll already computed variant indices up into the product-level index, so callers
+     * that walk the variants themselves do not pay for a second pass.
+     */
+    public function aggregateIndices(array $variantIndices): array
+    {
+        $minPrices = array_column($variantIndices, 'min_price');
+        $maxPrices = array_column($variantIndices, 'max_price');
+        $regularMinPrices = array_column($variantIndices, 'regular_min_price');
+        $regularMaxPrices = array_column($variantIndices, 'regular_max_price');
 
         return [
             'min_price'         => ! empty($minPrices) ? min($minPrices) : 0,
@@ -192,6 +196,17 @@ class FlexibleVariant extends AbstractType
         };
     }
 
+    /**
+     * Seed the parent product's catalog rule entries, letting batch callers prefetch them
+     * for every product at once instead of one query per product.
+     */
+    public function setCatalogRuleProducts(array $rules): static
+    {
+        $this->catalogRuleProductsCache = $rules;
+
+        return $this;
+    }
+
     /**
      * Get catalog_rule_products for the parent product (cached per indexer run).
      */

+ 101 - 0
packages/Longyi/Core/src/Http/Controllers/Admin/ProductSyncController.php

@@ -0,0 +1,101 @@
+<?php
+
+namespace Longyi\Core\Http\Controllers\Admin;
+
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\View\View;
+use Longyi\Core\Exports\ProductBasicInfoExport;
+use Longyi\Core\Models\AdminNotification;
+use Longyi\Core\Models\ProductSyncImport;
+use Longyi\Core\Services\ProductSyncImportService;
+use Maatwebsite\Excel\Facades\Excel;
+use Symfony\Component\HttpFoundation\BinaryFileResponse;
+use Webkul\Admin\Http\Controllers\Controller;
+
+class ProductSyncController extends Controller
+{
+    public function __construct(protected ProductSyncImportService $importService) {}
+
+    public function index(): View
+    {
+        $adminId = (int) auth()->guard('admin')->id();
+        $imports = $adminId ? $this->importService->recentForAdmin($adminId) : [];
+        $latestFinished = collect($imports)->first(fn (ProductSyncImport $import) => $import->isFinished());
+
+        return view('longyi::admin.catalog.products.sync', [
+            'imports' => $imports,
+            'result'  => $latestFinished ? [
+                'updated' => $latestFinished->updated_count,
+                'failed'  => $latestFinished->failed_count,
+                'errors'  => $latestFinished->errors ?? [],
+            ] : null,
+        ]);
+    }
+
+    public function export(): BinaryFileResponse
+    {
+        $locale = $this->exportLocale();
+        $channel = core()->getDefaultChannelCode();
+
+        return Excel::download(
+            new ProductBasicInfoExport($locale, $channel),
+            'products-basic-info-'.now()->format('Y-m-d').'.xlsx'
+        );
+    }
+
+    public function import(Request $request): RedirectResponse
+    {
+        $request->validate([
+            'import_file' => [
+                'required',
+                'file',
+                'extensions:xlsx,xls,csv',
+                'mimetypes:text/csv,text/plain,application/csv,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+                'max:10240',
+            ],
+        ]);
+
+        $this->importService->queue(
+            $request->file('import_file'),
+            auth()->guard('admin')->user(),
+            app()->getLocale()
+        );
+
+        return redirect()
+            ->route('admin.catalog.products.sync.index')
+            ->with('success', trans('longyi::app.product-sync.queued'));
+    }
+
+    public function viewNotice(int $id): RedirectResponse
+    {
+        $notification = AdminNotification::query()
+            ->where('type', AdminNotification::TYPE_PRODUCT_SYNC)
+            ->findOrFail($id);
+
+        $notification->read = 1;
+        $notification->save();
+
+        $route = $notification->route ?: 'admin.catalog.products.sync.index';
+
+        return redirect()->route($route);
+    }
+
+    protected function exportLocale(): string
+    {
+        $locale = core()->getRequestedLocaleCode();
+        $channel = core()->getDefaultChannelCode();
+
+        $exists = DB::table('product_flat')
+            ->where('locale', $locale)
+            ->when($channel, fn ($query) => $query->where('channel', $channel))
+            ->exists();
+
+        if ($exists) {
+            return $locale;
+        }
+
+        return core()->getDefaultLocaleCodeFromDefaultChannel();
+    }
+}

+ 10 - 0
packages/Longyi/Core/src/Imports/ProductBasicInfoSpreadsheet.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace Longyi\Core\Imports;
+
+use Maatwebsite\Excel\Concerns\ToArray;
+
+class ProductBasicInfoSpreadsheet implements ToArray
+{
+    public function array(array $array): void {}
+}

+ 52 - 0
packages/Longyi/Core/src/Jobs/ImportProductBasicInfoJob.php

@@ -0,0 +1,52 @@
+<?php
+
+namespace Longyi\Core\Jobs;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Longyi\Core\Models\ProductSyncImport;
+use Longyi\Core\Services\ProductSyncImportService;
+
+class ImportProductBasicInfoJob implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    public int $timeout = 900;
+
+    public int $tries = 1;
+
+    public function __construct(
+        public int $importId,
+        public string $locale,
+    ) {}
+
+    public function handle(ProductSyncImportService $service): void
+    {
+        $import = ProductSyncImport::query()->find($this->importId);
+
+        if (! $import) {
+            return;
+        }
+
+        app()->setLocale($this->locale);
+
+        $service->process($import);
+    }
+
+    public function failed(\Throwable $exception): void
+    {
+        $import = ProductSyncImport::query()->find($this->importId);
+
+        if (! $import || $import->isFinished()) {
+            return;
+        }
+
+        app(ProductSyncImportService::class)->markFailedAndNotify(
+            $import,
+            $exception->getMessage()
+        );
+    }
+}

+ 66 - 0
packages/Longyi/Core/src/Models/AdminNotification.php

@@ -0,0 +1,66 @@
+<?php
+
+namespace Longyi\Core\Models;
+
+use Illuminate\Database\Eloquent\Builder;
+use Webkul\Notification\Models\Notification as BaseNotification;
+
+class AdminNotification extends BaseNotification
+{
+    public const TYPE_ORDER = 'order';
+
+    public const TYPE_PRODUCT_SYNC = 'product_sync';
+
+    protected $table = 'notifications';
+
+    /**
+     * @var list<string>
+     */
+    protected $fillable = [
+        'type',
+        'read',
+        'order_id',
+        'title',
+        'description',
+        'route',
+        'admin_id',
+    ];
+
+    /**
+     * @var list<string>
+     */
+    protected $appends = [
+        'datetime',
+    ];
+
+    protected static function booted(): void
+    {
+        static::addGlobalScope('visible-to-admin', function (Builder $query) {
+            if (! auth()->guard('admin')->check()) {
+                return;
+            }
+
+            $adminId = (int) auth()->guard('admin')->id();
+
+            $query->where(function (Builder $visible) use ($adminId) {
+                $visible
+                    ->where(function (Builder $orders) {
+                        $orders->where('notifications.type', self::TYPE_ORDER)
+                            ->orWhereNull('notifications.type');
+                    })
+                    ->orWhere(function (Builder $productSync) use ($adminId) {
+                        $productSync->where('notifications.type', self::TYPE_PRODUCT_SYNC)
+                            ->where(function (Builder $owner) use ($adminId) {
+                                $owner->where('notifications.admin_id', $adminId)
+                                    ->orWhereNull('notifications.admin_id');
+                            });
+                    });
+            });
+        });
+    }
+
+    public function getDatetimeAttribute(): ?string
+    {
+        return $this->created_at?->diffForHumans();
+    }
+}

+ 70 - 0
packages/Longyi/Core/src/Models/ProductSyncImport.php

@@ -0,0 +1,70 @@
+<?php
+
+namespace Longyi\Core\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Webkul\User\Models\Admin;
+
+class ProductSyncImport extends Model
+{
+    public const STATUS_PENDING = 'pending';
+
+    public const STATUS_PROCESSING = 'processing';
+
+    public const STATUS_COMPLETED = 'completed';
+
+    public const STATUS_FAILED = 'failed';
+
+    protected $fillable = [
+        'admin_id',
+        'original_filename',
+        'disk_path',
+        'status',
+        'updated_count',
+        'failed_count',
+        'errors',
+        'message',
+        'locale',
+        'started_at',
+        'finished_at',
+        'seen_at',
+    ];
+
+    /**
+     * @var array<string, string>
+     */
+    protected $casts = [
+        'admin_id'      => 'integer',
+        'updated_count' => 'integer',
+        'failed_count'  => 'integer',
+        'errors'        => 'array',
+        'started_at'    => 'datetime',
+        'finished_at'   => 'datetime',
+        'seen_at'       => 'datetime',
+    ];
+
+    public function admin(): BelongsTo
+    {
+        return $this->belongsTo(Admin::class, 'admin_id');
+    }
+
+    public function isFinished(): bool
+    {
+        return in_array($this->status, [self::STATUS_COMPLETED, self::STATUS_FAILED], true);
+    }
+
+    public function summaryMessage(): string
+    {
+        if ($this->status === self::STATUS_FAILED && filled($this->message)) {
+            return trans('longyi::app.product-sync.errors.generic', [
+                'message' => $this->message,
+            ]);
+        }
+
+        return trans('longyi::app.product-sync.import-summary', [
+            'updated' => $this->updated_count,
+            'failed'  => $this->failed_count,
+        ]);
+    }
+}

+ 27 - 9
packages/Longyi/Core/src/Providers/LongyiCoreServiceProvider.php

@@ -15,26 +15,26 @@ class LongyiCoreServiceProvider extends ServiceProvider
      */
     public function boot(): void
     {
-        $this->loadMigrationsFrom(__DIR__ . '/../Database/Migrations');
+        $this->loadMigrationsFrom(__DIR__.'/../Database/Migrations');
 
-        $this->loadTranslationsFrom(__DIR__ . '/../Resources/lang', 'longyi');
+        $this->loadTranslationsFrom(__DIR__.'/../Resources/lang', 'longyi');
 
-        $this->loadViewsFrom(__DIR__ . '/../Resources/views', 'longyi');
+        $this->loadViewsFrom(__DIR__.'/../Resources/views', 'longyi');
 
         Blade::anonymousComponentPath(__DIR__.'/../Resources/views/components', 'longyi');
 
         $this->registerRoutes();
 
         $this->publishes([
-            __DIR__ . '/../Config/flexible_variant.php' => config_path('flexible_variant.php'),
+            __DIR__.'/../Config/flexible_variant.php' => config_path('flexible_variant.php'),
         ], 'longyi-config');
 
         $this->publishes([
-            __DIR__ . '/../Resources/views' => resource_path('views/vendor/longyi'),
+            __DIR__.'/../Resources/views' => resource_path('views/vendor/longyi'),
         ], 'longyi-views');
 
         $this->publishes([
-            __DIR__ . '/../Resources/lang' => lang_path('vendor/longyi'),
+            __DIR__.'/../Resources/lang' => lang_path('vendor/longyi'),
         ], 'longyi-lang');
 
         $this->registerEventListeners();
@@ -48,6 +48,10 @@ class LongyiCoreServiceProvider extends ServiceProvider
         Event::listen('checkout.order.save.after', [VariantInventoryListener::class, 'decrementOnOrderSave']);
 
         Event::listen('sales.order.cancel.after', [VariantInventoryListener::class, 'restoreOnOrderCancel']);
+
+        Event::listen('bagisto.admin.catalog.products.create.before', function ($viewRenderEventManager) {
+            $viewRenderEventManager->addTemplate('longyi::admin.catalog.products.sync-button');
+        });
     }
 
     /**
@@ -56,15 +60,25 @@ class LongyiCoreServiceProvider extends ServiceProvider
     public function register(): void
     {
         $this->mergeConfigFrom(
-            __DIR__ . '/../Config/flexible_variant.php',
+            __DIR__.'/../Config/flexible_variant.php',
             'flexible_variant'
         );
 
         $this->mergeConfigFrom(
-            __DIR__ . '/../Config/product_types.php',
+            __DIR__.'/../Config/product_types.php',
             'product_types'
         );
 
+        $this->mergeConfigFrom(
+            __DIR__.'/../Config/admin-menu.php',
+            'menu.admin'
+        );
+
+        $this->mergeConfigFrom(
+            __DIR__.'/../Config/acl.php',
+            'acl'
+        );
+
         $this->registerContracts();
 
         $this->registerRepositories();
@@ -77,7 +91,7 @@ class LongyiCoreServiceProvider extends ServiceProvider
     {
         Route::middleware('web')
             ->prefix('admin')
-            ->group(__DIR__ . '/../Routes/admin-routes.php');
+            ->group(__DIR__.'/../Routes/admin-routes.php');
     }
 
     /**
@@ -107,6 +121,10 @@ class LongyiCoreServiceProvider extends ServiceProvider
             \Longyi\Core\Models\ProductImage::class
         );
 
+        $this->app->bind(
+            \Webkul\Notification\Contracts\Notification::class,
+            \Longyi\Core\Models\AdminNotification::class
+        );
 
         $this->app->singleton(
             \Webkul\Product\ProductImage::class,

+ 112 - 65
packages/Longyi/Core/src/Resources/lang/en/app.php

@@ -2,95 +2,142 @@
 
 return [
     'flexible-variant' => [
-        'title' => 'Flexible Variant',
+        'title'        => 'Flexible Variant',
         'product-type' => 'Flexible Variant Product',
-        
+
         'options' => [
-            'title' => 'Product Options',
-            'create' => 'Create Option',
-            'edit' => 'Edit Option',
-            'delete' => 'Delete Option',
-            'label' => 'Option Label',
-            'code' => 'Option Code',
-            'type' => 'Option Type',
-            'position' => 'Position',
+            'title'       => 'Product Options',
+            'create'      => 'Create Option',
+            'edit'        => 'Edit Option',
+            'delete'      => 'Delete Option',
+            'label'       => 'Option Label',
+            'code'        => 'Option Code',
+            'type'        => 'Option Type',
+            'position'    => 'Position',
             'is-required' => 'Required',
-            'meta' => 'Meta Data',
-            'values' => 'Option Values',
-            'empty' => 'No options created yet',
-            
+            'meta'        => 'Meta Data',
+            'values'      => 'Option Values',
+            'empty'       => 'No options created yet',
+
             'types' => [
-                'select' => 'Select Dropdown',
-                'radio' => 'Radio Buttons',
+                'select'   => 'Select Dropdown',
+                'radio'    => 'Radio Buttons',
                 'checkbox' => 'Checkboxes',
-                'color' => 'Color Swatches',
-                'button' => 'Button Select',
+                'color'    => 'Color Swatches',
+                'button'   => 'Button Select',
             ],
         ],
-        
+
         'option-values' => [
-            'title' => 'Option Values',
-            'create' => 'Add Value',
-            'edit' => 'Edit Value',
-            'delete' => 'Delete Value',
-            'label' => 'Value Label',
-            'code' => 'Value Code',
+            'title'    => 'Option Values',
+            'create'   => 'Add Value',
+            'edit'     => 'Edit Value',
+            'delete'   => 'Delete Value',
+            'label'    => 'Value Label',
+            'code'     => 'Value Code',
             'position' => 'Position',
-            'meta' => 'Meta Data',
+            'meta'     => 'Meta Data',
         ],
-        
+
         'variants' => [
-            'title' => 'Product Variants',
-            'create' => 'Create Variant',
-            'edit' => 'Edit Variant',
-            'delete' => 'Delete Variant',
-            'sku' => 'SKU',
-            'name' => 'Variant Name',
-            'price' => 'Price',
-            'compare-price' => 'Compare Price',
-            'special-price' => 'Special Price',
+            'title'              => 'Product Variants',
+            'create'             => 'Create Variant',
+            'edit'               => 'Edit Variant',
+            'delete'             => 'Delete Variant',
+            'sku'                => 'SKU',
+            'name'               => 'Variant Name',
+            'price'              => 'Price',
+            'compare-price'      => 'Compare Price',
+            'special-price'      => 'Special Price',
             'special-price-from' => 'Special Price From',
-            'special-price-to' => 'Special Price To',
-            'cost' => 'Cost',
-            'weight' => 'Weight',
-            'quantity' => 'Quantity',
-            'status' => 'Status',
-            'images' => 'Images',
-            'sort-order' => 'Sort Order',
-            'option-values' => 'Option Values',
-            'saleable' => 'Saleable',
-            'not-saleable' => 'Not Saleable',
-            'empty' => 'No variants created yet',
-            
+            'special-price-to'   => 'Special Price To',
+            'cost'               => 'Cost',
+            'weight'             => 'Weight',
+            'quantity'           => 'Quantity',
+            'status'             => 'Status',
+            'images'             => 'Images',
+            'sort-order'         => 'Sort Order',
+            'option-values'      => 'Option Values',
+            'saleable'           => 'Saleable',
+            'not-saleable'       => 'Not Saleable',
+            'empty'              => 'No variants created yet',
+
             'bulk' => [
                 'update-quantities' => 'Update Quantities',
-                'update-status' => 'Update Status',
-                'enable' => 'Enable Selected',
-                'disable' => 'Disable Selected',
+                'update-status'     => 'Update Status',
+                'enable'            => 'Enable Selected',
+                'disable'           => 'Disable Selected',
             ],
         ],
-        
+
         'messages' => [
-            'option-created' => 'Option created successfully',
-            'option-updated' => 'Option updated successfully',
-            'option-deleted' => 'Option deleted successfully',
+            'option-created'   => 'Option created successfully',
+            'option-updated'   => 'Option updated successfully',
+            'option-deleted'   => 'Option deleted successfully',
             'options-attached' => 'Options attached successfully',
-            
-            'variant-created' => 'Variant created successfully',
-            'variant-updated' => 'Variant updated successfully',
-            'variant-deleted' => 'Variant deleted successfully',
+
+            'variant-created'  => 'Variant created successfully',
+            'variant-updated'  => 'Variant updated successfully',
+            'variant-deleted'  => 'Variant deleted successfully',
             'variants-updated' => 'Variants updated successfully',
-            
-            'variant-exists' => 'A variant with these option values already exists',
-            'option-not-found' => 'Option not found',
+
+            'variant-exists'    => 'A variant with these option values already exists',
+            'option-not-found'  => 'Option not found',
             'variant-not-found' => 'Variant not found',
             'product-not-found' => 'Product not found',
         ],
-        
+
         'validations' => [
-            'option-required' => 'Option :option is required',
+            'option-required'           => 'Option :option is required',
             'combination-not-available' => 'Selected combination is not available',
-            'variant-not-saleable' => 'Selected variant is not available for purchase',
+            'variant-not-saleable'      => 'Selected variant is not available for purchase',
+        ],
+    ],
+
+    'product-sync' => [
+        'title'         => 'Product Sync',
+        'export'        => 'Export Excel',
+        'import'        => 'Upload Sync',
+        'help'          => 'Export product basics, edit the spreadsheet, then upload to update existing products by SKU. Import runs in the background; when it finishes you will see it in the admin notification bell. New products are not created. Images and variant options are not changed.',
+        'file-label'    => 'Excel file',
+        'file-hint'     => 'xlsx / xls / csv. Match the exported column headers. SKU is the lookup key.',
+        'columns-title' => 'Columns',
+        'errors-title'  => 'Row errors',
+        'history-title' => 'Recent imports',
+        'status'        => 'Status',
+        'filename'      => 'File',
+        'queued'        => 'Import queued. You will be notified in the admin notification bell when it finishes.',
+        'statuses'      => [
+            'pending'    => 'Queued',
+            'processing' => 'Importing',
+            'completed'  => 'Completed',
+            'failed'     => 'Failed',
+        ],
+        'row'           => 'Row',
+        'message'       => 'Message',
+        'import-summary'=> 'Updated :updated product(s), :failed row(s) failed.',
+        'columns'       => [
+            'product-id' => 'product_id: Bagisto product ID (export only; import still matches by SKU)',
+            'sku'        => 'sku: existing product SKU (required, not used to rename SKU)',
+            'locale'     => 'locale: store locale code; empty uses the default channel locale',
+            'core'       => 'name, url_key, short_description, description, price, special_price, weight, status (1/0), SEO fields',
+            'qty'        => 'qty: parent inventory; empty keeps the current quantity. Variant stock is not updated.',
+            'categories' => 'categories: current-locale category names (or slugs) separated by |. Empty keeps current categories. Unknown names fail the row.',
+        ],
+        'errors'        => [
+            'empty-file'            => 'The uploaded file is empty.',
+            'sku-column-missing'    => 'The file must include a sku column.',
+            'sku-required'          => 'SKU is required.',
+            'sku-not-found'         => 'SKU was not found.',
+            'duplicate-sku'         => 'Duplicate SKU (already used on row :row).',
+            'field-required'        => ':field cannot be empty.',
+            'url-key-taken'         => 'url_key is already used by another product.',
+            'unknown-category'      => 'Unknown category: :name',
+            'invalid-qty'           => 'qty must be a number.',
+            'invalid-status'        => 'status must be 1 or 0.',
+            'invalid-number'        => ':field must be a number.',
+            'no-inventory-source'   => 'No inventory source is configured.',
+            'generic'               => 'Import failed: :message',
         ],
     ],
 ];

+ 110 - 63
packages/Longyi/Core/src/Resources/lang/zh_CN/app.php

@@ -2,93 +2,140 @@
 
 return [
     'flexible-variant' => [
-        'title' => '灵活变体',
+        'title'        => '灵活变体',
         'product-type' => '灵活变体产品',
-        
+
         'options' => [
-            'title' => '产品选项',
-            'create' => '创建选项',
-            'edit' => '编辑选项',
-            'delete' => '删除选项',
-            'label' => '选项标签',
-            'code' => '选项代码',
-            'type' => '选项类型',
-            'position' => '排序',
+            'title'       => '产品选项',
+            'create'      => '创建选项',
+            'edit'        => '编辑选项',
+            'delete'      => '删除选项',
+            'label'       => '选项标签',
+            'code'        => '选项代码',
+            'type'        => '选项类型',
+            'position'    => '排序',
             'is-required' => '必选',
-            'meta' => '元数据',
-            'values' => '选项值',
-            
+            'meta'        => '元数据',
+            'values'      => '选项值',
+
             'types' => [
-                'select' => '下拉选择',
-                'radio' => '单选按钮',
+                'select'   => '下拉选择',
+                'radio'    => '单选按钮',
                 'checkbox' => '复选框',
-                'color' => '颜色选择器',
-                'button' => '按钮选择',
+                'color'    => '颜色选择器',
+                'button'   => '按钮选择',
             ],
         ],
-        
+
         'option-values' => [
-            'title' => '选项值',
-            'create' => '添加值',
-            'edit' => '编辑值',
-            'delete' => '删除值',
-            'label' => '值标签',
-            'code' => '值代码',
+            'title'    => '选项值',
+            'create'   => '添加值',
+            'edit'     => '编辑值',
+            'delete'   => '删除值',
+            'label'    => '值标签',
+            'code'     => '值代码',
             'position' => '排序',
-            'meta' => '元数据',
+            'meta'     => '元数据',
         ],
-        
+
         'variants' => [
-            'title' => '产品变体',
-            'create' => '创建变体',
-            'edit' => '编辑变体',
-            'delete' => '删除变体',
-            'sku' => 'SKU',
-            'name' => '变体名称',
-            'price' => '价格',
-            'compare-price' => '对比价格',
-            'special-price' => '特价',
+            'title'              => '产品变体',
+            'create'             => '创建变体',
+            'edit'               => '编辑变体',
+            'delete'             => '删除变体',
+            'sku'                => 'SKU',
+            'name'               => '变体名称',
+            'price'              => '价格',
+            'compare-price'      => '对比价格',
+            'special-price'      => '特价',
             'special-price-from' => '特价开始日期',
-            'special-price-to' => '特价结束日期',
-            'cost' => '成本价',
-            'weight' => '重量',
-            'quantity' => '库存数量',
-            'status' => '状态',
-            'images' => '图片',
-            'sort-order' => '排序',
-            'option-values' => '选项值',
-            'saleable' => '可售',
-            'not-saleable' => '不可售',
-            
+            'special-price-to'   => '特价结束日期',
+            'cost'               => '成本价',
+            'weight'             => '重量',
+            'quantity'           => '库存数量',
+            'status'             => '状态',
+            'images'             => '图片',
+            'sort-order'         => '排序',
+            'option-values'      => '选项值',
+            'saleable'           => '可售',
+            'not-saleable'       => '不可售',
+
             'bulk' => [
                 'update-quantities' => '批量更新库存',
-                'update-status' => '批量更新状态',
-                'enable' => '启用选中项',
-                'disable' => '禁用选中项',
+                'update-status'     => '批量更新状态',
+                'enable'            => '启用选中项',
+                'disable'           => '禁用选中项',
             ],
         ],
-        
+
         'messages' => [
-            'option-created' => '选项创建成功',
-            'option-updated' => '选项更新成功',
-            'option-deleted' => '选项删除成功',
+            'option-created'   => '选项创建成功',
+            'option-updated'   => '选项更新成功',
+            'option-deleted'   => '选项删除成功',
             'options-attached' => '选项关联成功',
-            
-            'variant-created' => '变体创建成功',
-            'variant-updated' => '变体更新成功',
-            'variant-deleted' => '变体删除成功',
+
+            'variant-created'  => '变体创建成功',
+            'variant-updated'  => '变体更新成功',
+            'variant-deleted'  => '变体删除成功',
             'variants-updated' => '变体更新成功',
-            
-            'variant-exists' => '具有这些选项值的变体已存在',
-            'option-not-found' => '选项未找到',
+
+            'variant-exists'    => '具有这些选项值的变体已存在',
+            'option-not-found'  => '选项未找到',
             'variant-not-found' => '变体未找到',
             'product-not-found' => '产品未找到',
         ],
-        
+
         'validations' => [
-            'option-required' => '选项 :option 是必选的',
+            'option-required'           => '选项 :option 是必选的',
             'combination-not-available' => '所选组合不可用',
-            'variant-not-saleable' => '所选变体无法购买',
+            'variant-not-saleable'      => '所选变体无法购买',
+        ],
+    ],
+
+    'product-sync' => [
+        'title'         => '批量同步',
+        'export'        => '导出 Excel',
+        'import'        => '上传同步',
+        'help'          => '先导出产品基础信息,在 Excel 中修改后再上传。导入在后台异步执行,完成后会显示在后台右上角通知铃铛中。按 SKU 更新已有产品,不会新建或删除,也不会改图片和规格变体。',
+        'file-label'    => 'Excel 文件',
+        'file-hint'     => '支持 xlsx / xls / csv。请保持导出时的表头。SKU 为匹配键。',
+        'columns-title' => '列说明',
+        'errors-title'  => '失败行',
+        'history-title' => '最近导入',
+        'status'        => '状态',
+        'filename'      => '文件',
+        'queued'        => '已提交后台导入,完成后可在右上角通知中查看。',
+        'statuses'      => [
+            'pending'    => '排队中',
+            'processing' => '导入中',
+            'completed'  => '已完成',
+            'failed'     => '失败',
+        ],
+        'row'           => '行号',
+        'message'       => '原因',
+        'import-summary'=> '已更新 :updated 个产品,失败 :failed 行。',
+        'columns'       => [
+            'product-id' => 'product_id:产品 ID(导出对照用,导入仍按 SKU 匹配,不能用来改 ID)',
+            'sku'        => 'sku:已有产品 SKU(必填,不能用来改 SKU)',
+            'locale'     => 'locale:语言代码;留空则使用默认渠道语言',
+            'core'       => 'name、url_key、short_description、description、price、special_price、weight、status(1/0)、SEO 字段',
+            'qty'        => 'qty:父商品库存;留空则不改。变体库存本版不更新。',
+            'categories' => 'categories:分类名或 slug,用 | 分隔(名称里的逗号不会拆开)。留空则不改分类。名称找不到则整行失败。',
+        ],
+        'errors'        => [
+            'empty-file'            => '上传文件为空。',
+            'sku-column-missing'    => '文件必须包含 sku 列。',
+            'sku-required'          => 'SKU 不能为空。',
+            'sku-not-found'         => '找不到该 SKU。',
+            'duplicate-sku'         => 'SKU 重复(已在第 :row 行出现)。',
+            'field-required'        => ':field 不能为空。',
+            'url-key-taken'         => 'url_key 已被其他产品占用。',
+            'unknown-category'      => '未知分类::name',
+            'invalid-qty'           => 'qty 必须是数字。',
+            'invalid-status'        => 'status 必须是 1 或 0。',
+            'invalid-number'        => ':field 必须是数字。',
+            'no-inventory-source'   => '未配置库存来源。',
+            'generic'               => '导入失败::message',
         ],
     ],
 ];

+ 9 - 0
packages/Longyi/Core/src/Resources/views/admin/catalog/products/sync-button.blade.php

@@ -0,0 +1,9 @@
+@if (bouncer()->hasPermission('catalog.product_sync'))
+    <a
+        href="{{ route('admin.catalog.products.sync.index') }}"
+        class="transparent-button px-1 py-1.5 hover:bg-gray-200 dark:text-white dark:hover:bg-gray-800"
+    >
+        <span class="icon-export text-xl text-gray-600"></span>
+        @lang('longyi::app.product-sync.title')
+    </a>
+@endif

+ 159 - 0
packages/Longyi/Core/src/Resources/views/admin/catalog/products/sync.blade.php

@@ -0,0 +1,159 @@
+<x-admin::layouts>
+    <x-slot:title>
+        @lang('longyi::app.product-sync.title')
+    </x-slot>
+
+    {!! view_render_event('bagisto.admin.catalog.products.sync.before') !!}
+
+    <x-admin::form
+        :action="route('admin.catalog.products.sync.import')"
+        method="POST"
+        enctype="multipart/form-data"
+    >
+        <div class="flex items-center justify-between gap-4 max-sm:flex-wrap">
+            <p class="text-xl font-bold text-gray-800 dark:text-white">
+                @lang('longyi::app.product-sync.title')
+            </p>
+
+            <div class="flex items-center gap-x-2.5">
+                @if (bouncer()->hasPermission('catalog.product_sync.export'))
+                    <a
+                        href="{{ route('admin.catalog.products.sync.export') }}"
+                        class="transparent-button px-1 py-1.5 hover:bg-gray-200 dark:text-white dark:hover:bg-gray-800"
+                    >
+                        <span class="icon-export text-xl text-gray-600"></span>
+                        @lang('longyi::app.product-sync.export')
+                    </a>
+                @endif
+
+                @if (bouncer()->hasPermission('catalog.product_sync.import'))
+                    <button
+                        type="submit"
+                        class="primary-button"
+                    >
+                        @lang('longyi::app.product-sync.import')
+                    </button>
+                @endif
+            </div>
+        </div>
+
+        <div class="mt-3.5 flex gap-2.5 max-xl:flex-wrap">
+            <div class="flex flex-1 flex-col gap-2 max-xl:flex-auto">
+                <div class="box-shadow rounded bg-white p-4 dark:bg-gray-900">
+                    <p class="mb-4 text-base font-semibold text-gray-800 dark:text-white">
+                        @lang('longyi::app.product-sync.import')
+                    </p>
+
+                    <p class="mb-4 text-sm text-gray-600 dark:text-gray-300">
+                        @lang('longyi::app.product-sync.help')
+                    </p>
+
+                    <x-admin::form.control-group>
+                        <x-admin::form.control-group.label class="required">
+                            @lang('longyi::app.product-sync.file-label')
+                        </x-admin::form.control-group.label>
+
+                        <x-admin::form.control-group.control
+                            type="file"
+                            name="import_file"
+                            rules="required"
+                            accept=".xlsx,.xls,.csv"
+                            :label="trans('longyi::app.product-sync.file-label')"
+                        />
+
+                        <x-admin::form.control-group.error control-name="import_file" />
+
+                        <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
+                            @lang('longyi::app.product-sync.file-hint')
+                        </p>
+                    </x-admin::form.control-group>
+                </div>
+
+                <div class="box-shadow rounded bg-white p-4 dark:bg-gray-900">
+                    <p class="mb-3 text-base font-semibold text-gray-800 dark:text-white">
+                        @lang('longyi::app.product-sync.columns-title')
+                    </p>
+
+                    <ul class="list-inside list-disc space-y-1 text-sm text-gray-600 dark:text-gray-300">
+                        <li>@lang('longyi::app.product-sync.columns.product-id')</li>
+                        <li>@lang('longyi::app.product-sync.columns.sku')</li>
+                        <li>@lang('longyi::app.product-sync.columns.locale')</li>
+                        <li>@lang('longyi::app.product-sync.columns.core')</li>
+                        <li>@lang('longyi::app.product-sync.columns.qty')</li>
+                        <li>@lang('longyi::app.product-sync.columns.categories')</li>
+                    </ul>
+                </div>
+
+                @if (! empty($imports))
+                    <div class="box-shadow rounded bg-white p-4 dark:bg-gray-900">
+                        <p class="mb-3 text-base font-semibold text-gray-800 dark:text-white">
+                            @lang('longyi::app.product-sync.history-title')
+                        </p>
+
+                        <div class="overflow-x-auto">
+                            <table class="w-full text-left text-sm text-gray-600 dark:text-gray-300">
+                                <thead>
+                                    <tr class="border-b dark:border-gray-800">
+                                        <th class="px-2 py-2">ID</th>
+                                        <th class="px-2 py-2">@lang('longyi::app.product-sync.filename')</th>
+                                        <th class="px-2 py-2">@lang('longyi::app.product-sync.status')</th>
+                                        <th class="px-2 py-2">@lang('longyi::app.product-sync.message')</th>
+                                    </tr>
+                                </thead>
+                                <tbody>
+                                    @foreach ($imports as $import)
+                                        <tr class="border-b dark:border-gray-800">
+                                            <td class="px-2 py-2">{{ $import->id }}</td>
+                                            <td class="px-2 py-2">{{ $import->original_filename }}</td>
+                                            <td class="px-2 py-2">
+                                                @lang('longyi::app.product-sync.statuses.'.$import->status)
+                                            </td>
+                                            <td class="px-2 py-2">
+                                                @if ($import->isFinished())
+                                                    {{ $import->summaryMessage() }}
+                                                @else
+                                                    —
+                                                @endif
+                                            </td>
+                                        </tr>
+                                    @endforeach
+                                </tbody>
+                            </table>
+                        </div>
+                    </div>
+                @endif
+
+                @if (! empty($result['errors'] ?? []))
+                    <div class="box-shadow rounded bg-white p-4 dark:bg-gray-900">
+                        <p class="mb-3 text-base font-semibold text-gray-800 dark:text-white">
+                            @lang('longyi::app.product-sync.errors-title')
+                        </p>
+
+                        <div class="overflow-x-auto">
+                            <table class="w-full text-left text-sm text-gray-600 dark:text-gray-300">
+                                <thead>
+                                    <tr class="border-b dark:border-gray-800">
+                                        <th class="px-2 py-2">@lang('longyi::app.product-sync.row')</th>
+                                        <th class="px-2 py-2">SKU</th>
+                                        <th class="px-2 py-2">@lang('longyi::app.product-sync.message')</th>
+                                    </tr>
+                                </thead>
+                                <tbody>
+                                    @foreach ($result['errors'] as $error)
+                                        <tr class="border-b dark:border-gray-800">
+                                            <td class="px-2 py-2">{{ $error['row'] }}</td>
+                                            <td class="px-2 py-2">{{ $error['sku'] }}</td>
+                                            <td class="px-2 py-2 text-red-600">{{ $error['message'] }}</td>
+                                        </tr>
+                                    @endforeach
+                                </tbody>
+                            </table>
+                        </div>
+                    </div>
+                @endif
+            </div>
+        </div>
+    </x-admin::form>
+
+    {!! view_render_event('bagisto.admin.catalog.products.sync.after') !!}
+</x-admin::layouts>

+ 9 - 1
packages/Longyi/Core/src/Routes/admin-routes.php

@@ -2,10 +2,18 @@
 
 use Illuminate\Support\Facades\Route;
 use Longyi\Core\Http\Controllers\Admin\FlexibleVariantController;
+use Longyi\Core\Http\Controllers\Admin\ProductSyncController;
 
 Route::group(['middleware' => ['admin']], function () {
+    Route::prefix('catalog/products/sync')->group(function () {
+        Route::get('/', [ProductSyncController::class, 'index'])->name('admin.catalog.products.sync.index');
+        Route::get('/export', [ProductSyncController::class, 'export'])->name('admin.catalog.products.sync.export');
+        Route::post('/import', [ProductSyncController::class, 'import'])->name('admin.catalog.products.sync.import');
+        Route::get('/notices/{id}', [ProductSyncController::class, 'viewNotice'])->name('admin.catalog.products.sync.notice');
+    });
+
     Route::prefix('flexible-variant')->group(function () {
-        
+
         // ==================== OPTION ROUTES ====================
         Route::prefix('options')->group(function () {
             Route::get('/', [FlexibleVariantController::class, 'getOptions'])->name('admin.flexible_variant.options.index');

+ 7 - 0
packages/Longyi/Core/src/Services/ProductBasicInfoSyncException.php

@@ -0,0 +1,7 @@
+<?php
+
+namespace Longyi\Core\Services;
+
+use RuntimeException;
+
+class ProductBasicInfoSyncException extends RuntimeException {}

+ 26 - 0
packages/Longyi/Core/src/Services/ProductBasicInfoSyncResult.php

@@ -0,0 +1,26 @@
+<?php
+
+namespace Longyi\Core\Services;
+
+class ProductBasicInfoSyncResult
+{
+    public int $updated = 0;
+
+    public int $failed = 0;
+
+    /**
+     * @var list<array{row: int, sku: string, message: string}>
+     */
+    public array $errors = [];
+
+    public function addError(int $row, string $sku, string $message): void
+    {
+        $this->failed++;
+
+        $this->errors[] = [
+            'row'     => $row,
+            'sku'     => $sku,
+            'message' => $message,
+        ];
+    }
+}

+ 580 - 0
packages/Longyi/Core/src/Services/ProductBasicInfoSyncService.php

@@ -0,0 +1,580 @@
+<?php
+
+namespace Longyi\Core\Services;
+
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+use Longyi\Core\Support\ProductBasicInfoColumns;
+use Webkul\Attribute\Repositories\AttributeRepository;
+use Webkul\Product\Helpers\Indexers\ElasticSearch;
+use Webkul\Product\Helpers\Indexers\Flat;
+use Webkul\Product\Helpers\Indexers\Price;
+use Webkul\Product\Models\Product;
+use Webkul\Product\Repositories\ProductAttributeValueRepository;
+use Webkul\Product\Repositories\ProductRepository;
+
+class ProductBasicInfoSyncService
+{
+    /**
+     * @var array<string, int>
+     */
+    protected array $productsBySku = [];
+
+    public function __construct(
+        protected ProductRepository $productRepository,
+        protected ProductAttributeValueRepository $productAttributeValueRepository,
+        protected AttributeRepository $attributeRepository,
+    ) {}
+
+    /**
+     * @param  list<list<mixed>>  $table  First row is headings.
+     */
+    public function syncFromTable(array $table, bool $reindex = true): ProductBasicInfoSyncResult
+    {
+        return $this->sync($this->mapTableToRows($table), $reindex);
+    }
+
+    /**
+     * @param  list<list<mixed>>  $table
+     * @return list<array<string, mixed>>
+     */
+    public function mapTableToRows(array $table): array
+    {
+        if ($table === []) {
+            throw new ProductBasicInfoSyncException(trans('longyi::app.product-sync.errors.empty-file'));
+        }
+
+        $headings = [];
+
+        foreach ((array) array_shift($table) as $heading) {
+            $headings[] = $this->normalizeHeading($heading);
+        }
+
+        if (! in_array(ProductBasicInfoColumns::SKU, $headings, true)) {
+            throw new ProductBasicInfoSyncException(trans('longyi::app.product-sync.errors.sku-column-missing'));
+        }
+
+        $rows = [];
+
+        foreach ($table as $line) {
+            $row = [];
+
+            foreach ($headings as $index => $heading) {
+                if ($heading === '') {
+                    continue;
+                }
+
+                $row[$heading] = $line[$index] ?? null;
+            }
+
+            $rows[] = $row;
+        }
+
+        return $rows;
+    }
+
+    /**
+     * @param  list<array<string, mixed>>  $rows
+     */
+    public function sync(array $rows, bool $reindex = true): ProductBasicInfoSyncResult
+    {
+        $result = new ProductBasicInfoSyncResult;
+        $touchedProductIds = [];
+        $seenSkus = [];
+
+        foreach ($rows as $index => $row) {
+            $excelRow = $index + 2;
+            $sku = $this->cellString($row[ProductBasicInfoColumns::SKU] ?? '');
+
+            if ($sku === '' && $this->rowIsEmpty($row)) {
+                continue;
+            }
+
+            if ($sku === '') {
+                $result->addError($excelRow, '', trans('longyi::app.product-sync.errors.sku-required'));
+
+                continue;
+            }
+
+            if (isset($seenSkus[$sku])) {
+                $result->addError(
+                    $excelRow,
+                    $sku,
+                    trans('longyi::app.product-sync.errors.duplicate-sku', ['row' => $seenSkus[$sku]])
+                );
+
+                continue;
+            }
+
+            $seenSkus[$sku] = $excelRow;
+
+            try {
+                $productId = $this->syncRow($row, $sku);
+                $result->updated++;
+                $touchedProductIds[] = $productId;
+            } catch (ProductBasicInfoSyncException $e) {
+                $result->addError($excelRow, $sku, $e->getMessage());
+            } catch (\Throwable $e) {
+                $result->addError($excelRow, $sku, $e->getMessage());
+            }
+        }
+
+        if ($reindex) {
+            $this->reindexProducts($touchedProductIds);
+        }
+
+        return $result;
+    }
+
+    /**
+     * @param  array<string, mixed>  $row
+     */
+    protected function syncRow(array $row, string $sku): int
+    {
+        $product = $this->findProductBySku($sku);
+
+        if (! $product) {
+            throw new ProductBasicInfoSyncException(trans('longyi::app.product-sync.errors.sku-not-found'));
+        }
+
+        $locale = $this->cellString($row[ProductBasicInfoColumns::LOCALE] ?? '');
+
+        if ($locale === '') {
+            $locale = core()->getDefaultLocaleCodeFromDefaultChannel();
+        }
+
+        $channel = core()->getDefaultChannelCode();
+        $presentCodes = $this->presentAttributeCodes($row);
+        $payload = [
+            'channel' => $channel,
+            'locale'  => $locale,
+        ];
+
+        foreach ($presentCodes as $code) {
+            $raw = $row[$code] ?? null;
+            $value = $this->normalizeAttributeValue($code, $raw);
+
+            if (
+                in_array($code, ProductBasicInfoColumns::REQUIRED_ATTRIBUTE_CODES, true)
+                && $this->isEmptyValue($value)
+            ) {
+                throw new ProductBasicInfoSyncException(trans('longyi::app.product-sync.errors.field-required', [
+                    'field' => $code,
+                ]));
+            }
+
+            if ($code === 'status' && $this->isEmptyValue($raw)) {
+                continue;
+            }
+
+            if ($code === 'url_key' && ! $this->isUrlKeyUnique($product->id, (string) $value, $locale)) {
+                throw new ProductBasicInfoSyncException(trans('longyi::app.product-sync.errors.url-key-taken'));
+            }
+
+            $payload[$code] = $value;
+        }
+
+        $attributeCodes = array_values(array_intersect($presentCodes, array_keys($payload)));
+
+        $categoryNames = null;
+
+        if (array_key_exists(ProductBasicInfoColumns::CATEGORIES, $row)) {
+            $categoriesRaw = $this->cellString($row[ProductBasicInfoColumns::CATEGORIES] ?? '');
+
+            if ($categoriesRaw !== '') {
+                $categoryNames = $this->splitCategories($categoriesRaw);
+            }
+        }
+
+        $qty = null;
+
+        if (array_key_exists(ProductBasicInfoColumns::QTY, $row)) {
+            $qtyRaw = $this->cellString($row[ProductBasicInfoColumns::QTY] ?? '');
+
+            if ($qtyRaw !== '') {
+                if (! is_numeric($qtyRaw)) {
+                    throw new ProductBasicInfoSyncException(trans('longyi::app.product-sync.errors.invalid-qty'));
+                }
+
+                $qty = (int) $qtyRaw;
+            }
+        }
+
+        $categoryIds = $categoryNames !== null
+            ? $this->resolveCategoryIds($categoryNames, $locale)
+            : null;
+
+        DB::transaction(function () use ($product, $payload, $attributeCodes, $categoryIds, $qty) {
+            if ($attributeCodes !== []) {
+                $this->productRepository->update($payload, $product->id, $attributeCodes);
+            }
+
+            if ($categoryIds !== null) {
+                $this->saveCategories($product, $categoryIds);
+            }
+
+            if ($qty !== null) {
+                $this->saveInventory($product, $qty);
+            }
+        });
+
+        $product->unsetRelation('categories');
+
+        try {
+            event('catalog.product.update.after', $product->fresh(['categories']));
+        } catch (\Throwable) {
+            // Cache listeners must not roll back a successful spreadsheet row.
+        }
+
+        return (int) $product->id;
+    }
+
+    /**
+     * @param  array<string, mixed>  $row
+     * @return list<string>
+     */
+    protected function presentAttributeCodes(array $row): array
+    {
+        $codes = [];
+
+        foreach (ProductBasicInfoColumns::ATTRIBUTE_CODES as $code) {
+            if (array_key_exists($code, $row)) {
+                $codes[] = $code;
+            }
+        }
+
+        return $codes;
+    }
+
+    protected function normalizeAttributeValue(string $code, mixed $raw): mixed
+    {
+        if ($code === 'special_price' && $this->isEmptyValue($raw)) {
+            return null;
+        }
+
+        if ($code === 'status') {
+            if ($this->isEmptyValue($raw)) {
+                return null;
+            }
+
+            $normalized = strtolower($this->cellString($raw));
+
+            if (in_array($normalized, ['1', 'true', 'yes', 'enabled'], true)) {
+                return 1;
+            }
+
+            if (in_array($normalized, ['0', 'false', 'no', 'disabled'], true)) {
+                return 0;
+            }
+
+            throw new ProductBasicInfoSyncException(trans('longyi::app.product-sync.errors.invalid-status'));
+        }
+
+        if (in_array($code, ['price', 'special_price', 'weight'], true)) {
+            if ($this->isEmptyValue($raw)) {
+                return $code === 'special_price' ? null : '';
+            }
+
+            if (! is_numeric($raw) && ! is_numeric($this->cellString($raw))) {
+                throw new ProductBasicInfoSyncException(trans('longyi::app.product-sync.errors.invalid-number', [
+                    'field' => $code,
+                ]));
+            }
+
+            return (float) $raw;
+        }
+
+        return $this->cellString($raw);
+    }
+
+    protected function findProductBySku(string $sku): ?Product
+    {
+        if ($this->productsBySku === []) {
+            $this->productsBySku = Product::query()
+                ->pluck('id', 'sku')
+                ->mapWithKeys(fn ($id, $skuKey) => [(string) $skuKey => (int) $id])
+                ->all();
+        }
+
+        $id = $this->productsBySku[$sku] ?? null;
+
+        if (! $id) {
+            return null;
+        }
+
+        return Product::query()->find($id);
+    }
+
+    protected function isUrlKeyUnique(int $productId, string $urlKey, string $locale): bool
+    {
+        $attribute = $this->attributeRepository->findOneByField('code', 'url_key');
+
+        if (! $attribute) {
+            return true;
+        }
+
+        return ! DB::table('product_attribute_values')
+            ->where('attribute_id', $attribute->id)
+            ->where('text_value', $urlKey)
+            ->where('product_id', '!=', $productId)
+            ->when(
+                Schema::hasColumn('product_attribute_values', 'locale'),
+                fn ($query) => $query->where(function ($inner) use ($locale) {
+                    $inner->where('locale', $locale)->orWhereNull('locale');
+                })
+            )
+            ->exists();
+    }
+
+    /**
+     * @param  list<string>  $names
+     * @return list<int>
+     */
+    protected function resolveCategoryIds(array $names, string $locale): array
+    {
+        if ($names === [] || ! Schema::hasTable('category_translations')) {
+            throw new ProductBasicInfoSyncException(trans('longyi::app.product-sync.errors.unknown-category', [
+                'name' => implode('|', $names),
+            ]));
+        }
+
+        $ids = [];
+        $missing = [];
+
+        foreach ($names as $name) {
+            $categoryId = $this->findCategoryId($name, $locale);
+
+            if ($categoryId) {
+                $ids[] = $categoryId;
+
+                continue;
+            }
+
+            $missing[] = $name;
+        }
+
+        if ($missing !== []) {
+            throw new ProductBasicInfoSyncException(trans('longyi::app.product-sync.errors.unknown-category', [
+                'name' => implode('|', $missing),
+            ]));
+        }
+
+        return array_values(array_unique($ids));
+    }
+
+    protected function findCategoryId(string $name, string $locale): ?int
+    {
+        $match = function ($query) use ($name) {
+            $query->where(function ($inner) use ($name) {
+                $inner->whereRaw('TRIM(name) = ?', [$name])
+                    ->orWhere('slug', $name);
+            });
+        };
+
+        $preferred = DB::table('category_translations')
+            ->where('locale', $locale)
+            ->where($match)
+            ->orderBy('category_id')
+            ->value('category_id');
+
+        if ($preferred) {
+            return (int) $preferred;
+        }
+
+        $any = DB::table('category_translations')
+            ->where($match)
+            ->orderBy('category_id')
+            ->value('category_id');
+
+        return $any ? (int) $any : null;
+    }
+
+    /**
+     * @param  list<int>  $categoryIds
+     */
+    protected function saveCategories(Product $product, array $categoryIds): void
+    {
+        DB::table('product_categories')->where('product_id', $product->id)->delete();
+
+        $categoryIds = array_values(array_unique(array_map('intval', $categoryIds)));
+
+        if ($categoryIds === []) {
+            return;
+        }
+
+        $hasPosition = Schema::hasColumn('product_categories', 'position');
+        $rows = [];
+
+        foreach ($categoryIds as $categoryId) {
+            $row = [
+                'product_id'  => $product->id,
+                'category_id' => $categoryId,
+            ];
+
+            if ($hasPosition) {
+                $maxPosition = (int) DB::table('product_categories')
+                    ->where('category_id', $categoryId)
+                    ->max('position');
+
+                $row['position'] = $maxPosition + 1;
+            }
+
+            $rows[] = $row;
+        }
+
+        DB::table('product_categories')->insert($rows);
+
+        $product->unsetRelation('categories');
+    }
+
+    protected function saveInventory(Product $product, int $qty): void
+    {
+        $existing = DB::table('product_inventories')
+            ->where('product_id', $product->id)
+            ->orderBy('id')
+            ->first();
+
+        $sourceId = $existing?->inventory_source_id
+            ?? DB::table('inventory_sources')->value('id');
+
+        if (! $sourceId) {
+            throw new ProductBasicInfoSyncException(trans('longyi::app.product-sync.errors.no-inventory-source'));
+        }
+
+        DB::table('product_inventories')->updateOrInsert(
+            [
+                'product_id'          => $product->id,
+                'inventory_source_id' => $sourceId,
+                'vendor_id'           => $existing?->vendor_id ?? 0,
+            ],
+            ['qty' => $qty]
+        );
+    }
+
+    /**
+     * @param  list<int>  $productIds
+     */
+    public function reindexProducts(array $productIds): void
+    {
+        $ids = array_values(array_unique(array_filter($productIds)));
+
+        if ($ids === []) {
+            return;
+        }
+
+        $priceIndexer = app(Price::class);
+        $flatIndexer = app(Flat::class);
+        $elasticIndexer = $this->elasticEnabled() ? app(ElasticSearch::class) : null;
+
+        $relations = [
+            'attribute_family',
+            'attribute_values',
+            'channels',
+            'categories',
+            'super_attributes',
+            'variants',
+            'variants.channels',
+            'variants.attribute_family',
+            'variants.attribute_values',
+            'price_indices',
+            'customer_group_prices',
+            'catalog_rule_prices',
+            'flexibleVariants',
+            'flexibleVariants.price_indices',
+            'flexibleVariants.customer_group_prices',
+        ];
+
+        foreach (array_chunk($ids, 20) as $chunk) {
+            $products = Product::with($relations)->whereIn('id', $chunk)->get();
+
+            foreach ($products as $product) {
+                try {
+                    $priceIndexer->reindexRow($product);
+                    $flatIndexer->reindexRow($product);
+                    $elasticIndexer?->reindexRow($product);
+                } catch (\Throwable) {
+                    // Keep the spreadsheet update even if a single index rebuild fails.
+                }
+            }
+        }
+    }
+
+    protected function elasticEnabled(): bool
+    {
+        return core()->getConfigData('catalog.products.search.engine') === 'elastic';
+    }
+
+    /**
+     * @return list<string>
+     */
+    protected function splitCategories(string $raw): array
+    {
+        return array_values(array_unique(array_filter(array_map(
+            'trim',
+            preg_split('/\s*[||]\s*/u', $raw) ?: []
+        ), fn (string $name) => $name !== '')));
+    }
+
+    protected function normalizeHeading(mixed $heading): string
+    {
+        $normalized = strtolower(trim((string) $heading, " \t\n\r\0\x0B\xC2\xA0"));
+
+        return match ($normalized) {
+            'category', '分类' => ProductBasicInfoColumns::CATEGORIES,
+            'id', 'productid', '产品id' => ProductBasicInfoColumns::PRODUCT_ID,
+            default => $normalized,
+        };
+    }
+
+    /**
+     * @param  array<string, mixed>  $row
+     */
+    protected function rowIsEmpty(array $row): bool
+    {
+        foreach ($row as $value) {
+            if (! $this->isEmptyValue($value)) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    protected function isEmptyValue(mixed $value): bool
+    {
+        if ($value === null) {
+            return true;
+        }
+
+        if (is_string($value)) {
+            return trim($value) === '';
+        }
+
+        return false;
+    }
+
+    protected function cellString(mixed $value): string
+    {
+        if ($value === null) {
+            return '';
+        }
+
+        if (is_bool($value)) {
+            return $value ? '1' : '0';
+        }
+
+        if (is_int($value)) {
+            return (string) $value;
+        }
+
+        if (is_float($value)) {
+            if (floor($value) == $value) {
+                return (string) (int) $value;
+            }
+
+            return rtrim(rtrim(sprintf('%.4F', $value), '0'), '.');
+        }
+
+        return trim((string) $value);
+    }
+}

+ 159 - 0
packages/Longyi/Core/src/Services/ProductSyncImportService.php

@@ -0,0 +1,159 @@
+<?php
+
+namespace Longyi\Core\Services;
+
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Storage;
+use Longyi\Core\Imports\ProductBasicInfoSpreadsheet;
+use Longyi\Core\Jobs\ImportProductBasicInfoJob;
+use Longyi\Core\Models\AdminNotification;
+use Longyi\Core\Models\ProductSyncImport;
+use Maatwebsite\Excel\Facades\Excel;
+use Webkul\User\Models\Admin;
+
+class ProductSyncImportService
+{
+    public const DISK = 'local';
+
+    public function __construct(protected ProductBasicInfoSyncService $syncService) {}
+
+    public function queue(UploadedFile $file, ?Admin $admin, string $locale): ProductSyncImport
+    {
+        $path = $file->store('product-sync', self::DISK);
+
+        $import = ProductSyncImport::query()->create([
+            'admin_id'          => $admin?->id,
+            'original_filename' => $file->getClientOriginalName(),
+            'disk_path'         => $path,
+            'status'            => ProductSyncImport::STATUS_PENDING,
+            'locale'            => $locale,
+        ]);
+
+        ImportProductBasicInfoJob::dispatch($import->id, $locale);
+
+        return $import;
+    }
+
+    public function process(ProductSyncImport $import, bool $reindex = true): ProductSyncImport
+    {
+        if ($import->status !== ProductSyncImport::STATUS_PENDING) {
+            return $import;
+        }
+
+        $import->update([
+            'status'     => ProductSyncImport::STATUS_PROCESSING,
+            'started_at' => now(),
+        ]);
+
+        try {
+            if (! Storage::disk(self::DISK)->exists($import->disk_path)) {
+                throw new ProductBasicInfoSyncException(trans('longyi::app.product-sync.errors.empty-file'));
+            }
+
+            $sheets = Excel::toArray(
+                new ProductBasicInfoSpreadsheet,
+                $import->disk_path,
+                self::DISK
+            );
+
+            $result = $this->syncService->syncFromTable($sheets[0] ?? [], $reindex);
+
+            $import->update([
+                'status'        => ProductSyncImport::STATUS_COMPLETED,
+                'updated_count' => $result->updated,
+                'failed_count'  => $result->failed,
+                'errors'        => $result->errors,
+                'message'       => null,
+                'finished_at'   => now(),
+            ]);
+        } catch (ProductBasicInfoSyncException $e) {
+            $this->markFailed($import, $e->getMessage());
+        } catch (\Throwable $e) {
+            Log::error('Product sync import failed.', [
+                'import_id' => $import->id,
+                'message'   => $e->getMessage(),
+            ]);
+
+            $this->markFailed($import, $e->getMessage());
+        } finally {
+            $this->deleteStoredFile($import);
+        }
+
+        $import->refresh();
+
+        $this->notify($import);
+
+        return $import;
+    }
+
+    public function markFailedAndNotify(ProductSyncImport $import, string $message): void
+    {
+        if ($import->isFinished()) {
+            return;
+        }
+
+        $this->markFailed($import, $message);
+        $this->deleteStoredFile($import);
+        $this->notify($import->fresh());
+    }
+
+    public function notify(ProductSyncImport $import): void
+    {
+        try {
+            AdminNotification::query()->create([
+                'type'        => AdminNotification::TYPE_PRODUCT_SYNC,
+                'read'        => 0,
+                'order_id'    => null,
+                'title'       => $import->summaryMessage(),
+                'description' => $import->original_filename,
+                'route'       => 'admin.catalog.products.sync.index',
+                'admin_id'    => $import->admin_id,
+            ]);
+        } catch (\Throwable $e) {
+            Log::error('Product sync import notification failed.', [
+                'import_id' => $import->id,
+                'message'   => $e->getMessage(),
+            ]);
+        }
+    }
+
+    /**
+     * @return list<ProductSyncImport>
+     */
+    public function recentForAdmin(int $adminId, int $limit = 10): array
+    {
+        return ProductSyncImport::query()
+            ->where('admin_id', $adminId)
+            ->latest('id')
+            ->limit($limit)
+            ->get()
+            ->all();
+    }
+
+    protected function markFailed(ProductSyncImport $import, string $message): void
+    {
+        $import->update([
+            'status'      => ProductSyncImport::STATUS_FAILED,
+            'message'     => $message,
+            'finished_at' => now(),
+        ]);
+    }
+
+    protected function deleteStoredFile(ProductSyncImport $import): void
+    {
+        if ($import->disk_path === '' || $import->disk_path === null) {
+            return;
+        }
+
+        try {
+            Storage::disk(self::DISK)->delete($import->disk_path);
+        } catch (\Throwable $e) {
+            Log::warning('Product sync import file could not be deleted.', [
+                'import_id' => $import->id,
+                'path'      => $import->disk_path,
+                'message'   => $e->getMessage(),
+            ]);
+        }
+    }
+}

+ 70 - 0
packages/Longyi/Core/src/Support/ProductBasicInfoColumns.php

@@ -0,0 +1,70 @@
+<?php
+
+namespace Longyi\Core\Support;
+
+class ProductBasicInfoColumns
+{
+    public const SKU = 'sku';
+
+    public const PRODUCT_ID = 'product_id';
+
+    public const LOCALE = 'locale';
+
+    public const QTY = 'qty';
+
+    public const CATEGORIES = 'categories';
+
+    /**
+     * Excel header order. First row of export/import files.
+     *
+     * @var list<string>
+     */
+    public const HEADINGS = [
+        self::PRODUCT_ID,
+        self::SKU,
+        self::LOCALE,
+        'name',
+        'url_key',
+        'short_description',
+        'description',
+        'price',
+        'special_price',
+        'weight',
+        'status',
+        self::QTY,
+        self::CATEGORIES,
+        'meta_title',
+        'meta_keywords',
+        'meta_description',
+    ];
+
+    /**
+     * EAV attribute codes written via type update whitelist.
+     *
+     * @var list<string>
+     */
+    public const ATTRIBUTE_CODES = [
+        'name',
+        'url_key',
+        'short_description',
+        'description',
+        'price',
+        'special_price',
+        'weight',
+        'status',
+        'meta_title',
+        'meta_keywords',
+        'meta_description',
+    ];
+
+    /**
+     * Attribute codes that may not be cleared to an empty value.
+     *
+     * @var list<string>
+     */
+    public const REQUIRED_ATTRIBUTE_CODES = [
+        'name',
+        'url_key',
+        'price',
+    ];
+}

+ 4 - 0
packages/Longyi/Core/src/Type/FlexibleVariant.php

@@ -72,6 +72,10 @@ class FlexibleVariant extends AbstractType
     {
         $product = parent::update($data, $id, $attribute);
 
+        if (is_array($attribute) && $attribute !== []) {
+            return $product;
+        }
+
         if (isset($data['options']) && is_array($data['options'])) {
             $syncData = [];
 

+ 184 - 0
packages/Longyi/Core/tests/TestCase.php

@@ -0,0 +1,184 @@
+<?php
+
+namespace Longyi\Core\Tests;
+
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+use Tests\TestCase as BaseTestCase;
+use Webkul\Attribute\Models\Attribute;
+use Webkul\Category\Models\Category;
+use Webkul\Core\Models\Channel;
+use Webkul\Product\Models\Product;
+use Webkul\Product\Models\ProductAttributeValue;
+
+abstract class TestCase extends BaseTestCase
+{
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        if (! Schema::hasTable('products') || ! Schema::hasTable('attribute_families')) {
+            $this->markTestSkipped('Catalog tables are missing.');
+        }
+    }
+
+    protected function seedRequiredData(): void
+    {
+        try {
+            if (! Category::query()->exists()) {
+                Category::factory()->create([
+                    'parent_id' => null,
+                ]);
+            }
+
+            if (! Channel::query()->exists()) {
+                Channel::factory()->create();
+            }
+        } catch (\Exception $e) {
+            $this->markTestSkipped('Test database not properly configured: '.$e->getMessage());
+        }
+    }
+
+    protected function createSimpleProduct(array $overrides = []): Product
+    {
+        $this->seedRequiredData();
+
+        $attributeFamilyId = (int) (DB::table('attribute_families')->value('id') ?? 0);
+
+        if ($attributeFamilyId === 0) {
+            $this->markTestSkipped('Run Bagisto seeders for attribute_families.');
+        }
+
+        $product = Product::factory()->create([
+            'type'                => 'simple',
+            'attribute_family_id' => $attributeFamilyId,
+            ...$overrides,
+        ]);
+
+        $this->upsertProductAttributeValue($product->id, 'name', 'Test '.$product->sku);
+        $this->upsertProductAttributeValue($product->id, 'url_key', strtolower($product->sku));
+        $this->upsertProductAttributeValue($product->id, 'status', 1, null, 'default');
+        $this->upsertProductAttributeValue($product->id, 'price', 10.0, null, null);
+
+        return $product;
+    }
+
+    protected function upsertProductAttributeValue(
+        int $productId,
+        string $attributeCode,
+        mixed $value,
+        ?string $locale = 'en',
+        ?string $channel = null
+    ): void {
+        $attribute = Attribute::query()->where('code', $attributeCode)->first();
+
+        if (! $attribute) {
+            $this->markTestSkipped(sprintf('Required attribute "%s" not found. Run Bagisto seeders for attributes.', $attributeCode));
+        }
+
+        $type = (string) ($attribute->type ?? 'text');
+        $field = ProductAttributeValue::$attributeTypeFields[$type] ?? 'text_value';
+
+        $payload = [
+            'product_id'     => $productId,
+            'attribute_id'   => (int) $attribute->id,
+            'locale'         => $attribute->value_per_locale ? $locale : null,
+            'channel'        => $attribute->value_per_channel ? ($channel ?? 'default') : null,
+            'text_value'     => null,
+            'boolean_value'  => null,
+            'integer_value'  => null,
+            'float_value'    => null,
+            'datetime_value' => null,
+            'date_value'     => null,
+            'json_value'     => null,
+        ];
+
+        $payload[$field] = $field === 'boolean_value'
+            ? (bool) $value
+            : ($field === 'float_value' ? (float) $value : $value);
+
+        $payload['unique_id'] = implode('|', array_filter([
+            $payload['channel'],
+            $payload['locale'],
+            $productId,
+            (int) $attribute->id,
+        ]));
+
+        ProductAttributeValue::query()->updateOrCreate(
+            [
+                'product_id'   => $productId,
+                'attribute_id' => (int) $attribute->id,
+                'locale'       => $payload['locale'],
+                'channel'      => $payload['channel'],
+            ],
+            $payload
+        );
+    }
+
+    protected function attributeText(int $productId, string $code): ?string
+    {
+        $attribute = Attribute::query()->where('code', $code)->first();
+
+        if (! $attribute) {
+            return null;
+        }
+
+        $field = ProductAttributeValue::$attributeTypeFields[$attribute->type] ?? 'text_value';
+
+        $row = ProductAttributeValue::query()
+            ->where('product_id', $productId)
+            ->where('attribute_id', $attribute->id)
+            ->orderByDesc('id')
+            ->first();
+
+        if (! $row) {
+            return null;
+        }
+
+        $value = $row->{$field};
+
+        return $value === null ? null : (string) $value;
+    }
+
+    protected function ensureInventory(Product $product, int $qty = 50): int
+    {
+        $inventorySourceId = (int) (DB::table('inventory_sources')->value('id') ?? 0);
+
+        if (! $inventorySourceId) {
+            $this->markTestSkipped('No inventory_sources found. Run Bagisto seeders for inventory sources.');
+        }
+
+        DB::table('product_inventories')->updateOrInsert(
+            [
+                'product_id'          => $product->id,
+                'inventory_source_id' => $inventorySourceId,
+                'vendor_id'           => 0,
+            ],
+            ['qty' => $qty]
+        );
+
+        return $inventorySourceId;
+    }
+
+    protected function createNamedCategory(string $name, string $locale = 'en'): Category
+    {
+        $category = Category::factory()->create([
+            'parent_id' => null,
+        ]);
+
+        DB::table('category_translations')->updateOrInsert(
+            [
+                'category_id' => $category->id,
+                'locale'      => $locale,
+            ],
+            [
+                'name'        => $name,
+                'slug'        => strtolower(str_replace(' ', '-', $name)).'-'.$category->id,
+                'url_path'    => strtolower(str_replace(' ', '-', $name)).'-'.$category->id,
+                'description' => $name,
+            ]
+        );
+
+        return $category->fresh();
+    }
+}

+ 268 - 0
packages/Longyi/Core/tests/Unit/ProductBasicInfoSyncServiceTest.php

@@ -0,0 +1,268 @@
+<?php
+
+namespace Longyi\Core\Tests\Unit;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+use Longyi\Core\Models\ProductOption;
+use Longyi\Core\Services\ProductBasicInfoSyncService;
+use Longyi\Core\Support\ProductBasicInfoColumns;
+use Longyi\Core\Tests\TestCase;
+use Webkul\Product\Models\Product;
+
+class ProductBasicInfoSyncServiceTest extends TestCase
+{
+    public function test_updates_name_price_and_status_for_existing_sku(): void
+    {
+        $product = $this->createSimpleProduct(['sku' => 'SYNC-CORE-1']);
+
+        $result = $this->syncService()->sync([[
+            'sku'    => 'SYNC-CORE-1',
+            'locale' => 'en',
+            'name'   => 'Updated Name',
+            'price'  => 29.99,
+            'status' => 0,
+        ]], false);
+
+        $this->assertSame(1, $result->updated);
+        $this->assertSame(0, $result->failed);
+        $this->assertSame('Updated Name', $this->attributeText($product->id, 'name'));
+        $this->assertEqualsWithDelta(29.99, (float) $this->attributeText($product->id, 'price'), 0.001);
+        $this->assertSame(0, (int) $this->attributeText($product->id, 'status'));
+    }
+
+    public function test_unknown_sku_empty_name_and_unknown_category_fail_without_writing(): void
+    {
+        $emptyNameProduct = $this->createSimpleProduct(['sku' => 'SYNC-CORE-2']);
+        $categoryProduct = $this->createSimpleProduct(['sku' => 'SYNC-CORE-2B']);
+        $originalEmptyName = $this->attributeText($emptyNameProduct->id, 'name');
+        $originalCategoryName = $this->attributeText($categoryProduct->id, 'name');
+
+        $result = $this->syncService()->sync([
+            [
+                'sku'    => 'MISSING-SKU',
+                'locale' => 'en',
+                'name'   => 'Should Not Exist',
+            ],
+            [
+                'sku'    => 'SYNC-CORE-2',
+                'locale' => 'en',
+                'name'   => '',
+            ],
+            [
+                'sku'        => 'SYNC-CORE-2B',
+                'locale'     => 'en',
+                'name'       => 'Changed Anyway',
+                'categories' => 'No Such Category',
+            ],
+        ], false);
+
+        $this->assertSame(0, $result->updated);
+        $this->assertSame(3, $result->failed);
+        $this->assertSame($originalEmptyName, $this->attributeText($emptyNameProduct->id, 'name'));
+        $this->assertSame($originalCategoryName, $this->attributeText($categoryProduct->id, 'name'));
+        $this->assertFalse(
+            DB::table('products')->where('sku', 'MISSING-SKU')->exists()
+        );
+    }
+
+    public function test_empty_qty_and_categories_do_not_overwrite_existing_values(): void
+    {
+        $product = $this->createSimpleProduct(['sku' => 'SYNC-CORE-3']);
+        $this->ensureInventory($product, 42);
+
+        $category = $this->createNamedCategory('Kept Category');
+        $product->categories()->sync([$category->id => ['position' => 0]]);
+
+        $result = $this->syncService()->sync([[
+            'sku'        => 'SYNC-CORE-3',
+            'locale'     => 'en',
+            'name'       => 'Name After Empty Columns',
+            'qty'        => '',
+            'categories' => '',
+        ]], false);
+
+        $this->assertSame(1, $result->updated);
+        $this->assertSame('Name After Empty Columns', $this->attributeText($product->id, 'name'));
+        $this->assertSame(42, (int) DB::table('product_inventories')->where('product_id', $product->id)->value('qty'));
+        $this->assertTrue($product->fresh()->categories->contains('id', $category->id));
+    }
+
+    public function test_replaces_categories_when_names_are_updated(): void
+    {
+        $product = $this->createSimpleProduct(['sku' => 'SYNC-CAT-1']);
+        $original = $this->createNamedCategory('Original Category');
+        $target = $this->createNamedCategory('Target Category');
+        $product->categories()->sync([$original->id => ['position' => 0]]);
+
+        $result = $this->syncService()->sync([[
+            'sku'        => 'SYNC-CAT-1',
+            'locale'     => 'en',
+            'categories' => 'Target Category',
+        ]], false);
+
+        $this->assertSame(1, $result->updated, json_encode($result->errors));
+        $this->assertSame(0, $result->failed);
+        $fresh = $product->fresh()->categories->pluck('id')->all();
+        $this->assertEqualsCanonicalizing([$target->id], $fresh);
+    }
+
+    public function test_resolves_category_name_from_another_locale(): void
+    {
+        $product = $this->createSimpleProduct(['sku' => 'SYNC-CAT-2']);
+        $category = $this->createNamedCategory('假发', 'zh_CN');
+
+        $result = $this->syncService()->sync([[
+            'sku'        => 'SYNC-CAT-2',
+            'locale'     => 'en',
+            'categories' => '假发',
+        ]], false);
+
+        $this->assertSame(1, $result->updated, json_encode($result->errors));
+        $this->assertTrue($product->fresh()->categories->contains('id', $category->id));
+    }
+
+    public function test_keeps_comma_inside_category_name(): void
+    {
+        $product = $this->createSimpleProduct(['sku' => 'SYNC-CAT-3']);
+        $category = $this->createNamedCategory('13x4 Lace Front Wigs, Human Hair');
+
+        $result = $this->syncService()->sync([[
+            'sku'        => 'SYNC-CAT-3',
+            'locale'     => 'en',
+            'categories' => '13x4 Lace Front Wigs, Human Hair',
+        ]], false);
+
+        $this->assertSame(1, $result->updated, json_encode($result->errors));
+        $this->assertTrue($product->fresh()->categories->contains('id', $category->id));
+    }
+
+    public function test_map_table_accepts_category_heading_alias(): void
+    {
+        $rows = $this->syncService()->mapTableToRows([
+            ['sku', 'category'],
+            ['ABC', 'Wigs'],
+        ]);
+
+        $this->assertSame('Wigs', $rows[0]['categories']);
+    }
+
+    public function test_whitelist_update_does_not_remove_images_or_options(): void
+    {
+        $product = $this->createSimpleProduct(['sku' => 'SYNC-CORE-4']);
+
+        DB::table('product_images')->insert([
+            'type'       => 'images',
+            'path'       => 'catalog/product/keep-me.jpg',
+            'product_id' => $product->id,
+            'position'   => 1,
+        ]);
+
+        $optionCount = null;
+
+        if (Schema::hasTable('product_options') && Schema::hasTable('product_product_options')) {
+            $option = ProductOption::query()->create([
+                'label'    => 'Keep Option',
+                'code'     => 'keep_option_'.$product->id,
+                'type'     => 'select',
+                'position' => 0,
+            ]);
+
+            $product->options()->attach($option->id, [
+                'position'    => 0,
+                'is_required' => true,
+            ]);
+
+            $optionCount = DB::table('product_product_options')->where('product_id', $product->id)->count();
+            $this->assertSame(1, $optionCount);
+        }
+
+        $imageCount = DB::table('product_images')->where('product_id', $product->id)->count();
+
+        $result = $this->syncService()->sync([[
+            'sku'   => 'SYNC-CORE-4',
+            'name'  => 'Still Has Media',
+            'price' => 12.5,
+        ]], false);
+
+        $this->assertSame(1, $result->updated);
+        $this->assertSame($imageCount, DB::table('product_images')->where('product_id', $product->id)->count());
+
+        if ($optionCount !== null) {
+            $this->assertSame(
+                $optionCount,
+                DB::table('product_product_options')->where('product_id', $product->id)->count()
+            );
+        }
+    }
+
+    public function test_map_table_requires_sku_column(): void
+    {
+        $this->expectExceptionMessage(trans('longyi::app.product-sync.errors.sku-column-missing'));
+
+        $this->syncService()->mapTableToRows([
+            ['name', 'price'],
+            ['Wig', '10'],
+        ]);
+    }
+
+    public function test_headings_match_export_contract(): void
+    {
+        $this->assertSame([
+            'product_id',
+            'sku',
+            'locale',
+            'name',
+            'url_key',
+            'short_description',
+            'description',
+            'price',
+            'special_price',
+            'weight',
+            'status',
+            'qty',
+            'categories',
+            'meta_title',
+            'meta_keywords',
+            'meta_description',
+        ], ProductBasicInfoColumns::HEADINGS);
+    }
+
+    public function test_duplicate_sku_rows_are_rejected(): void
+    {
+        $this->createSimpleProduct(['sku' => 'SYNC-CORE-5']);
+
+        $result = $this->syncService()->sync([
+            [
+                'sku'  => 'SYNC-CORE-5',
+                'name' => 'First',
+            ],
+            [
+                'sku'  => 'SYNC-CORE-5',
+                'name' => 'Second',
+            ],
+        ], false);
+
+        $this->assertSame(1, $result->updated);
+        $this->assertSame(1, $result->failed);
+        $this->assertSame('First', $this->attributeText(
+            (int) Product::query()->where('sku', 'SYNC-CORE-5')->value('id'),
+            'name'
+        ));
+    }
+
+    public function test_export_route_is_not_captured_by_product_file_download(): void
+    {
+        $route = app('router')->getRoutes()->match(
+            Request::create('/admin/catalog/products/sync/export', 'GET')
+        );
+
+        $this->assertSame('admin.catalog.products.sync.export', $route->getName());
+    }
+
+    protected function syncService(): ProductBasicInfoSyncService
+    {
+        return app(ProductBasicInfoSyncService::class);
+    }
+}

+ 124 - 0
packages/Longyi/Core/tests/Unit/ProductSyncImportServiceTest.php

@@ -0,0 +1,124 @@
+<?php
+
+namespace Longyi\Core\Tests\Unit;
+
+use Illuminate\Http\Request;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\Queue;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Support\Facades\Storage;
+use Longyi\Core\Jobs\ImportProductBasicInfoJob;
+use Longyi\Core\Models\AdminNotification;
+use Longyi\Core\Models\ProductSyncImport;
+use Longyi\Core\Services\ProductSyncImportService;
+use Longyi\Core\Tests\TestCase;
+
+class ProductSyncImportServiceTest extends TestCase
+{
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        if (! Schema::hasTable('product_sync_imports')) {
+            $this->markTestSkipped('product_sync_imports table is missing.');
+        }
+    }
+
+    public function test_queue_stores_file_and_dispatches_job(): void
+    {
+        Queue::fake();
+        Storage::fake(ProductSyncImportService::DISK);
+
+        $file = UploadedFile::fake()->create('products.csv', 20, 'text/csv');
+
+        $import = $this->service()->queue($file, null, 'en');
+
+        $this->assertSame(ProductSyncImport::STATUS_PENDING, $import->status);
+        $this->assertSame('products.csv', $import->original_filename);
+        $this->assertTrue(Storage::disk(ProductSyncImportService::DISK)->exists($import->disk_path));
+
+        Queue::assertPushed(ImportProductBasicInfoJob::class, function (ImportProductBasicInfoJob $job) use ($import) {
+            return $job->importId === $import->id && $job->locale === 'en';
+        });
+    }
+
+    public function test_process_updates_existing_sku_and_creates_admin_notice(): void
+    {
+        if (! Schema::hasColumn('notifications', 'title')) {
+            $this->markTestSkipped('notifications.title column is missing.');
+        }
+
+        $product = $this->createSimpleProduct(['sku' => 'SYNC-ASYNC-1']);
+        $path = 'product-sync/test-'.uniqid('', true).'.csv';
+
+        Storage::disk(ProductSyncImportService::DISK)->put(
+            $path,
+            "sku,name\nSYNC-ASYNC-1,Async Updated Name\n"
+        );
+
+        $import = ProductSyncImport::query()->create([
+            'original_filename' => 'products.csv',
+            'disk_path'         => $path,
+            'status'            => ProductSyncImport::STATUS_PENDING,
+            'locale'            => 'en',
+        ]);
+
+        try {
+            $import = $this->service()->process($import, false);
+        } finally {
+            Storage::disk(ProductSyncImportService::DISK)->delete($path);
+        }
+
+        $this->assertSame(ProductSyncImport::STATUS_COMPLETED, $import->status);
+        $this->assertSame(1, $import->updated_count);
+        $this->assertSame(0, $import->failed_count);
+        $this->assertSame('Async Updated Name', $this->attributeText($product->id, 'name'));
+        $this->assertFalse(Storage::disk(ProductSyncImportService::DISK)->exists($path));
+
+        $this->assertTrue(
+            AdminNotification::query()
+                ->where('type', AdminNotification::TYPE_PRODUCT_SYNC)
+                ->where('description', 'products.csv')
+                ->exists()
+        );
+    }
+
+    public function test_process_missing_file_marks_failed_and_notifies(): void
+    {
+        if (! Schema::hasColumn('notifications', 'title')) {
+            $this->markTestSkipped('notifications.title column is missing.');
+        }
+
+        $import = ProductSyncImport::query()->create([
+            'original_filename' => 'missing.csv',
+            'disk_path'         => 'product-sync/does-not-exist-'.uniqid('', true).'.csv',
+            'status'            => ProductSyncImport::STATUS_PENDING,
+            'locale'            => 'en',
+        ]);
+
+        $import = $this->service()->process($import, false);
+
+        $this->assertSame(ProductSyncImport::STATUS_FAILED, $import->status);
+        $this->assertNotSame('', (string) $import->message);
+        $this->assertTrue(
+            AdminNotification::query()
+                ->where('type', AdminNotification::TYPE_PRODUCT_SYNC)
+                ->where('description', 'missing.csv')
+                ->exists()
+        );
+    }
+
+    public function test_notice_route_is_registered(): void
+    {
+        $route = app('router')->getRoutes()->match(
+            Request::create('/admin/catalog/products/sync/notices/1', 'GET')
+        );
+
+        $this->assertSame('admin.catalog.products.sync.notice', $route->getName());
+    }
+
+    protected function service(): ProductSyncImportService
+    {
+        return app(ProductSyncImportService::class);
+    }
+}

+ 26 - 0
packages/Longyi/Feishu/composer.json

@@ -0,0 +1,26 @@
+{
+    "name": "longyi/feishu",
+    "description": "Feishu (Lark) custom-bot alerting for Bagisto operations",
+    "type": "library",
+    "license": "MIT",
+    "authors": [
+        {
+            "name": "Longyi",
+            "email": "dev@longyi.com"
+        }
+    ],
+    "minimum-stability": "dev",
+    "require": {},
+    "autoload": {
+        "psr-4": {
+            "Longyi\\Feishu\\": "src/"
+        }
+    },
+    "extra": {
+        "laravel": {
+            "providers": [
+                "Longyi\\Feishu\\Providers\\FeishuServiceProvider"
+            ]
+        }
+    }
+}

+ 108 - 0
packages/Longyi/Feishu/src/Config/feishu.php

@@ -0,0 +1,108 @@
+<?php
+
+use Longyi\Feishu\Listeners\PaymentAlertSubscriber;
+
+/*
+|--------------------------------------------------------------------------
+| 飞书群自定义机器人
+|--------------------------------------------------------------------------
+|
+| 用于把需要人工介入的运营事件推送到飞书群。发送始终走队列,飞书不可用
+| 不会影响支付主流程。
+|
+*/
+
+return [
+
+    /*
+    | 总开关。未配置 webhook_url 时即使打开也不会发送。
+    */
+    'enabled' => env('FEISHU_BOT_ENABLED', false),
+
+    /*
+    | 群机器人的 Webhook 地址,形如
+    | https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxx
+    */
+    'webhook_url' => env('FEISHU_BOT_WEBHOOK_URL', ''),
+
+    /*
+    | 机器人「加签」安全设置里的密钥。留空表示未开启加签。
+    */
+    'secret' => env('FEISHU_BOT_SECRET', ''),
+
+    /*
+    | 单次请求超时(秒)。飞书接口很快,不要设太长,免得拖住队列。
+    */
+    'timeout' => (int) env('FEISHU_BOT_TIMEOUT', 5),
+
+    /*
+    | 发送任务所在队列。
+    */
+    'queue' => env('FEISHU_BOT_QUEUE', 'notifications'),
+
+    /*
+    | 同一订单的同一类告警在该时间窗内只发一次,避免对账反复扫描时刷屏。
+    | 设为 0 关闭去重。
+    */
+    'throttle_minutes' => (int) env('FEISHU_BOT_THROTTLE_MINUTES', 60),
+
+    /*
+    | 卡片里「查看订单」按钮的地址模板,{id} 会被订单 id 替换。
+    | 留空时回退到 admin.sales.orders.view 路由。
+    */
+    'admin_order_url' => env('FEISHU_BOT_ADMIN_ORDER_URL', ''),
+
+    /*
+    | 环境标识,会显示在卡片脚注,避免测试环境的告警被误当成线上。
+    */
+    'environment' => env('FEISHU_BOT_ENVIRONMENT', env('APP_ENV', 'production')),
+
+    /*
+    |--------------------------------------------------------------------------
+    | 告警映射
+    |--------------------------------------------------------------------------
+    |
+    | key 为事件名,value 描述这条告警怎么呈现:
+    |
+    |   category — 归类,manual = 需人工处理的异常单,cancelled = 超时取消单
+    |   title    — 卡片标题
+    |   color    — 卡片头部颜色(red / orange / carmine / blue / green / grey)
+    |
+    | 想停掉某类告警,注释掉对应行即可,无需改代码。
+    */
+    'alerts' => [
+        /* 需人工处理的异常单 */
+        'bagistoapi.payment.reconcile.approved-unsettled' => [
+            'category' => PaymentAlertSubscriber::CATEGORY_MANUAL,
+            'title'    => '支付已授权但未入账,需人工确认',
+            'color'    => 'red',
+        ],
+        'bagistoapi.payment.reconcile.settle-failed' => [
+            'category' => PaymentAlertSubscriber::CATEGORY_MANUAL,
+            'title'    => '对账发现扣款但落账失败',
+            'color'    => 'red',
+        ],
+        'bagistoapi.payment.amount-mismatch' => [
+            'category' => PaymentAlertSubscriber::CATEGORY_MANUAL,
+            'title'    => '支付金额与订单金额不符',
+            'color'    => 'red',
+        ],
+        'bagistoapi.payment.capture-not-completed' => [
+            'category' => PaymentAlertSubscriber::CATEGORY_MANUAL,
+            'title'    => '网关扣款未完成',
+            'color'    => 'orange',
+        ],
+        'bagistoapi.payment.reconcile.no-gateway-info' => [
+            'category' => PaymentAlertSubscriber::CATEGORY_MANUAL,
+            'title'    => '订单缺少网关信息,无法对账',
+            'color'    => 'orange',
+        ],
+
+        /* 超时取消单 */
+        'bagistoapi.payment.reconcile.cancelled' => [
+            'category' => PaymentAlertSubscriber::CATEGORY_CANCELLED,
+            'title'    => '支付超时,订单已自动取消',
+            'color'    => 'grey',
+        ],
+    ],
+];

+ 44 - 0
packages/Longyi/Feishu/src/Jobs/SendFeishuMessageJob.php

@@ -0,0 +1,44 @@
+<?php
+
+namespace Longyi\Feishu\Jobs;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Log;
+use Longyi\Feishu\Services\FeishuBot;
+
+/**
+ * 异步投递飞书消息。
+ *
+ * 告警只是旁路,飞书超时或挂掉都不能反过来影响支付、对账这些主流程,
+ * 所以发送一律丢到队列,并且失败只记日志。
+ */
+class SendFeishuMessageJob implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    public int $tries = 3;
+
+    /**
+     * 飞书偶发 5xx 时给它一点恢复时间,同时避免把队列堵住。
+     */
+    public array $backoff = [10, 30, 60];
+
+    public function __construct(
+        public array $message,
+        public array $context = [],
+    ) {}
+
+    public function handle(FeishuBot $bot): void
+    {
+        $bot->send($this->message);
+    }
+
+    public function failed(\Throwable $e): void
+    {
+        Log::error('Feishu alert dropped after retries: '.$e->getMessage(), $this->context);
+    }
+}

+ 193 - 0
packages/Longyi/Feishu/src/Listeners/PaymentAlertSubscriber.php

@@ -0,0 +1,193 @@
+<?php
+
+namespace Longyi\Feishu\Listeners;
+
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Log;
+use Longyi\Feishu\Jobs\SendFeishuMessageJob;
+use Longyi\Feishu\Messages\AlertCard;
+
+/**
+ * 把支付对账过程中需要人关注的事件推到飞书。
+ *
+ * 通过通配符监听 bagistoapi.payment.*,再按 config('feishu.alerts') 过滤,
+ * 这样新增/停用一类告警只需要改配置。用通配符还有个好处:这些事件的 payload
+ * 有的是裸 Order、有的是关联数组,通配符监听器统一拿到原始数组,
+ * 不会被 Laravel 的参数展开规则坑到。
+ */
+class PaymentAlertSubscriber
+{
+    /** 需人工处理的异常单 */
+    public const CATEGORY_MANUAL = 'manual';
+
+    /** 支付超时被自动取消的订单 */
+    public const CATEGORY_CANCELLED = 'cancelled';
+
+    /**
+     * @param  array  $payload  裸 Order 时为 [0 => $order],否则为事件自带的关联数组
+     */
+    public function handle(string $eventName, array $payload): void
+    {
+        try {
+            /*
+             * 取整张表再按事件名索引:事件名本身带点,直接 config('feishu.alerts.'.$name)
+             * 会被当成多层嵌套键去解析,永远取不到。
+             */
+            $definition = config('feishu.alerts', [])[$eventName] ?? null;
+
+            if (! $definition || ! config('feishu.enabled', false)) {
+                return;
+            }
+
+            $order = $this->resolveOrder($payload);
+
+            if (! $order) {
+                return;
+            }
+
+            if (! $this->shouldSend($eventName, $order->id)) {
+                return;
+            }
+
+            SendFeishuMessageJob::dispatch(
+                $this->buildCard($eventName, $definition, $order, $payload)->toMessage(),
+                ['event' => $eventName, 'order_id' => $order->id],
+            )->onQueue((string) config('feishu.queue', 'notifications'));
+        } catch (\Throwable $e) {
+            /*
+             * 告警链路自身出问题,绝不能把异常抛回支付/对账流程。
+             */
+            Log::warning('Feishu payment alert skipped: '.$e->getMessage(), [
+                'event' => $eventName,
+            ]);
+        }
+    }
+
+    /**
+     * 事件 payload 形态不一:裸 Order 会被 Arr::wrap 成 [0 => $order],
+     * 其余事件用 'order' 键携带。
+     */
+    protected function resolveOrder(array $payload): mixed
+    {
+        $order = $payload['order'] ?? $payload[0] ?? null;
+
+        return is_object($order) && isset($order->id) ? $order : null;
+    }
+
+    /**
+     * 同一订单的同一类告警在窗口内只发一次。
+     *
+     * 对账兜底扫描可能在窗口边界上把同一个订单重复捞起来,没有这层去重
+     * 值班群会被同一条告警刷屏。
+     */
+    protected function shouldSend(string $eventName, int|string $orderId): bool
+    {
+        $minutes = (int) config('feishu.throttle_minutes', 60);
+
+        if ($minutes <= 0) {
+            return true;
+        }
+
+        return Cache::add("feishu:alert:{$eventName}:{$orderId}", true, now()->addMinutes($minutes));
+    }
+
+    protected function buildCard(string $eventName, array $definition, mixed $order, array $payload): AlertCard
+    {
+        $category = $definition['category'] ?? self::CATEGORY_MANUAL;
+
+        $card = AlertCard::make(
+            $definition['title'] ?? $eventName,
+            $definition['color'] ?? 'red',
+        )
+            ->description($this->describe($category, $payload))
+            ->field('订单号', $order->increment_id ?? $order->id)
+            ->field('金额', $this->formatAmount($order))
+            ->field('订单状态', $order->status ?? null)
+            ->field('支付方式', $order->payment->method ?? null)
+            ->field('网关单号', $this->gatewayOrderId($order))
+            ->field('客户', $order->customer_email ?? null)
+            ->field('下单时间', $this->formatDate($order->created_at ?? null))
+            ->button('查看订单', $this->adminUrl($order->id))
+            ->footer(sprintf(
+                '%s · %s · %s',
+                config('feishu.environment', config('app.env')),
+                $eventName,
+                now()->toDateTimeString(),
+            ));
+
+        if (! empty($payload['gateway_status'])) {
+            $card->field('网关状态', $payload['gateway_status']);
+        }
+
+        if (isset($payload['expected'], $payload['captured'])) {
+            $card->field('应收 / 实收', $payload['expected'].' / '.$payload['captured']);
+        }
+
+        if (! empty($payload['error'])) {
+            $card->field('错误信息', $payload['error']);
+        }
+
+        return $card;
+    }
+
+    /**
+     * 两类告警要传达的行动不同:异常单要人去查,取消单只是知会。
+     */
+    protected function describe(string $category, array $payload): string
+    {
+        if ($category === self::CATEGORY_CANCELLED) {
+            return '该订单在支付窗口内未完成付款,系统已自动取消并释放库存。若客户反馈已扣款,请走退款或人工补单。';
+        }
+
+        return '**该订单需要人工介入。** 请核对网关后台的实际扣款情况,再决定补单或退款。';
+    }
+
+    protected function gatewayOrderId(mixed $order): ?string
+    {
+        $additional = $order->payment->additional ?? [];
+
+        if (! is_array($additional)) {
+            return null;
+        }
+
+        return $additional['gateway_order_id'] ?? $additional['paypal_order_id'] ?? null;
+    }
+
+    protected function formatAmount(mixed $order): ?string
+    {
+        if (! isset($order->grand_total)) {
+            return null;
+        }
+
+        return trim(($order->order_currency_code ?? '').' '.number_format((float) $order->grand_total, 2));
+    }
+
+    protected function formatDate(mixed $date): ?string
+    {
+        if (! $date) {
+            return null;
+        }
+
+        return $date instanceof \DateTimeInterface
+            ? $date->format('Y-m-d H:i:s')
+            : (string) $date;
+    }
+
+    /**
+     * 优先用配置的模板,方便后台域名和站点域名不同的部署。
+     */
+    protected function adminUrl(int|string $orderId): ?string
+    {
+        $template = (string) config('feishu.admin_order_url', '');
+
+        if ($template !== '') {
+            return str_replace('{id}', (string) $orderId, $template);
+        }
+
+        try {
+            return route('admin.sales.orders.view', $orderId);
+        } catch (\Throwable) {
+            return null;
+        }
+    }
+}

+ 132 - 0
packages/Longyi/Feishu/src/Messages/AlertCard.php

@@ -0,0 +1,132 @@
+<?php
+
+namespace Longyi\Feishu\Messages;
+
+/**
+ * 构造飞书交互卡片。
+ *
+ * 卡片比纯文本贵不了多少,但值班的人一眼就能看到订单号、金额和原因,
+ * 并且能直接点进后台,所以告警统一走卡片。
+ */
+class AlertCard
+{
+    protected array $fields = [];
+
+    protected ?string $description = null;
+
+    protected ?string $buttonUrl = null;
+
+    protected ?string $buttonText = null;
+
+    protected ?string $footer = null;
+
+    public function __construct(
+        protected string $title,
+        protected string $color = 'red',
+    ) {}
+
+    public static function make(string $title, string $color = 'red'): static
+    {
+        return new static($title, $color);
+    }
+
+    /**
+     * 正文首段,一般写这条告警发生了什么、需要人做什么。
+     */
+    public function description(?string $description): static
+    {
+        $this->description = $description;
+
+        return $this;
+    }
+
+    /**
+     * 追加一个「字段名: 值」。值为空时自动跳过,避免卡片出现空行。
+     */
+    public function field(string $label, mixed $value): static
+    {
+        if ($value === null || $value === '') {
+            return $this;
+        }
+
+        $this->fields[] = ['label' => $label, 'value' => (string) $value];
+
+        return $this;
+    }
+
+    public function button(?string $text, ?string $url): static
+    {
+        if ($text && $url) {
+            $this->buttonText = $text;
+            $this->buttonUrl = $url;
+        }
+
+        return $this;
+    }
+
+    public function footer(?string $footer): static
+    {
+        $this->footer = $footer;
+
+        return $this;
+    }
+
+    /**
+     * 输出可直接 POST 给 webhook 的消息体。
+     */
+    public function toMessage(): array
+    {
+        $elements = [];
+
+        if ($this->description) {
+            $elements[] = [
+                'tag'  => 'div',
+                'text' => ['tag' => 'lark_md', 'content' => $this->description],
+            ];
+        }
+
+        if ($this->fields) {
+            $elements[] = [
+                'tag'    => 'div',
+                'fields' => array_map(fn (array $field) => [
+                    'is_short' => true,
+                    'text'     => [
+                        'tag'     => 'lark_md',
+                        'content' => "**{$field['label']}**\n{$field['value']}",
+                    ],
+                ], $this->fields),
+            ];
+        }
+
+        if ($this->buttonUrl) {
+            $elements[] = [
+                'tag'     => 'action',
+                'actions' => [[
+                    'tag'  => 'button',
+                    'text' => ['tag' => 'plain_text', 'content' => $this->buttonText],
+                    'url'  => $this->buttonUrl,
+                    'type' => 'primary',
+                ]],
+            ];
+        }
+
+        if ($this->footer) {
+            $elements[] = [
+                'tag'      => 'note',
+                'elements' => [['tag' => 'plain_text', 'content' => $this->footer]],
+            ];
+        }
+
+        return [
+            'msg_type' => 'interactive',
+            'card'     => [
+                'config' => ['wide_screen_mode' => true],
+                'header' => [
+                    'template' => $this->color,
+                    'title'    => ['tag' => 'plain_text', 'content' => $this->title],
+                ],
+                'elements' => $elements,
+            ],
+        ];
+    }
+}

+ 19 - 0
packages/Longyi/Feishu/src/Providers/EventServiceProvider.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace Longyi\Feishu\Providers;
+
+use Illuminate\Support\Facades\Event;
+use Illuminate\Support\ServiceProvider;
+use Longyi\Feishu\Listeners\PaymentAlertSubscriber;
+
+class EventServiceProvider extends ServiceProvider
+{
+    public function boot(): void
+    {
+        /*
+         * 通配符监听拿到的是 (事件名, 原始 payload 数组),由订阅者自己
+         * 按 config('feishu.alerts') 决定是否要发,未配置的事件直接忽略。
+         */
+        Event::listen('bagistoapi.payment.*', [PaymentAlertSubscriber::class, 'handle']);
+    }
+}

+ 32 - 0
packages/Longyi/Feishu/src/Providers/FeishuServiceProvider.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace Longyi\Feishu\Providers;
+
+use Illuminate\Support\ServiceProvider;
+use Longyi\Feishu\Console\Commands\FeishuTestCommand;
+use Longyi\Feishu\Services\FeishuBot;
+
+class FeishuServiceProvider extends ServiceProvider
+{
+    public function register(): void
+    {
+        $this->mergeConfigFrom(__DIR__.'/../Config/feishu.php', 'feishu');
+
+        $this->app->singleton(FeishuBot::class, fn () => new FeishuBot());
+    }
+
+    public function boot(): void
+    {
+        $this->publishes([
+            __DIR__.'/../Config/feishu.php' => config_path('feishu.php'),
+        ], 'feishu-config');
+
+        $this->app->register(EventServiceProvider::class);
+
+        if ($this->app->runningInConsole()) {
+            $this->commands([
+                FeishuTestCommand::class,
+            ]);
+        }
+    }
+}

+ 99 - 0
packages/Longyi/Feishu/src/Services/FeishuBot.php

@@ -0,0 +1,99 @@
+<?php
+
+namespace Longyi\Feishu\Services;
+
+use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\Log;
+
+/**
+ * 飞书群自定义机器人客户端。
+ *
+ * 只负责把一条已经构造好的消息投递到 webhook,不关心业务语义。
+ */
+class FeishuBot
+{
+    public function __construct(
+        protected ?string $webhookUrl = null,
+        protected ?string $secret = null,
+        protected ?int $timeout = null,
+    ) {
+        $this->webhookUrl ??= (string) config('feishu.webhook_url', '');
+        $this->secret ??= (string) config('feishu.secret', '');
+        $this->timeout ??= (int) config('feishu.timeout', 5);
+    }
+
+    /**
+     * 开关打开且配置了 webhook 才算可用。
+     */
+    public function isEnabled(): bool
+    {
+        return (bool) config('feishu.enabled', false) && $this->webhookUrl !== '';
+    }
+
+    /**
+     * 投递一条飞书消息。
+     *
+     * @param  array  $message  形如 ['msg_type' => 'interactive', 'card' => [...]]
+     *
+     * @throws \RuntimeException 飞书返回业务错误时抛出,交给调用方重试
+     */
+    public function send(array $message): bool
+    {
+        if (! $this->isEnabled()) {
+            return false;
+        }
+
+        if ($this->secret !== '') {
+            $timestamp = time();
+
+            $message['timestamp'] = (string) $timestamp;
+            $message['sign'] = $this->sign($timestamp);
+        }
+
+        $response = Http::timeout($this->timeout)
+            ->asJson()
+            ->post($this->webhookUrl, $message);
+
+        /*
+         * 飞书对业务错误同样返回 HTTP 200,必须看响应体里的 code,
+         * 否则加签失败、机器人被停用这类问题会被当成发送成功。
+         */
+        $body = $response->json() ?? [];
+        $code = $body['code'] ?? $body['StatusCode'] ?? null;
+
+        if ($response->successful() && (int) $code === 0) {
+            return true;
+        }
+
+        throw new \RuntimeException(sprintf(
+            'Feishu webhook rejected the message (http %d, code %s): %s',
+            $response->status(),
+            var_export($code, true),
+            $body['msg'] ?? $body['StatusMessage'] ?? 'unknown error',
+        ));
+    }
+
+    /**
+     * 发送但不抛异常,用于绝对不能影响调用方的场景。
+     */
+    public function sendQuietly(array $message): bool
+    {
+        try {
+            return $this->send($message);
+        } catch (\Throwable $e) {
+            Log::warning('Feishu notification failed: '.$e->getMessage());
+
+            return false;
+        }
+    }
+
+    /**
+     * 加签:对 "{timestamp}\n{secret}" 做 HMAC-SHA256(空消息体),再 Base64。
+     */
+    public function sign(int $timestamp): string
+    {
+        return base64_encode(
+            hash_hmac('sha256', '', $timestamp."\n".$this->secret, true)
+        );
+    }
+}

+ 36 - 0
packages/Longyi/FreeGift/src/ApiResource/AddGift.php

@@ -0,0 +1,36 @@
+<?php
+
+namespace Longyi\FreeGift\ApiResource;
+
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\GraphQl\Mutation;
+use Longyi\FreeGift\State\GiftCartProcessor;
+
+/**
+ * GraphQL:手动领取一个赠品(赠品弹窗选择)。
+ *
+ * mutation createAddGift(input: {sku: "SP-003", ruleId: 4, qty: 1}) { addGift { success message cartItemId } }
+ *
+ * 内部复用 GiftProcessor::addGiftToCart(),会把该候选的
+ * is_item_deleted 标记清掉 —— 即「手动加回被我删过的赠品」。
+ */
+#[ApiResource(
+    routePrefix: '/api/shop',
+    shortName: 'AddGift',
+    operations: [],
+    graphQlOperations: [
+        new Mutation(
+            name: 'create',
+            input: AddGiftInput::class,
+            output: GiftActionResult::class,
+            processor: GiftCartProcessor::class,
+            denormalizationContext: [
+                'allow_extra_attributes' => true,
+                'groups'                 => ['mutation'],
+            ],
+            normalizationContext: ['groups' => ['mutation']],
+            description: '手动把某个赠品加进购物车',
+        ),
+    ]
+)]
+class AddGift {}

+ 28 - 0
packages/Longyi/FreeGift/src/ApiResource/AddGiftInput.php

@@ -0,0 +1,28 @@
+<?php
+
+namespace Longyi\FreeGift\ApiResource;
+
+use ApiPlatform\Metadata\ApiProperty;
+use Symfony\Component\Serializer\Annotation\Groups;
+
+/**
+ * createAddGift 的入参。
+ *
+ * ⚠ 属性一律 snake_case:GraphQL 传入的 camelCase 字段名会被转换后再
+ * 反序列化到属性上,写成 camelCase 会收到 null(同输出侧)。GraphQL 侧
+ * 的字段名仍是 camelCase(sku / ruleId / qty)。
+ */
+class AddGiftInput
+{
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: '赠品 SKU', required: true)]
+    public ?string $sku = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: '赠品规则(cart_rules.id)', required: true)]
+    public ?int $rule_id = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: '数量,默认 1,超出剩余名额自动截断')]
+    public ?float $qty = null;
+}

+ 30 - 0
packages/Longyi/FreeGift/src/ApiResource/GiftActionResult.php

@@ -0,0 +1,30 @@
+<?php
+
+namespace Longyi\FreeGift\ApiResource;
+
+use ApiPlatform\Metadata\ApiProperty;
+use Symfony\Component\Serializer\Annotation\Groups;
+
+/**
+ * 赠品操作的统一返回(addGift / removeGift / restoreGifts)。
+ *
+ * 只放单词字段:GraphQL 输出 DTO 的 camelCase(多词)字段会取不到值
+ * (项目里 PaymentMethodOutput::isAllowed 同样为 null),操作后需要
+ * 行 ID / 最新名额请重新查 giftCandidates 与 giftProducts。
+ */
+class GiftActionResult
+{
+    /**
+     * API Platform 需要一个标识属性,否则 normalize 阶段会抛
+     * "Not able to retrieve identifiers"(参考 DeleteAllCompareItems)。
+     */
+    #[Groups(['mutation'])]
+    #[ApiProperty(readable: true, writable: false, identifier: true)]
+    public ?string $id = null;
+
+    #[Groups(['mutation'])]
+    public ?bool $success = null;
+
+    #[Groups(['mutation'])]
+    public ?string $message = null;
+}

+ 51 - 0
packages/Longyi/FreeGift/src/ApiResource/GiftBannerData.php

@@ -0,0 +1,51 @@
+<?php
+
+namespace Longyi\FreeGift\ApiResource;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\GraphQl\Query;
+use ApiPlatform\Metadata\GraphQl\QueryCollection;
+use Longyi\FreeGift\State\GiftCartProvider;
+use Symfony\Component\Serializer\Annotation\Groups;
+
+/**
+ * 赠品活动 Banner(受 sales.free_gift.general.show_banner 与规则 show_banner 控制)。
+ *
+ * ⚠ 属性一律 snake_case(原因见 GiftProductData 的注释)。字段名 = giftBanners。
+ */
+#[ApiResource(
+    routePrefix: '/api/shop',
+    shortName: 'GiftBanner',
+    operations: [],
+    graphQlOperations: [
+        // item_query 用于「被别的输出嵌套时的标识提取」,见 GiftProductData 的注释
+        new Query(name: 'item_query', output: false),
+        new QueryCollection(
+            paginationEnabled: false,
+            provider: GiftCartProvider::class,
+            normalizationContext: ['groups' => ['query']],
+        ),
+    ]
+)]
+class GiftBannerData
+{
+    #[Groups(['query'])]
+    #[ApiProperty(readable: true, writable: false, identifier: true)]
+    public ?string $id = null;
+
+    #[Groups(['query'])]
+    public ?int $rule_id = null;
+
+    #[Groups(['query'])]
+    public ?string $text = null;
+
+    #[Groups(['query'])]
+    public ?string $image = null;
+
+    #[Groups(['query'])]
+    public ?float $allowed_qty = null;
+
+    #[Groups(['query'])]
+    public ?float $remaining_qty = null;
+}

+ 69 - 0
packages/Longyi/FreeGift/src/ApiResource/GiftGroupData.php

@@ -0,0 +1,69 @@
+<?php
+
+namespace Longyi\FreeGift\ApiResource;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\GraphQl\Query;
+use ApiPlatform\Metadata\GraphQl\QueryCollection;
+use Longyi\FreeGift\State\GiftCartProvider;
+use Symfony\Component\Serializer\Annotation\Groups;
+
+/**
+ * 一条赠品规则对应的候选分组(只有规则级信息)。
+ *
+ * 具体可选的赠品走 giftProducts,用返回里的 rule_id 归组 —— 不在这里嵌套
+ * 资源数组,否则 API Platform 会把它当「关联资源」去调 GiftProduct 的
+ * provider,导致每组都塞进全量赠品。
+ *
+ * ⚠ 属性一律 snake_case(原因见 GiftProductData 的注释)。
+ * 字段名 = pluralize(lcfirst(GiftCandidate)) = giftCandidates。
+ */
+#[ApiResource(
+    routePrefix: '/api/shop',
+    shortName: 'GiftCandidate',
+    operations: [],
+    graphQlOperations: [
+        // item_query 用于「被别的输出嵌套时的标识提取」,见 GiftProductData 的注释
+        new Query(name: 'item_query', output: false),
+        new QueryCollection(
+            paginationEnabled: false,
+            provider: GiftCartProvider::class,
+            normalizationContext: ['groups' => ['query']],
+        ),
+    ]
+)]
+class GiftGroupData
+{
+    /**
+     * API Platform 需要标识属性(参考 PaymentMethodOutput),否则
+     * GraphQL 输出会被清空。
+     */
+    #[Groups(['query'])]
+    #[ApiProperty(readable: true, writable: false, identifier: true)]
+    public ?string $id = null;
+
+    #[Groups(['query'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public ?int $rule_id = null;
+
+    #[Groups(['query'])]
+    public ?string $rule_name = null;
+
+    #[Groups(['query'])]
+    #[ApiProperty(description: '0 = 每个 SKU 各送 N 个;1 = 整组合计送 N 个(用户自选)')]
+    public ?int $type = null;
+
+    #[Groups(['query'])]
+    #[ApiProperty(description: '是否自动加购(false 时需用户手动挑选)')]
+    public ?bool $auto_add = null;
+
+    #[Groups(['query'])]
+    public ?float $allowed_qty = null;
+
+    #[Groups(['query'])]
+    public ?float $reserved_qty = null;
+
+    #[Groups(['query'])]
+    public ?float $remaining_qty = null;
+}

+ 91 - 0
packages/Longyi/FreeGift/src/ApiResource/GiftProductData.php

@@ -0,0 +1,91 @@
+<?php
+
+namespace Longyi\FreeGift\ApiResource;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\GraphQl\Query;
+use ApiPlatform\Metadata\GraphQl\QueryCollection;
+use Longyi\FreeGift\State\GiftCartProvider;
+use Symfony\Component\Serializer\Annotation\Groups;
+
+/**
+ * 单个赠品候选(对应 REST GiftController::candidates 里 groups[*].gifts[*])。
+ *
+ * ⚠ 属性一律 snake_case:GraphQL 的字段解析是按「转换后的属性名」从对象上取值,
+ * camelCase 多词属性会取不到(null)。输出成 camelCase 由
+ * api-platform.name_converter 完成,见 Webkul\BagistoApi\Models\CustomerProfile。
+ *
+ * 字段名 = giftProducts。
+ */
+#[ApiResource(
+    routePrefix: '/api/shop',
+    shortName: 'GiftProduct',
+    operations: [],
+    graphQlOperations: [
+        // 必须有 item_query(即便 output: false):CartData::$freeGifts 这种
+        // 「嵌套在别的输出里」的场景,API Platform 要靠它来提取标识,
+        // 缺了会抛 IdentifiersExtractor ... $parameterName must be of type string, null given。
+        // 同 Webkul\BagistoApi\Dto\CartItemData。
+        new Query(name: 'item_query', output: false),
+        new QueryCollection(
+            paginationEnabled: false,
+            provider: GiftCartProvider::class,
+            normalizationContext: ['groups' => ['query']],
+        ),
+    ]
+)]
+class GiftProductData
+{
+    #[Groups(['query'])]
+    #[ApiProperty(readable: true, writable: false, identifier: true)]
+    public ?string $id = null;
+
+    #[Groups(['query'])]
+    #[ApiProperty(description: '赠品 SKU')]
+    public ?string $sku = null;
+
+    #[Groups(['query'])]
+    #[ApiProperty(description: '所属赠品规则(cart_rules.id),用于与 giftCandidates 归组')]
+    public ?int $rule_id = null;
+
+    #[Groups(['query'])]
+    public ?string $rule_name = null;
+
+    #[Groups(['query'])]
+    public ?int $product_id = null;
+
+    #[Groups(['query'])]
+    public ?string $name = null;
+
+    #[Groups(['query'])]
+    #[ApiProperty(description: '商品详情页地址')]
+    public ?string $url = null;
+
+    #[Groups(['query'])]
+    public ?string $image = null;
+
+    #[Groups(['query'])]
+    #[ApiProperty(description: '格式化后的原价')]
+    public ?string $price = null;
+
+    #[Groups(['query'])]
+    #[ApiProperty(description: '本条规则允许赠送的数量')]
+    public ?float $allowed_qty = null;
+
+    #[Groups(['query'])]
+    #[ApiProperty(description: '已经放进购物车的数量')]
+    public ?float $reserved_qty = null;
+
+    #[Groups(['query'])]
+    #[ApiProperty(description: '还能再选的数量')]
+    public ?float $remaining_qty = null;
+
+    #[Groups(['query'])]
+    #[ApiProperty(description: '已加进购物车的赠品行 ID,未加为 null')]
+    public ?int $cart_item_id = null;
+
+    #[Groups(['query'])]
+    #[ApiProperty(description: '用户是否手动删除过该赠品(true 时可用 restoreGifts 恢复自动加购)')]
+    public ?bool $is_deleted = null;
+}

+ 36 - 0
packages/Longyi/FreeGift/src/ApiResource/RemoveGift.php

@@ -0,0 +1,36 @@
+<?php
+
+namespace Longyi\FreeGift\ApiResource;
+
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\GraphQl\Mutation;
+use Longyi\FreeGift\State\GiftCartProcessor;
+
+/**
+ * GraphQL:移除赠品。
+ *
+ * mutation createRemoveGift(input: {cartItemId: 167}) { removeGift { success message } }
+ *
+ * 走原生 Cart::removeItem(),删除监听器会打 is_item_deleted 标记
+ * (用户删过就不再自动加回,可用 createRestoreGifts 或 createAddGift 解锁)。
+ */
+#[ApiResource(
+    routePrefix: '/api/shop',
+    shortName: 'RemoveGift',
+    operations: [],
+    graphQlOperations: [
+        new Mutation(
+            name: 'create',
+            input: RemoveGiftInput::class,
+            output: GiftActionResult::class,
+            processor: GiftCartProcessor::class,
+            denormalizationContext: [
+                'allow_extra_attributes' => true,
+                'groups'                 => ['mutation'],
+            ],
+            normalizationContext: ['groups' => ['mutation']],
+            description: '从购物车里移除赠品',
+        ),
+    ]
+)]
+class RemoveGift {}

+ 18 - 0
packages/Longyi/FreeGift/src/ApiResource/RemoveGiftInput.php

@@ -0,0 +1,18 @@
+<?php
+
+namespace Longyi\FreeGift\ApiResource;
+
+use ApiPlatform\Metadata\ApiProperty;
+use Symfony\Component\Serializer\Annotation\Groups;
+
+/**
+ * createRemoveGift 的入参。
+ *
+ * ⚠ 属性 snake_case,理由见 AddGiftInput;GraphQL 侧字段名仍是 cartItemId。
+ */
+class RemoveGiftInput
+{
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: '赠品行在购物车里的 cart_items.id', required: true)]
+    public ?int $cart_item_id = null;
+}

+ 8 - 0
packages/Longyi/FreeGift/src/ApiResource/RestoreGiftInput.php

@@ -0,0 +1,8 @@
+<?php
+
+namespace Longyi\FreeGift\ApiResource;
+
+/**
+ * createRestoreGifts 无需入参(占位,保证 GraphQL 生成 input 类型)。
+ */
+class RestoreGiftInput {}

+ 36 - 0
packages/Longyi/FreeGift/src/ApiResource/RestoreGifts.php

@@ -0,0 +1,36 @@
+<?php
+
+namespace Longyi\FreeGift\ApiResource;
+
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\GraphQl\Mutation;
+use Longyi\FreeGift\State\GiftCartProcessor;
+
+/**
+ * GraphQL:恢复当前购物车的赠品自动加购资格。
+ *
+ * mutation createRestoreGifts(input: {}) { restoreGifts { success message } }
+ *
+ * 清掉 free_gift_items.is_item_deleted 标记后重算 totals,
+ * 相当于把购物车「当作新车」看待 —— 用于用户误删赠品后一键恢复。
+ */
+#[ApiResource(
+    routePrefix: '/api/shop',
+    shortName: 'RestoreGifts',
+    operations: [],
+    graphQlOperations: [
+        new Mutation(
+            name: 'create',
+            input: RestoreGiftInput::class,
+            output: GiftActionResult::class,
+            processor: GiftCartProcessor::class,
+            denormalizationContext: [
+                'allow_extra_attributes' => true,
+                'groups'                 => ['mutation'],
+            ],
+            normalizationContext: ['groups' => ['mutation']],
+            description: '清除「用户已删除」标记,恢复赠品的自动加购',
+        ),
+    ]
+)]
+class RestoreGifts {}

+ 84 - 0
packages/Longyi/FreeGift/src/Config/Gift.php

@@ -0,0 +1,84 @@
+<?php
+
+namespace Longyi\FreeGift\Config;
+
+/**
+ * 赠品活动(Free Gift)的全局常量。
+ *
+ * 5 种规则类型直接复用 Bagisto 的 `cart_rules.action_type` 字段,
+ * 不额外建 action 枚举表,和原生的 by_percent / by_fixed / cart_fixed / buy_x_get_y 并列。
+ */
+class Gift
+{
+    /**
+     * 买它送它:购买指定商品本身,按 discount_step 送 discount_amount 个同款赠品。
+     */
+    public const ACTION_SAME_PRODUCT = 'free_gift_same_product';
+
+    /**
+     * 买指定商品送:购物车中存在满足条件的商品时赠送。
+     */
+    public const ACTION_WITH_PRODUCTS = 'free_gift_with_products';
+
+    /**
+     * 每 N 件送:每买满 discount_step 件,送 discount_amount 个赠品。
+     */
+    public const ACTION_EACH_N = 'free_gift_each_n';
+
+    /**
+     * 整车送:只要满足条件(通常条件为空)就送。
+     */
+    public const ACTION_WHOLE_CART = 'free_gift_whole_cart';
+
+    /**
+     * 满额送:满足条件商品的金额每达到 discount_step,送 discount_amount 个赠品。
+     */
+    public const ACTION_SPENT = 'free_gift_spent';
+
+    /**
+     * 全部赠品型 action。
+     */
+    public const ACTION_TYPES = [
+        self::ACTION_SAME_PRODUCT,
+        self::ACTION_WITH_PRODUCTS,
+        self::ACTION_EACH_N,
+        self::ACTION_WHOLE_CART,
+        self::ACTION_SPENT,
+    ];
+
+    /**
+     * 赠品组类型:列表里每个 SKU 各送 N 个。
+     */
+    public const TYPE_ALL = 0;
+
+    /**
+     * 赠品组类型:列表合计只送 N 个,由用户自选。
+     */
+    public const TYPE_ONE = 1;
+
+    /**
+     * `cart_items.additional` 中标记「该行是赠品」的键,值为 cart_rules.id。
+     */
+    public const CART_ITEM_FLAG = 'free_gift_rule_id';
+
+    /**
+     * 判定 action_type 是否为赠品型。
+     */
+    public static function isGiftAction($actionType): bool
+    {
+        return in_array($actionType, self::ACTION_TYPES, true);
+    }
+
+    /**
+     * 判定 action_type 是否是「必须绑定商品条件」的类型。
+     */
+    public static function requiresProductCondition($actionType): bool
+    {
+        return in_array($actionType, [
+            self::ACTION_SAME_PRODUCT,
+            self::ACTION_WITH_PRODUCTS,
+            self::ACTION_EACH_N,
+            self::ACTION_SPENT,
+        ], true);
+    }
+}

+ 44 - 0
packages/Longyi/FreeGift/src/Config/system.php

@@ -0,0 +1,44 @@
+<?php
+
+/*
+|--------------------------------------------------------------------------
+| 赠品活动(Free Gift)—— 系统配置 → 销售
+|--------------------------------------------------------------------------
+|
+| ⚠ 注意 key 的层级:Bagisto 的 SystemConfig::prepareConfigurationItems() 是
+| 用 `Arr::undot(Arr::dot($item['key'] => $item))` 按 key 拼树的,然后
+| processSubConfigItems() 只保留「自己带 name 的节点」。
+| 所以三段 key(sales.free_gift.general)必须有对应的二段父级条目
+| (sales.free_gift),否则整个分组会被静默丢掉 —— 不报错,也不出现在页面上。
+|
+*/
+
+return [
+    [
+        'key'  => 'sales.free_gift',
+        'name' => 'free_gift::admin.system.title',
+        'info' => 'free_gift::admin.system.info',
+        'icon' => 'settings/gift.svg',
+        'sort' => 8,
+    ], [
+        'key'    => 'sales.free_gift.general',
+        'name'   => 'free_gift::admin.system.general',
+        'info'   => 'free_gift::admin.system.general-info',
+        'sort'   => 1,
+        'fields' => [
+            [
+                'name'          => 'disable_auto_add',
+                'title'         => 'free_gift::admin.system.disable-auto-add',
+                'type'          => 'boolean',
+                'channel_based' => false,
+                'locale_based'  => false,
+            ], [
+                'name'          => 'show_banner',
+                'title'         => 'free_gift::admin.system.show-banner',
+                'type'          => 'boolean',
+                'channel_based' => false,
+                'locale_based'  => false,
+            ],
+        ],
+    ],
+];

+ 7 - 0
packages/Longyi/FreeGift/src/Contracts/FreeGiftItem.php

@@ -0,0 +1,7 @@
+<?php
+
+namespace Longyi\FreeGift\Contracts;
+
+interface FreeGiftItem
+{
+}

+ 7 - 0
packages/Longyi/FreeGift/src/Contracts/FreeGiftRule.php

@@ -0,0 +1,7 @@
+<?php
+
+namespace Longyi\FreeGift\Contracts;
+
+interface FreeGiftRule
+{
+}

+ 49 - 0
packages/Longyi/FreeGift/src/Database/Migrations/2026_09_17_000001_create_free_gift_rules_table.php

@@ -0,0 +1,49 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::create('free_gift_rules', function (Blueprint $table) {
+            $table->increments('id');
+
+            // 关联的营销规则(cart_rules)
+            $table->unsignedInteger('cart_rule_id')->unique()->comment('关联的 cart_rules.id');
+
+            // 赠品配置
+            $table->text('sku')->nullable()->comment('赠品 SKU 列表,逗号分隔');
+            $table->unsignedTinyInteger('type')->default(0)->comment('赠品组类型:0-每个SKU各送N 1-一组合计送N');
+            $table->string('items_discount')->nullable()->comment('折扣写法:100% / 50% / -10 / 一口价 99');
+
+            // 附加限制
+            $table->decimal('minimal_items_price', 12, 4)->nullable()->comment('赠品折后最低价');
+            $table->boolean('apply_tax')->default(1)->comment('赠品是否参与税费计算');
+            $table->boolean('apply_shipping')->default(0)->comment('是否计算赠品运费');
+            $table->boolean('auto_add')->default(1)->comment('是否自动加入购物车');
+
+            // 前台 Banner
+            $table->boolean('show_banner')->default(0)->comment('购物车是否展示 Banner');
+            $table->string('banner_image')->nullable()->comment('Banner 图片');
+            $table->string('banner_alt')->nullable()->comment('Banner 文案');
+
+            $table->timestamps();
+
+            $table->index('cart_rule_id');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('free_gift_rules');
+    }
+};

+ 53 - 0
packages/Longyi/FreeGift/src/Database/Migrations/2026_09_17_000002_create_free_gift_items_table.php

@@ -0,0 +1,53 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::create('free_gift_items', function (Blueprint $table) {
+            $table->increments('id');
+
+            // 归属
+            $table->unsignedInteger('cart_id')->comment('cart.id');
+            $table->unsignedInteger('cart_item_id')->nullable()->comment('对应的 cart_items.id');
+            $table->unsignedInteger('product_id')->nullable()->comment('赠品 product.id');
+
+            // 赠品与规则
+            $table->string('sku')->comment('赠品 SKU');
+            $table->unsignedInteger('rule_id')->comment('cart_rules.id');
+            $table->unsignedTinyInteger('rule_type')->default(0)->comment('赠品组类型:0-每个SKU各送N 1-一组合计送N');
+
+            // 数量
+            $table->decimal('allowed_qty', 12, 4)->default(0)->comment('规则允许赠送的总数');
+            $table->decimal('reserved_qty', 12, 4)->default(0)->comment('已加入购物车的数量');
+
+            // 折扣
+            $table->string('discount_amount')->nullable()->comment('该赠品适用的折扣写法');
+
+            // 状态
+            $table->boolean('auto_add')->default(1)->comment('是否自动加购');
+            $table->boolean('is_item_deleted')->default(0)->comment('用户是否手动删除过');
+
+            $table->timestamps();
+
+            $table->unique(['cart_id', 'sku', 'rule_id'], 'FREE_GIFT_ITEM_CART_SKU_RULE_UNIQUE');
+            $table->index(['cart_id', 'rule_id']);
+            $table->index('cart_item_id');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('free_gift_items');
+    }
+};

+ 127 - 0
packages/Longyi/FreeGift/src/Http/Controllers/Admin/ProductPickerController.php

@@ -0,0 +1,127 @@
+<?php
+
+namespace Longyi\FreeGift\Http\Controllers\Admin;
+
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Longyi\FreeGift\Services\GiftProductResolver;
+use Longyi\FreeGift\Services\GiftProductValidator;
+use Webkul\Admin\Http\Controllers\Controller;
+use Webkul\Product\Models\ProductFlat;
+
+/**
+ * 赠品 SKU 选择器(购物车规则 → 赠品设置里那个「选择商品」弹窗)的数据源。
+ *
+ * 只读接口,空关键词返回全部商品;数据取当前渠道 + 当前语言的 product_flat,
+ * 一个商品一行(product_flat 是「商品 + 渠道 + 语言」的展开表)。
+ *
+ * 每行都会带 `giftable` + `giftable_reason`:赠品能不能真的送出去
+ * 取决于「启用 + 类型放行 + flexible_variant 有可售变体」,
+ * 提前提示可以避免运营配出一个永远送不出去的规则(见 GiftProductValidator::explain)。
+ */
+class ProductPickerController extends Controller
+{
+    /**
+     * 每页条数。
+     */
+    protected const PER_PAGE = 20;
+
+    public function __construct(
+        protected GiftProductResolver $productResolver
+    ) {}
+
+    /**
+     * 商品列表。
+     */
+    public function index(Request $request): JsonResponse
+    {
+        $keyword = $this->escapeLike(trim((string) $request->input('query')));
+
+        $products = ProductFlat::query()
+            ->select(['product_id', 'sku', 'name', 'type', 'status', 'price'])
+            ->where('channel', core()->getCurrentChannelCode())
+            ->where('locale', app()->getLocale())
+            /*
+             * 排除可配置商品的子商品(parent_id 有值):赠品行是绕过
+             * prepareForCart() 直接写 cart_items 的,子商品单独进购物车
+             * 在渲染 / 税费 / 下单链路里没有配套的可配置选项,会出问题。
+             */
+            ->whereNull('parent_id')
+            ->when($keyword !== '', function ($query) use ($keyword) {
+                $query->where(function ($subQuery) use ($keyword) {
+                    $subQuery->where('name', 'like', '%'.$keyword.'%')
+                        ->orWhere('sku', 'like', '%'.$keyword.'%');
+                });
+            })
+            ->orderBy('name')
+            ->paginate(self::PER_PAGE);
+
+        /*
+         * flexible_variant 还要求「至少一个启用且有货的变体」,否则赠品行写不进
+         * 购物车。这里按当前页批量查一次,避免逐行 N+1。
+         */
+        $flexibleIds = $products->getCollection()
+            ->where('type', 'flexible_variant')
+            ->pluck('product_id')
+            ->map(fn ($id) => (int) $id)
+            ->all();
+
+        $withSaleableVariant = $this->productResolver->productIdsWithSaleableVariant($flexibleIds);
+
+        $products->through(function ($product) use ($withSaleableVariant) {
+            $reason = $this->giftableReason($product, $withSaleableVariant);
+
+            return [
+                'id'              => (int) $product->product_id,
+                'sku'             => (string) $product->sku,
+                'name'            => (string) $product->name,
+                'type'            => (string) $product->type,
+                'price'           => core()->formatBasePrice((float) $product->price),
+                'giftable'        => $reason === null,
+                'giftable_reason' => $reason === null
+                    ? null
+                    : trans('free_gift::admin.picker-'.$reason),
+            ];
+        });
+
+        return response()->json([
+            'data' => $products->items(),
+            'meta' => [
+                'current_page' => $products->currentPage(),
+                'last_page'    => $products->lastPage(),
+                'total'        => $products->total(),
+            ],
+        ]);
+    }
+
+    /**
+     * 不能当赠品的原因(null = 可以),返回的是翻译 key 的后缀。
+     *
+     * @param  array<int, true>  $withSaleableVariant
+     */
+    protected function giftableReason($product, array $withSaleableVariant): ?string
+    {
+        if (! $product->status) {
+            return 'reason-inactive';
+        }
+
+        if (! in_array($product->type, GiftProductValidator::ALLOWED_TYPES, true)) {
+            return 'reason-type';
+        }
+
+        if ($product->type === 'flexible_variant'
+            && ! isset($withSaleableVariant[(int) $product->product_id])) {
+            return 'reason-no-variant';
+        }
+
+        return null;
+    }
+
+    /**
+     * 转义 LIKE 里的通配符(SKU 里下划线/百分号很常见,不转义会搜错)。
+     */
+    protected function escapeLike(string $keyword): string
+    {
+        return str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $keyword);
+    }
+}

+ 247 - 0
packages/Longyi/FreeGift/src/Http/Controllers/Shop/GiftController.php

@@ -0,0 +1,247 @@
+<?php
+
+namespace Longyi\FreeGift\Http\Controllers\Shop;
+
+use Illuminate\Http\JsonResponse;
+use Illuminate\Routing\Controller;
+use Longyi\FreeGift\Config\Gift;
+use Longyi\FreeGift\Repositories\FreeGiftItemRepository;
+use Longyi\FreeGift\Repositories\FreeGiftRuleRepository;
+use Longyi\FreeGift\Services\GiftProcessor;
+use Longyi\FreeGift\State\GiftCartContext;
+use Webkul\Checkout\Facades\Cart;
+use Webkul\Product\ProductImage;
+use Webkul\Product\Repositories\ProductRepository;
+
+/**
+ * 前台赠品接口。
+ *
+ * 对应 Amasty 的 Controller\Cart\Add / Controller\Popup。
+ */
+class GiftController extends Controller
+{
+    public function __construct(
+        protected FreeGiftItemRepository $giftItemRepository,
+        protected FreeGiftRuleRepository $giftRuleRepository,
+        protected ProductRepository $productRepository,
+        protected ProductImage $productImage,
+        protected GiftProcessor $processor,
+        protected GiftCartContext $cartContext
+    ) {}
+
+    /**
+     * 定位当前购物车。
+     *
+     * 本控制器对 Blade 前台(session)和 headless 前台(Authorization: Bearer,
+     * 游客车的 token 就是 cart id)两种入口都要能用:
+     *  - token 场景下 Cart::getCart() 拿不到车(Cart::initCart() 只看登录客户 /
+     *    session('cart')),只调它会让 /api/free-gift/* 静默返回空;
+     *  - GiftCartContext 与 GraphQL 的 GiftCartProvider 用同一套口径,且在命中
+     *    时会 Cart::setCart(),后续 Cart::removeItem() 等才能作用于这台车。
+     */
+    protected function resolveCart(): ?object
+    {
+        return $this->cartContext->resolveCart() ?: Cart::getCart();
+    }
+
+    /**
+     * 当前购物车的赠品候选(弹窗 / Banner 数据源)。
+     */
+    public function candidates(): JsonResponse
+    {
+        $payload = [
+            'groups'  => [],
+            'banners' => [],
+        ];
+
+        if (! $this->resolveCart()) {
+            return new JsonResponse(['data' => $payload]);
+        }
+
+        Cart::collectTotals();
+
+        $cart = Cart::getCart();
+
+        if (! $cart) {
+            return new JsonResponse(['data' => $payload]);
+        }
+
+        $groups = [];
+
+        foreach ($this->giftItemRepository->getByCart($cart->id) as $row) {
+            if ($row->allowed_qty < 1) {
+                continue;
+            }
+
+            $giftRule = $this->giftRuleRepository->findByCartRuleId($row->rule_id);
+
+            if (! $giftRule) {
+                continue;
+            }
+
+            $product = $row->product_id ? $this->productRepository->find($row->product_id) : null;
+
+            if (! $product) {
+                continue;
+            }
+
+            $ruleId = (int) $row->rule_id;
+
+            if (! isset($groups[$ruleId])) {
+                $groups[$ruleId] = [
+                    'rule_id'       => $ruleId,
+                    'rule_name'     => $giftRule->cart_rule?->name,
+                    'type'          => (int) $row->rule_type,
+                    'auto_add'      => (bool) $row->auto_add,
+                    'allowed_qty'   => 0,
+                    'reserved_qty'  => 0,
+                    'remaining_qty' => 0,
+                    'gifts'         => [],
+                ];
+            }
+
+            $image = $this->productImage->getProductBaseImage($product);
+
+            $groups[$ruleId]['gifts'][] = [
+                'sku'           => $row->sku,
+                'product_id'    => (int) $product->id,
+                'name'          => $product->name,
+                'url'           => route('shop.product_or_category.index', $product->url_key),
+                'image'         => $image['medium_image_url'] ?? null,
+                'price'         => core()->formatPrice((float) $product->price),
+                'allowed_qty'   => (float) $row->allowed_qty,
+                'reserved_qty'  => (float) $row->reserved_qty,
+                'remaining_qty' => max(0, (float) $row->allowed_qty - (float) $row->reserved_qty),
+                'cart_item_id'  => $row->cart_item_id ? (int) $row->cart_item_id : null,
+            ];
+        }
+
+        foreach ($groups as $ruleId => $group) {
+            $allowed = array_column($group['gifts'], 'allowed_qty');
+            $reserved = array_column($group['gifts'], 'reserved_qty');
+
+            // 「一组合计送 N 个」:整组共用 N 个名额,N 取单条的允许量而不是求和
+            $group['allowed_qty'] = $group['type'] === Gift::TYPE_ONE
+                ? max($allowed)
+                : array_sum($allowed);
+
+            $group['reserved_qty'] = array_sum($reserved);
+
+            $group['remaining_qty'] = max(0, $group['allowed_qty'] - $group['reserved_qty']);
+
+            $groups[$ruleId] = $group;
+        }
+
+        $groups = array_values($groups);
+
+        return new JsonResponse([
+            'data' => [
+                'groups'  => $groups,
+                'banners' => $this->buildBanners($groups),
+            ],
+        ]);
+    }
+
+    /**
+     * 手动把赠品加进购物车(手动模式 / 弹窗选择)。
+     */
+    public function add(): JsonResponse
+    {
+        $data = request()->validate([
+            'sku'     => ['required', 'string'],
+            'rule_id' => ['required', 'integer'],
+            'qty'     => ['nullable', 'numeric', 'min:1'],
+        ]);
+
+        $cart = $this->resolveCart();
+
+        if (! $cart) {
+            return new JsonResponse([
+                'message' => trans('free_gift::app.cart.cart-not-found'),
+            ], 422);
+        }
+
+        $result = $this->processor->addGiftToCart(
+            $cart,
+            $data['sku'],
+            (int) $data['rule_id'],
+            (float) ($data['qty'] ?? 1)
+        );
+
+        if (! $result['success']) {
+            return new JsonResponse(['message' => $result['message']], 422);
+        }
+
+        Cart::collectTotals();
+
+        return new JsonResponse([
+            'message' => $result['message'],
+        ]);
+    }
+
+    /**
+     * 移除赠品。
+     *
+     * 走原生 removeItem(),删除监听器负责打 is_item_deleted 标记,
+     * 避免用户删掉之后又被自动加回来。
+     */
+    public function remove(): JsonResponse
+    {
+        $data = request()->validate([
+            'cart_item_id' => ['required', 'integer'],
+        ]);
+
+        /*
+         * 先定位当前车(同时 Cart::setCart()):Cart::removeItem() 用的是
+         * $this->cart,token 场景下它本来为空,会直接 return false —— 表现为
+         * 「请求成功但什么都没删」。
+         */
+        if (! $this->resolveCart()) {
+            return new JsonResponse([
+                'message' => trans('free_gift::app.cart.cart-not-found'),
+            ], 422);
+        }
+
+        Cart::removeItem((int) $data['cart_item_id']);
+
+        Cart::collectTotals();
+
+        return new JsonResponse([
+            'message' => trans('free_gift::app.cart.gift-removed'),
+        ]);
+    }
+
+    /**
+     * 需要展示 Banner 的规则。
+     *
+     * @param  array  $groups
+     * @return array
+     */
+    protected function buildBanners(array $groups): array
+    {
+        $banners = [];
+
+        // 全局总开关:System Configuration > Sales > Free Gift
+        if (! core()->getConfigData('sales.free_gift.general.show_banner')) {
+            return $banners;
+        }
+
+        foreach ($groups as $group) {
+            $giftRule = $this->giftRuleRepository->findByCartRuleId($group['rule_id']);
+
+            if (! $giftRule || ! $giftRule->show_banner) {
+                continue;
+            }
+
+            $banners[] = [
+                'rule_id'      => $group['rule_id'],
+                'text'         => $giftRule->banner_alt ?: trans('free_gift::app.cart.banner-default'),
+                'image'        => $giftRule->bannerImageUrl(),
+                'allowed_qty'  => $group['allowed_qty'],
+                'remaining_qty' => $group['remaining_qty'],
+            ];
+        }
+
+        return $banners;
+    }
+}

+ 34 - 0
packages/Longyi/FreeGift/src/Http/Requests/GiftCartRuleRequest.php

@@ -0,0 +1,34 @@
+<?php
+
+namespace Longyi\FreeGift\Http\Requests;
+
+use Webkul\Admin\Http\Requests\CartRuleRequest;
+
+/**
+ * 在原生营销规则表单的校验规则上,追加赠品配置字段。
+ */
+class GiftCartRuleRequest extends CartRuleRequest
+{
+    /**
+     * Get the validation rules that apply to the request.
+     *
+     * @return array
+     */
+    public function rules()
+    {
+        return array_merge(parent::rules(), [
+            'free_gift'                     => ['nullable', 'array'],
+            'free_gift.sku'                 => ['nullable', 'string'],
+            'free_gift.type'                => ['nullable', 'in:0,1'],
+            'free_gift.items_discount'      => ['nullable', 'string'],
+            'free_gift.minimal_items_price' => ['nullable', 'numeric', 'min:0'],
+            'free_gift.apply_tax'           => ['nullable', 'in:0,1'],
+            'free_gift.apply_shipping'      => ['nullable', 'in:0,1'],
+            'free_gift.auto_add'            => ['nullable', 'in:0,1'],
+            'free_gift.show_banner'         => ['nullable', 'in:0,1'],
+            'free_gift.banner_image'        => ['nullable', 'string'],
+            'free_gift.banner_alt'          => ['nullable', 'string'],
+            'free_gift_banner_image_file'   => ['nullable', 'file', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
+        ]);
+    }
+}

+ 30 - 0
packages/Longyi/FreeGift/src/Listeners/AdminCartRuleForm.php

@@ -0,0 +1,30 @@
+<?php
+
+namespace Longyi\FreeGift\Listeners;
+
+use Webkul\Theme\ViewRenderEventManager;
+
+/**
+ * 后台营销规则(Marketing > Promotions > Cart Rules)表单扩展。
+ *
+ * 用 view_render_event 往原生表单里注入,而不是整份覆盖 create/edit 视图:
+ * 原生模板各 60K+,一旦覆盖,Webkul 后续升级表单结构就要人工同步,代价太高。
+ */
+class AdminCartRuleForm
+{
+    /**
+     * 在右侧 Settings 抽屉之后追加「赠品设置」抽屉。
+     *
+     * create / edit 共用同一个模板:edit 场景下事件参数里带着 $cartRule,
+     * 模板自己会去 free_gift_rules 里回填已保存的配置。
+     *
+     * @param  mixed  $viewRenderEventManager
+     * @return void
+     */
+    public function giftSettings($viewRenderEventManager)
+    {
+        if ($viewRenderEventManager instanceof ViewRenderEventManager) {
+            $viewRenderEventManager->addTemplate('free_gift::admin.cart-rules.gift-settings');
+        }
+    }
+}

+ 31 - 0
packages/Longyi/FreeGift/src/Listeners/Cart.php

@@ -0,0 +1,31 @@
+<?php
+
+namespace Longyi\FreeGift\Listeners;
+
+use Longyi\FreeGift\Services\GiftProcessor;
+
+/**
+ * 赠品主入口。
+ *
+ * 挂在 checkout.cart.collect.totals.before 上,与原生促销
+ * Webkul\CartRule\Listeners\Cart@applyCartRules 并列。
+ *
+ * 这个时机很关键(Webkul\Checkout\Cart::collectTotals 第 849 行):
+ *  - 在 calculateItemsTax() 之前,也在 refreshCart() 之前
+ *  - refreshCart() 会把我们刚写进 cart_items 的赠品行重新载入,
+ *    因此后续的 totals 汇总会自动把赠品算进去
+ */
+class Cart
+{
+    public function __construct(
+        protected GiftProcessor $processor
+    ) {}
+
+    /**
+     * @param  \Webkul\Checkout\Contracts\Cart  $cart
+     */
+    public function applyFreeGifts($cart): void
+    {
+        $this->processor->process($cart);
+    }
+}

+ 39 - 0
packages/Longyi/FreeGift/src/Listeners/CartItemDelete.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace Longyi\FreeGift\Listeners;
+
+use Longyi\FreeGift\Config\Gift;
+use Longyi\FreeGift\Repositories\FreeGiftItemRepository;
+use Webkul\Checkout\Repositories\CartItemRepository;
+
+/**
+ * 用户手动删除赠品时打标记。
+ *
+ * 对应 Amasty 的 AmpPromo 删除拦截:如果不打标记,
+ * 下一次 collectTotals() 又会把赠品自动加回来,用户会以为删除无效。
+ */
+class CartItemDelete
+{
+    public function __construct(
+        protected CartItemRepository $cartItemRepository,
+        protected FreeGiftItemRepository $giftItemRepository
+    ) {}
+
+    /**
+     * @param  int  $itemId
+     */
+    public function handle($itemId): void
+    {
+        $item = $this->cartItemRepository->find($itemId);
+
+        if (! $item) {
+            return;
+        }
+
+        if (! ($item->additional[Gift::CART_ITEM_FLAG] ?? null)) {
+            return;
+        }
+
+        $this->giftItemRepository->markDeletedByCartItemId((int) $item->id);
+    }
+}

+ 33 - 0
packages/Longyi/FreeGift/src/Listeners/CartItemUpdate.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace Longyi\FreeGift\Listeners;
+
+use Longyi\FreeGift\Config\Gift;
+
+/**
+ * 禁止用户修改赠品数量。
+ *
+ * 挂在 checkout.cart.update.before 上(Webkul\Checkout\Cart::updateItems 第 374 行),
+ * 此时 $item->quantity 已经被赋成新值,而 getOriginal('quantity') 还是旧值。
+ *
+ * 注意:购物车页的更新表单会一次性提交整张表(包含赠品行),
+ * 所以只有在「数量真的被改动」时才拦截,否则用户连主商品的数量都改不了。
+ */
+class CartItemUpdate
+{
+    /**
+     * @param  \Webkul\Checkout\Contracts\CartItem  $item
+     */
+    public function handle($item): void
+    {
+        if (! ($item->additional[Gift::CART_ITEM_FLAG] ?? null)) {
+            return;
+        }
+
+        if ((float) $item->getOriginal('quantity') === (float) $item->quantity) {
+            return;
+        }
+
+        throw new \Exception(trans('free_gift::app.cart.gift-quantity-locked'));
+    }
+}

+ 79 - 0
packages/Longyi/FreeGift/src/Listeners/OrderPlaced.php

@@ -0,0 +1,79 @@
+<?php
+
+namespace Longyi\FreeGift\Listeners;
+
+use Longyi\FreeGift\Repositories\FreeGiftItemRepository;
+use Webkul\CartRule\Repositories\CartRuleCustomerRepository;
+use Webkul\CartRule\Repositories\CartRuleRepository;
+
+/**
+ * 下单成功后结算赠品用量。
+ *
+ * 赠品型规则不会进入 cart_rules 原生的 applied_cart_rule_ids,
+ * 所以 CartRule\Listeners\Order@manageCartRule 统计不到它们,
+ * times_used / cart_rule_customers 需要我们自己维护。
+ */
+class OrderPlaced
+{
+    public function __construct(
+        protected FreeGiftItemRepository $giftItemRepository,
+        protected CartRuleRepository $cartRuleRepository,
+        protected CartRuleCustomerRepository $cartRuleCustomerRepository
+    ) {}
+
+    /**
+     * @param  \Webkul\Sales\Contracts\Order  $order
+     */
+    public function handle($order): void
+    {
+        if (! $order || ! $order->cart_id) {
+            return;
+        }
+
+        $cartId = (int) $order->cart_id;
+
+        $ruleIds = $this->giftItemRepository->getModel()
+            ->where('cart_id', $cartId)
+            ->pluck('rule_id')
+            ->unique()
+            ->all();
+
+        foreach ($ruleIds as $ruleId) {
+            $rule = $this->cartRuleRepository->find($ruleId);
+
+            if (! $rule) {
+                continue;
+            }
+
+            // 注意:不能走 cartRuleRepository->update(),那是后台表单处理路径,
+            // 会强制读取 starts_from / ends_till / channels 等字段,只传 times_used
+            // 会抛 Undefined array key,并在 createOrderIfNotThenRetry 里触发无限重试
+            // (下单接口一直转圈的根因)。这里必须用模型层直接保存。
+            $rule->times_used = (int) $rule->times_used + 1;
+            $rule->save();
+
+            if (! $order->customer_id) {
+                continue;
+            }
+
+            $ruleCustomer = $this->cartRuleCustomerRepository->findOneWhere([
+                'cart_rule_id' => $rule->id,
+                'customer_id'  => $order->customer_id,
+            ]);
+
+            if ($ruleCustomer) {
+                $ruleCustomer->times_used = (int) $ruleCustomer->times_used + 1;
+                $ruleCustomer->save();
+            } else {
+                $this->cartRuleCustomerRepository->create([
+                    'cart_rule_id' => $rule->id,
+                    'customer_id'  => $order->customer_id,
+                    'times_used'   => 1,
+                ]);
+            }
+        }
+
+        // 订单已经生成,赠品状态行(绑在 cart 上)不再需要
+        $this->giftItemRepository->deleteByCart($cartId);
+    }
+}

+ 118 - 0
packages/Longyi/FreeGift/src/Models/FreeGiftItem.php

@@ -0,0 +1,118 @@
+<?php
+
+namespace Longyi\FreeGift\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Longyi\FreeGift\Config\Gift;
+use Longyi\FreeGift\Contracts\FreeGiftItem as FreeGiftItemContract;
+use Webkul\CartRule\Models\CartRule;
+use Webkul\Checkout\Models\Cart;
+use Webkul\Checkout\Models\CartItem;
+
+/**
+ * 赠品在购物车中的状态(每个 购物车 + SKU + 规则 一行)。
+ *
+ * 对应 Amasty 的 amasty_ampromo_items_storage。
+ */
+class FreeGiftItem extends Model implements FreeGiftItemContract
+{
+    /**
+     * The table associated with the model.
+     *
+     * @var string
+     */
+    protected $table = 'free_gift_items';
+
+    /**
+     * The attributes that are mass assignable.
+     *
+     * @var array
+     */
+    protected $fillable = [
+        'cart_id',
+        'cart_item_id',
+        'product_id',
+        'sku',
+        'rule_id',
+        'rule_type',
+        'allowed_qty',
+        'reserved_qty',
+        'discount_amount',
+        'auto_add',
+        'is_item_deleted',
+    ];
+
+    /**
+     * The attributes that should be cast.
+     *
+     * @var array
+     */
+    protected $casts = [
+        'cart_id'         => 'integer',
+        'cart_item_id'    => 'integer',
+        'product_id'      => 'integer',
+        'rule_id'         => 'integer',
+        'rule_type'       => 'integer',
+        'allowed_qty'     => 'float',
+        'reserved_qty'    => 'float',
+        'auto_add'        => 'boolean',
+        'is_item_deleted' => 'boolean',
+    ];
+
+    /**
+     * 所属购物车。
+     */
+    public function cart(): BelongsTo
+    {
+        return $this->belongsTo(Cart::class, 'cart_id');
+    }
+
+    /**
+     * 对应的购物车行。
+     */
+    public function cart_item(): BelongsTo
+    {
+        return $this->belongsTo(CartItem::class, 'cart_item_id');
+    }
+
+    /**
+     * 赠送该赠品的规则。
+     */
+    public function cart_rule(): BelongsTo
+    {
+        return $this->belongsTo(CartRule::class, 'rule_id');
+    }
+
+    /**
+     * 规则是否允许「一组合计送 N 个」。
+     */
+    public function isOneOf(): bool
+    {
+        return (int) $this->rule_type === Gift::TYPE_ONE;
+    }
+
+    /**
+     * 规则是否允许「每个 SKU 各送 N 个」。
+     */
+    public function isAllOf(): bool
+    {
+        return (int) $this->rule_type === Gift::TYPE_ALL;
+    }
+
+    /**
+     * 用户是否手动删除过该赠品(删除后不再自动加购,直到购物车清空)。
+     */
+    public function isDeleted(): bool
+    {
+        return (bool) $this->is_item_deleted;
+    }
+
+    /**
+     * 还差几个需要补进购物车。
+     */
+    public function getQtyToProcessAttribute(): float
+    {
+        return max(0, (float) $this->allowed_qty - (float) $this->reserved_qty);
+    }
+}

+ 119 - 0
packages/Longyi/FreeGift/src/Models/FreeGiftRule.php

@@ -0,0 +1,119 @@
+<?php
+
+namespace Longyi\FreeGift\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Support\Facades\Storage;
+use Longyi\FreeGift\Config\Gift;
+use Longyi\FreeGift\Contracts\FreeGiftRule as FreeGiftRuleContract;
+use Longyi\FreeGift\Services\GiftProductResolver;
+use Webkul\CartRule\Models\CartRule;
+
+/**
+ * 赠品规则配置,1:1 挂在 `cart_rules` 上。
+ */
+class FreeGiftRule extends Model implements FreeGiftRuleContract
+{
+    /**
+     * The table associated with the model.
+     *
+     * @var string
+     */
+    protected $table = 'free_gift_rules';
+
+    /**
+     * The attributes that are mass assignable.
+     *
+     * @var array
+     */
+    protected $fillable = [
+        'cart_rule_id',
+        'sku',
+        'type',
+        'items_discount',
+        'minimal_items_price',
+        'apply_tax',
+        'apply_shipping',
+        'auto_add',
+        'show_banner',
+        'banner_image',
+        'banner_alt',
+    ];
+
+    /**
+     * The attributes that should be cast.
+     *
+     * @var array
+     */
+    protected $casts = [
+        'cart_rule_id'        => 'integer',
+        'type'                => 'integer',
+        'minimal_items_price' => 'float',
+        'apply_tax'           => 'boolean',
+        'apply_shipping'      => 'boolean',
+        'auto_add'            => 'boolean',
+        'show_banner'         => 'boolean',
+    ];
+
+    /**
+     * 关联的营销规则。
+     */
+    public function cart_rule(): BelongsTo
+    {
+        return $this->belongsTo(CartRule::class, 'cart_rule_id');
+    }
+
+    /**
+     * Banner 图片的公开访问地址。
+     */
+    public function bannerImageUrl(): ?string
+    {
+        if (empty($this->banner_image)) {
+            return null;
+        }
+
+        return Storage::url($this->banner_image);
+    }
+
+    /**
+     * 赠品 SKU 列表。
+     *
+     * `sku` 是「逗号分隔多个 SKU」,但**商品 SKU 自身也可能含逗号**:
+     * 多件套商品就是 `LD--13*4Wig--24,LB--5*7Wig--RTG--20` 这样的 SKU,
+     * 直接 explode 会被拆成两个不存在的 SKU,赠品永远解析不到商品。
+     * 所以先拿整串去匹配商品/变体,命中就当成一个 SKU;否则才按逗号拆。
+     *
+     * @return string[]
+     */
+    public function skuList(): array
+    {
+        if (empty($this->sku)) {
+            return [];
+        }
+
+        $whole = trim((string) $this->sku);
+
+        if (str_contains($whole, ',') && app(GiftProductResolver::class)->resolve($whole)) {
+            return [$whole];
+        }
+
+        return array_values(array_filter(array_map('trim', explode(',', $whole))));
+    }
+
+    /**
+     * 是否是「一组合计送 N 个」。
+     */
+    public function isOneOf(): bool
+    {
+        return (int) $this->type === Gift::TYPE_ONE;
+    }
+
+    /**
+     * 是否是「每个 SKU 各送 N 个」。
+     */
+    public function isAllOf(): bool
+    {
+        return (int) $this->type === Gift::TYPE_ALL;
+    }
+}

+ 44 - 0
packages/Longyi/FreeGift/src/Providers/EventServiceProvider.php

@@ -0,0 +1,44 @@
+<?php
+
+namespace Longyi\FreeGift\Providers;
+
+use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
+
+class EventServiceProvider extends ServiceProvider
+{
+    /**
+     * The event listener mappings for the package.
+     *
+     * @var array
+     */
+    protected $listen = [
+        // 与原生促销 Webkul\CartRule\Listeners\Cart@applyCartRules 并列
+        'checkout.cart.collect.totals.before' => [
+            'Longyi\FreeGift\Listeners\Cart@applyFreeGifts',
+        ],
+
+        // 用户手动删除赠品 → 打标记,避免下次又被自动加回来
+        'checkout.cart.delete.before' => [
+            'Longyi\FreeGift\Listeners\CartItemDelete@handle',
+        ],
+
+        // 禁止修改赠品数量
+        'checkout.cart.update.before' => [
+            'Longyi\FreeGift\Listeners\CartItemUpdate@handle',
+        ],
+
+        // 下单后结算赠品用量
+        'checkout.order.save.after' => [
+            'Longyi\FreeGift\Listeners\OrderPlaced@handle',
+        ],
+
+        // 后台营销规则表单:Settings 抽屉之后追加「赠品设置」抽屉
+        'bagisto.admin.marketing.promotions.cart_rules.create.card.accordion.settings.after' => [
+            'Longyi\FreeGift\Listeners\AdminCartRuleForm@giftSettings',
+        ],
+
+        'bagisto.admin.marketing.promotions.cart_rules.edit.card.accordion.settings.after' => [
+            'Longyi\FreeGift\Listeners\AdminCartRuleForm@giftSettings',
+        ],
+    ];
+}

+ 74 - 0
packages/Longyi/FreeGift/src/Providers/FreeGiftServiceProvider.php

@@ -0,0 +1,74 @@
+<?php
+
+namespace Longyi\FreeGift\Providers;
+
+use ApiPlatform\State\ProcessorInterface;
+use ApiPlatform\State\ProviderInterface;
+use Illuminate\Support\Facades\Event;
+use Illuminate\Support\ServiceProvider;
+use Longyi\FreeGift\Http\Requests\GiftCartRuleRequest;
+use Longyi\FreeGift\Repositories\GiftCartRuleRepository;
+use Longyi\FreeGift\State\GiftCartProcessor;
+use Longyi\FreeGift\State\GiftCartProvider;
+use Webkul\Admin\Http\Requests\CartRuleRequest;
+use Webkul\CartRule\Repositories\CartRuleRepository;
+
+class FreeGiftServiceProvider extends ServiceProvider
+{
+    /**
+     * Register services.
+     */
+    public function register(): void
+    {
+        $this->registerConfig();
+
+        // GraphQL(API Platform)的 provider / processor 必须注册进容器,
+        // 否则 CallableProcessor 的 service locator 找不到它们:
+        // "Processor ... not found on operation create"
+        $this->app->tag(GiftCartProvider::class, ProviderInterface::class);
+        $this->app->tag(GiftCartProcessor::class, ProcessorInterface::class);
+
+        // 复用 Bagisto 原生的营销规则(Marketing > Promotions > Cart Rules):
+        // 规则本身的字段不重复造,只追加 free_gift[*] 配置。
+        $this->app->bind(CartRuleRequest::class, GiftCartRuleRequest::class);
+
+        $this->app->bind(CartRuleRepository::class, GiftCartRuleRepository::class);
+    }
+
+    /**
+     * Bootstrap services.
+     */
+    public function boot(): void
+    {
+        $this->loadMigrationsFrom(__DIR__.'/../Database/Migrations');
+
+        $this->loadTranslationsFrom(__DIR__.'/../Resources/lang', 'free_gift');
+
+        $this->loadViewsFrom(__DIR__.'/../Resources/views', 'free_gift');
+
+        $this->loadRoutesFrom(__DIR__.'/../Routes/shop-routes.php');
+
+        // 后台:赠品 SKU 选择器(商品搜索接口)
+        $this->loadRoutesFrom(__DIR__.'/../Routes/admin-routes.php');
+
+        $this->app->register(EventServiceProvider::class);
+
+        // 前台购物车页:赠品 Banner / 挑选弹窗
+        Event::listen('bagisto.shop.checkout.cart.header.after', function ($viewRenderEventManager) {
+            $viewRenderEventManager->addTemplate('free_gift::shop.cart.gift-container');
+        });
+
+        // 前台购物车行:赠品标记
+        Event::listen('bagisto.shop.checkout.cart.item_name.after', function ($viewRenderEventManager) {
+            $viewRenderEventManager->addTemplate('free_gift::shop.cart.gift-item-badge');
+        });
+    }
+
+    /**
+     * 系统配置项(System Configuration > Sales > Free Gift)。
+     */
+    protected function registerConfig(): void
+    {
+        $this->mergeConfigFrom(dirname(__DIR__).'/Config/system.php', 'core');
+    }
+}

+ 83 - 0
packages/Longyi/FreeGift/src/Repositories/FreeGiftItemRepository.php

@@ -0,0 +1,83 @@
+<?php
+
+namespace Longyi\FreeGift\Repositories;
+
+use Webkul\Core\Eloquent\Repository;
+
+class FreeGiftItemRepository extends Repository
+{
+    /**
+     * Specify model class name.
+     */
+    public function model(): string
+    {
+        return 'Longyi\FreeGift\Models\FreeGiftItem';
+    }
+
+    /**
+     * 取某个购物车下的全部赠品状态行。
+     *
+     * @param  int  $cartId
+     * @return \Illuminate\Support\Collection
+     */
+    public function getByCart($cartId)
+    {
+        return $this->findWhere(['cart_id' => $cartId]);
+    }
+
+    /**
+     * 取一条赠品状态行。
+     *
+     * @param  int  $cartId
+     * @param  string  $sku
+     * @param  int  $ruleId
+     * @return \Longyi\FreeGift\Contracts\FreeGiftItem|null
+     */
+    public function findOne($cartId, $sku, $ruleId)
+    {
+        return $this->findOneWhere([
+            'cart_id' => $cartId,
+            'sku'     => $sku,
+            'rule_id' => $ruleId,
+        ]);
+    }
+
+    /**
+     * 购物车清空时,恢复被用户手动删除的赠品的自动加购资格。
+     *
+     * @param  int  $cartId
+     * @return void
+     */
+    public function resetDeleted($cartId)
+    {
+        $this->getModel()->where('cart_id', $cartId)->update([
+            'is_item_deleted' => 0,
+            'reserved_qty'    => 0,
+        ]);
+    }
+
+    /**
+     * 把某个赠品行标记为「用户已手动删除」。
+     *
+     * @param  int  $cartItemId
+     * @return void
+     */
+    public function markDeletedByCartItemId($cartItemId)
+    {
+        $this->getModel()->where('cart_item_id', $cartItemId)->update([
+            'is_item_deleted' => 1,
+            'reserved_qty'    => 0,
+        ]);
+    }
+
+    /**
+     * 清理某个购物车的全部赠品状态行。
+     *
+     * @param  int  $cartId
+     * @return void
+     */
+    public function deleteByCart($cartId)
+    {
+        $this->getModel()->where('cart_id', $cartId)->delete();
+    }
+}

+ 58 - 0
packages/Longyi/FreeGift/src/Repositories/FreeGiftRuleRepository.php

@@ -0,0 +1,58 @@
+<?php
+
+namespace Longyi\FreeGift\Repositories;
+
+use Webkul\Core\Eloquent\Repository;
+
+class FreeGiftRuleRepository extends Repository
+{
+    /**
+     * Specify model class name.
+     */
+    public function model(): string
+    {
+        return 'Longyi\FreeGift\Models\FreeGiftRule';
+    }
+
+    /**
+     * 按营销规则 ID 取赠品配置。
+     *
+     * @param  int  $cartRuleId
+     * @return \Longyi\FreeGift\Contracts\FreeGiftRule|null
+     */
+    public function findByCartRuleId($cartRuleId)
+    {
+        return $this->findOneWhere(['cart_rule_id' => $cartRuleId]);
+    }
+
+    /**
+     * 批量保存赠品配置(新增或更新)。
+     *
+     * @param  int  $cartRuleId
+     * @param  array  $data
+     * @return \Longyi\FreeGift\Contracts\FreeGiftRule
+     */
+    public function saveByCartRuleId($cartRuleId, array $data)
+    {
+        $attributes = array_merge($data, ['cart_rule_id' => $cartRuleId]);
+
+        $existing = $this->findByCartRuleId($cartRuleId);
+
+        if ($existing) {
+            return $this->update($attributes, $existing->id);
+        }
+
+        return $this->create($attributes);
+    }
+
+    /**
+     * 删除某条营销规则下的赠品配置。
+     *
+     * @param  int  $cartRuleId
+     * @return void
+     */
+    public function deleteByCartRuleId($cartRuleId)
+    {
+        $this->getModel()->where('cart_rule_id', $cartRuleId)->delete();
+    }
+}

+ 146 - 0
packages/Longyi/FreeGift/src/Repositories/GiftCartRuleRepository.php

@@ -0,0 +1,146 @@
+<?php
+
+namespace Longyi\FreeGift\Repositories;
+
+use Illuminate\Container\Container;
+use Illuminate\Support\Facades\Storage;
+use Longyi\FreeGift\Config\Gift;
+use Webkul\Attribute\Repositories\AttributeFamilyRepository;
+use Webkul\Attribute\Repositories\AttributeRepository;
+use Webkul\CartRule\Repositories\CartRuleCouponRepository;
+use Webkul\CartRule\Repositories\CartRuleRepository as BaseCartRuleRepository;
+use Webkul\Category\Repositories\CategoryRepository;
+use Webkul\Core\Repositories\CountryRepository;
+use Webkul\Core\Repositories\CountryStateRepository;
+use Webkul\Tax\Repositories\TaxCategoryRepository;
+
+/**
+ * 接管原生营销规则仓库,在保存规则的同时落库/清理赠品配置。
+ *
+ * 之所以改仓库而不是改控制器:后台表单、DataGrid、API 都走同一个仓库,
+ * 改仓库能保证「任何入口保存规则」都会同步赠品配置。
+ */
+class GiftCartRuleRepository extends BaseCartRuleRepository
+{
+    public function __construct(
+        AttributeFamilyRepository $attributeFamilyRepository,
+        AttributeRepository $attributeRepository,
+        CategoryRepository $categoryRepository,
+        CartRuleCouponRepository $cartRuleCouponRepository,
+        TaxCategoryRepository $taxCategoryRepository,
+        CountryRepository $countryRepository,
+        CountryStateRepository $countryStateRepository,
+        protected FreeGiftRuleRepository $freeGiftRuleRepository,
+        Container $container
+    ) {
+        parent::__construct(
+            $attributeFamilyRepository,
+            $attributeRepository,
+            $categoryRepository,
+            $cartRuleCouponRepository,
+            $taxCategoryRepository,
+            $countryRepository,
+            $countryStateRepository,
+            $container
+        );
+    }
+
+    /**
+     * @return \Webkul\CartRule\Contracts\CartRule
+     */
+    public function create(array $data)
+    {
+        $cartRule = parent::create($data);
+
+        $this->saveGiftConfig($cartRule->id, $data);
+
+        return $cartRule;
+    }
+
+    /**
+     * @param  int  $id
+     * @return \Webkul\CartRule\Contracts\CartRule
+     */
+    public function update(array $data, $id)
+    {
+        $cartRule = parent::update($data, $id);
+
+        $this->saveGiftConfig($cartRule->id, $data);
+
+        return $cartRule;
+    }
+
+    /**
+     * 删除规则时一并清掉赠品配置(DB 层也有 cascade,这里为了兼容软删除等场景)。
+     *
+     * @param  int  $id
+     */
+    public function delete($id)
+    {
+        $this->freeGiftRuleRepository->deleteByCartRuleId($id);
+
+        return parent::delete($id);
+    }
+
+    /**
+     * 把请求里的 free_gift[*] 落库。
+     *
+     * @param  int  $cartRuleId
+     * @param  array  $data
+     */
+    protected function saveGiftConfig($cartRuleId, array $data): void
+    {
+        // 不是赠品型规则 → 清掉可能残留的旧配置
+        if (! Gift::isGiftAction($data['action_type'] ?? null)) {
+            $this->freeGiftRuleRepository->deleteByCartRuleId($cartRuleId);
+
+            return;
+        }
+
+        $freeGift = $data['free_gift'] ?? [];
+
+        $this->freeGiftRuleRepository->saveByCartRuleId($cartRuleId, [
+            'sku'                 => $freeGift['sku'] ?? null,
+            'type'                => (int) ($freeGift['type'] ?? Gift::TYPE_ALL),
+            'items_discount'      => $freeGift['items_discount'] ?? '100%',
+            'minimal_items_price' => ($freeGift['minimal_items_price'] ?? null) ?: null,
+            'apply_tax'           => (int) ($freeGift['apply_tax'] ?? 1),
+            'apply_shipping'      => (int) ($freeGift['apply_shipping'] ?? 0),
+            'auto_add'            => (int) ($freeGift['auto_add'] ?? 1),
+            'show_banner'         => (int) ($freeGift['show_banner'] ?? 0),
+            'banner_image'        => $this->resolveBannerImage($cartRuleId, $freeGift) ?: null,
+            'banner_alt'          => $freeGift['banner_alt'] ?? null,
+        ]);
+    }
+
+    /**
+     * 解析 Banner 图片路径。
+     *
+     * 表单里没有单独的 banner_image 字段(只有文件上传),所以未上传新图时必须
+     * 保留旧值,否则每次保存规则都会把已上传的图片清掉。
+     *
+     * @param  int  $cartRuleId
+     * @param  array  $freeGift
+     * @return string|null
+     */
+    protected function resolveBannerImage($cartRuleId, array $freeGift)
+    {
+        if (! request()->hasFile('free_gift_banner_image_file')) {
+            if (! empty($freeGift['banner_image'])) {
+                return $freeGift['banner_image'];
+            }
+
+            return $this->freeGiftRuleRepository->findByCartRuleId($cartRuleId)?->banner_image;
+        }
+
+        $path = request()->file('free_gift_banner_image_file')->store('free-gift');
+
+        $oldPath = $this->freeGiftRuleRepository->findByCartRuleId($cartRuleId)?->banner_image;
+
+        if ($oldPath && $oldPath !== $path) {
+            Storage::delete($oldPath);
+        }
+
+        return $path;
+    }
+}

+ 63 - 0
packages/Longyi/FreeGift/src/Resources/lang/en/admin.php

@@ -0,0 +1,63 @@
+<?php
+
+return [
+    'gift-settings'            => 'Free Gift Settings',
+    'gift-settings-info'       => 'Configured only when the action type is one of the free gift types.',
+    'sku'                      => 'Gift SKUs',
+    'sku-placeholder'          => 'e.g. gift-a, gift-b',
+    'sku-info'                 => 'Comma separated product SKUs. Leave empty for "buy it get it" to give the purchased product itself.',
+    'type'                     => 'Gift list type',
+    'type-all'                 => 'Each SKU gets the full quantity',
+    'type-one'                 => 'All SKUs share the quantity (customer picks)',
+    'items-discount'           => 'Gift discount',
+    'items-discount-info'      => '100% (fully free), 50%, -10 (off 10) or a fixed price like 99.',
+    'minimal-items-price'      => 'Minimum price after discount',
+    'minimal-items-price-info' => 'The gift price will never go below this value. Leave empty for no limit.',
+    'auto-add'                 => 'Add gifts to cart automatically',
+    'auto-add-info'            => 'If disabled, customers pick gifts manually from the cart page.',
+    'show-banner'              => 'Show banner in cart',
+    'banner-alt'               => 'Banner text',
+    'banner-image'             => 'Banner image',
+    'banner-image-info'        => 'Used by the cart page banner of this rule. JPG / PNG / WEBP, up to 2 MB.',
+    'yes'                      => 'Yes',
+    'no'                       => 'No',
+
+    // 赠品 SKU 选择器(弹窗)
+    'choose-products'          => 'Choose Products',
+    'picker-title'             => 'Choose gift products',
+    'picker-close'             => 'Close',
+    'picker-search-placeholder' => 'Search by product name or SKU, empty for all',
+    'picker-loading'           => 'Loading…',
+    'picker-empty'             => 'No products found',
+    'picker-no-name'           => '(Unnamed product)',
+    'picker-load-more'         => 'Load more',
+    'picker-not-giftable'      => 'Cannot be given as a gift',
+    'picker-reason-inactive'   => 'Cannot be a gift: product is disabled',
+    'picker-reason-type'       => 'Cannot be a gift: unsupported product type (only simple / virtual / flexible variant)',
+    'picker-reason-no-variant' => 'Cannot be a gift: no enabled variant in stock, so it never reaches the cart',
+    'picker-confirm'           => 'Confirm',
+    'picker-cancel'            => 'Cancel',
+    'picker-selected'          => 'Selected :count: :skus',
+
+    'action-same-product'  => 'Free Gift: Buy one get one (same product)',
+    'action-with-products' => 'Free Gift: With selected products',
+    'action-each-n'        => 'Free Gift: Every N items',
+    'action-whole-cart'    => 'Free Gift: Whole cart',
+    'action-spent'         => 'Free Gift: Spent amount',
+
+    'hint-same-product'  => '"Discount Amount" is the quantity of the same product given for free, "Discount Step" is how many items the customer must buy.',
+    'hint-with-products' => '"Discount Amount" is the quantity of the gift SKUs given away once any product of the conditions is in the cart.',
+    'hint-each-n'        => '"Discount Step" is the number of items required, "Discount Amount" is the number of gifts given for every step.',
+    'hint-whole-cart'    => '"Discount Amount" is the quantity of the gift SKUs given away, no condition is required.',
+    'hint-spent'         => '"Discount Step" is the amount (in base currency) required, "Discount Amount" is the number of gifts given for every step.',
+
+    // 系统配置 → 销售 → Free Gift
+    'system' => [
+        'title'            => 'Free Gift',
+        'info'             => 'Global settings for free gift (giveaway) promotions.',
+        'general'          => 'General',
+        'general-info'     => 'Global switches only. Each promotion still configures its own gifts in Marketing → Promotions → Cart Rules.',
+        'disable-auto-add' => 'Disable adding free gifts to the cart automatically',
+        'show-banner'      => 'Show the free gift banner on the cart page',
+    ],
+];

+ 27 - 0
packages/Longyi/FreeGift/src/Resources/lang/en/app.php

@@ -0,0 +1,27 @@
+<?php
+
+return [
+    'cart' => [
+        'gift-quantity-locked' => 'The quantity of free gift items cannot be changed. Please adjust the quantity of regular items instead.',
+        'gift'                 => 'Free Gift',
+        'gift-note'            => 'Free gift added automatically. It is not counted in the payable amount.',
+        'gift-original-price'  => 'Original price',
+        'gift-select-title'    => 'Choose your free gift',
+        'gift-select-hint'     => 'You can pick up to :qty item(s) from the list below.',
+        'gift-add'             => 'Add to cart',
+        'banner-default'       => 'Free gift added to cart',
+        'gift-added'           => 'Free gift added successfully.',
+        'gift-removed'         => 'Free gift removed.',
+        'gift-unavailable'     => 'The selected free gift is currently unavailable.',
+        'gift-rule-inactive'   => 'This free gift rule is no longer valid.',
+        'gift-pick-button'     => 'Choose gift',
+        'gift-strip'           => 'You have free gift(s) available from this promotion.',
+        'gift-free-price'      => 'FREE',
+        'gift-remove'          => 'Remove',
+        'gift-close'           => 'Close',
+        'gift-error'           => 'Unable to update the free gift, please try again.',
+        'gift-limit-reached'   => 'All the free gifts of this promotion have been added.',
+        'gift-restored'        => 'Free gifts restored.',
+        'cart-not-found'       => 'Cart not found or no longer active.',
+    ],
+];

+ 63 - 0
packages/Longyi/FreeGift/src/Resources/lang/zh_CN/admin.php

@@ -0,0 +1,63 @@
+<?php
+
+return [
+    'gift-settings'            => '赠品设置',
+    'gift-settings-info'       => '仅当动作类型选择赠品类时才生效。',
+    'sku'                      => '赠品 SKU',
+    'sku-placeholder'          => '例如:gift-a, gift-b',
+    'sku-info'                 => '多个 SKU 用英文逗号分隔。「买它送它」留空表示赠送所购商品本身。',
+    'type'                     => '赠品组类型',
+    'type-all'                 => '每个 SKU 各送指定数量',
+    'type-one'                 => '所有 SKU 合计送指定数量(用户自选)',
+    'items-discount'           => '赠品折扣',
+    'items-discount-info'      => '100% 表示全免;50% 表示五折;-10 表示立减 10;也可以直接填固定价,例如 99。',
+    'minimal-items-price'      => '折后最低价',
+    'minimal-items-price-info' => '赠品折扣后的价格不会低于该值,留空表示不限。',
+    'auto-add'                 => '自动加入购物车',
+    'auto-add-info'            => '关闭后由用户在购物车页面手动挑选赠品。',
+    'show-banner'              => '购物车显示 Banner',
+    'banner-alt'               => 'Banner 文案',
+    'banner-image'             => 'Banner 图片',
+    'banner-image-info'        => '该规则的购物车 Banner 图片。支持 JPG / PNG / WEBP,最大 2 MB。',
+    'yes'                      => '是',
+    'no'                       => '否',
+
+    // 赠品 SKU 选择器(弹窗)
+    'choose-products'          => '选择商品',
+    'picker-title'             => '选择赠品商品',
+    'picker-close'             => '关闭',
+    'picker-search-placeholder' => '输入商品名称或 SKU 搜索,留空显示全部',
+    'picker-loading'           => '加载中…',
+    'picker-empty'             => '没有找到商品',
+    'picker-no-name'           => '(未命名商品)',
+    'picker-load-more'         => '加载更多',
+    'picker-not-giftable'      => '不可作为赠品',
+    'picker-reason-inactive'   => '不可作为赠品:商品已停用',
+    'picker-reason-type'       => '不可作为赠品:该商品类型不支持(仅支持普通 / 虚拟 / 多变体商品)',
+    'picker-reason-no-variant' => '不可作为赠品:该多变体商品没有「启用且有库存」的变体,赠品加不进购物车',
+    'picker-confirm'           => '确定',
+    'picker-cancel'            => '取消',
+    'picker-selected'          => '已选 :count 个::skus',
+
+    'action-same-product'  => '赠品:买它送它(同款买赠)',
+    'action-with-products' => '赠品:购买指定商品送',
+    'action-each-n'        => '赠品:每 N 件送',
+    'action-whole-cart'    => '赠品:整单送',
+    'action-spent'         => '赠品:满额送',
+
+    'hint-same-product'  => '「折扣数量」为免费赠送的同款数量,「折扣步长」为需要购买的数量。',
+    'hint-with-products' => '「折扣数量」为购物车出现条件商品后一次性赠送的赠品数量。',
+    'hint-each-n'        => '「折扣步长」为需要的商品件数,「折扣数量」为每达标一次赠送的数量。',
+    'hint-whole-cart'    => '「折扣数量」为赠送的赠品数量,无需配置条件。',
+    'hint-spent'         => '「折扣步长」为需要的金额(基础货币),「折扣数量」为每达标一次赠送的数量。',
+
+    // 系统配置 → 销售 → 赠品活动
+    'system' => [
+        'title'            => '赠品活动',
+        'info'             => '赠品(免费赠送)相关的全局设置。',
+        'general'          => '通用设置',
+        'general-info'     => '这里只是全局开关;具体每个活动的赠品规则在「营销 → 促销 → 购物车规则」里配置。',
+        'disable-auto-add' => '关闭「自动把赠品加入购物车」',
+        'show-banner'      => '在购物车页面显示赠品 Banner',
+    ],
+];

+ 27 - 0
packages/Longyi/FreeGift/src/Resources/lang/zh_CN/app.php

@@ -0,0 +1,27 @@
+<?php
+
+return [
+    'cart' => [
+        'gift-quantity-locked' => '赠品数量不可修改,请调整普通商品的数量。',
+        'gift'                 => '赠品',
+        'gift-note'            => '赠品由活动自动加入,不计入应付金额。',
+        'gift-original-price'  => '原价',
+        'gift-select-title'    => '请选择赠品',
+        'gift-select-hint'     => '您最多可以从以下列表中挑选 :qty 件赠品。',
+        'gift-add'             => '加入购物车',
+        'banner-default'       => '赠品已添加到购物车',
+        'gift-added'           => '赠品已加入购物车。',
+        'gift-removed'         => '赠品已移除。',
+        'gift-unavailable'     => '所选赠品当前不可用。',
+        'gift-rule-inactive'   => '该赠品活动已失效。',
+        'gift-pick-button'     => '挑选赠品',
+        'gift-strip'           => '您有可挑选的赠品名额。',
+        'gift-free-price'      => '免费',
+        'gift-remove'          => '移除',
+        'gift-close'           => '关闭',
+        'gift-error'           => '赠品更新失败,请稍后重试。',
+        'gift-limit-reached'   => '该活动的赠品名额已全部领取。',
+        'gift-restored'        => '赠品已恢复自动加入。',
+        'cart-not-found'       => '购物车不存在或已失效。',
+    ],
+];

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1245 - 0
packages/Longyi/FreeGift/src/Resources/views/admin/cart-rules/gift-settings.blade.php


+ 342 - 0
packages/Longyi/FreeGift/src/Resources/views/shop/cart/gift-container.blade.php

@@ -0,0 +1,342 @@
+{!! view_render_event('bagisto.shop.checkout.cart.gift_container.before') !!}
+
+{{--
+    购物车页赠品区域:Banner + 赠品挑选弹窗。
+
+    整块由 v-free-gift 组件渲染,数据来自 free_gift::shop-routes 里的
+    api/free-gift/candidates,逻辑上与 Amasty 的 Cart Popup 一致。
+--}}
+<v-free-gift></v-free-gift>
+
+{!! view_render_event('bagisto.shop.checkout.cart.gift_container.after') !!}
+
+@pushOnce('scripts')
+    <script
+        type="text/x-template"
+        id="v-free-gift-template"
+    >
+        <div v-if="groups.length" class="container px-[60px] pt-6 max-lg:px-8 max-md:px-4">
+            <!-- 规则 Banner -->
+            <div
+                v-for="banner in banners"
+                :key="'banner-' + banner.rule_id"
+                class="mb-3 flex flex-wrap items-center justify-between gap-4 rounded-2xl border border-zinc-200 bg-white px-5 py-4 max-md:px-4 max-md:py-3"
+            >
+                <div class="flex items-center gap-4">
+                    <img
+                        v-if="banner.image"
+                        :src="banner.image"
+                        class="h-16 w-24 rounded-xl object-cover max-md:h-12 max-md:w-16"
+                        alt=""
+                    />
+
+                    <div>
+                        <p class="text-base font-medium text-zinc-800 max-md:text-sm">
+                            @{{ banner.text }}
+                        </p>
+
+                        <p v-if="banner.remaining_qty > 0" class="mt-0.5 text-xs text-zinc-500">
+                            @{{ hintFor(banner.rule_id) }}
+                        </p>
+                    </div>
+                </div>
+
+                <button
+                    v-if="banner.remaining_qty > 0"
+                    type="button"
+                    class="primary-button rounded-2xl px-6 py-2.5 text-sm max-md:px-4 max-md:py-2"
+                    @click="openPicker(banner.rule_id)"
+                >
+                    @lang('free_gift::app.cart.gift-pick-button')
+                </button>
+
+                <span
+                    v-else
+                    class="text-sm font-medium text-green-600"
+                >
+                    @lang('free_gift::app.cart.gift-added')
+                </span>
+            </div>
+
+            <!-- 没有 Banner 但还有剩余名额(手动模式 / 用户删掉了自动赠品) -->
+            <div
+                v-for="group in manualGroups"
+                :key="'group-' + group.rule_id"
+                class="mb-3 flex flex-wrap items-center justify-between gap-4 rounded-2xl border border-dashed border-navyBlue/40 bg-blue-50/40 px-5 py-3.5 max-md:px-4 max-md:py-3"
+            >
+                <div>
+                    <p class="text-sm font-medium text-zinc-800">
+                        @lang('free_gift::app.cart.gift-strip')
+                        <span v-if="group.rule_name">(@{{ group.rule_name }})</span>
+                    </p>
+                </div>
+
+                <button
+                    type="button"
+                    class="secondary-button rounded-2xl px-5 py-2 text-sm"
+                    @click="openPicker(group.rule_id)"
+                >
+                    @lang('free_gift::app.cart.gift-pick-button')
+                </button>
+            </div>
+        </div>
+
+        <!-- 赠品挑选弹窗 -->
+        <div
+            v-if="activeGroup"
+            class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4"
+        >
+            <div class="w-full max-w-2xl overflow-hidden rounded-2xl bg-white shadow-2xl">
+                <div class="flex items-start justify-between gap-4 border-b border-zinc-200 px-5 py-4">
+                    <div>
+                        <p class="text-lg font-semibold text-zinc-800">
+                            @lang('free_gift::app.cart.gift-select-title')
+                        </p>
+
+                        <p class="mt-1 text-xs text-zinc-500">
+                            @{{ hintFor(activeGroup.rule_id) }}
+                        </p>
+                    </div>
+
+                    <span
+                        class="cursor-pointer text-2xl leading-none text-zinc-400 hover:text-zinc-700"
+                        @click="closePicker"
+                    >
+                        &times;
+                    </span>
+                </div>
+
+                <div class="max-h-[60vh] overflow-y-auto px-5 py-4">
+                    <p
+                        v-if="errorMessage"
+                        class="mb-3 rounded-lg bg-red-50 px-3 py-2 text-xs text-red-600"
+                    >
+                        @{{ errorMessage }}
+                    </p>
+
+                    <div
+                        v-for="gift in activeGroup.gifts"
+                        :key="'gift-' + gift.sku"
+                        class="flex items-center gap-4 border-b border-zinc-100 py-3 last:border-b-0"
+                    >
+                        <img
+                            :src="gift.image"
+                            class="h-20 w-20 shrink-0 rounded-xl border border-zinc-100 object-cover"
+                            alt=""
+                        />
+
+                        <div class="min-w-0 flex-1">
+                            <a
+                                :href="gift.url"
+                                target="_blank"
+                                class="text-sm font-medium text-zinc-800 hover:text-navyBlue"
+                            >
+                                @{{ gift.name }}
+                            </a>
+
+                            <p class="mt-1 text-xs text-zinc-500">
+                                <span class="line-through">@{{ gift.price }}</span>
+
+                                <span class="ml-2 font-semibold text-green-600">
+                                    @lang('free_gift::app.cart.gift-free-price')
+                                </span>
+                            </p>
+
+                            <p
+                                v-if="activeGroup.type === 1 && activeGroup.remaining_qty <= 0 && gift.reserved_qty <= 0"
+                                class="mt-1 text-xs text-zinc-400"
+                            >
+                                @lang('free_gift::app.cart.gift-limit-reached')
+                            </p>
+                        </div>
+
+                        <div class="flex shrink-0 items-center gap-3">
+                            <template v-if="gift.reserved_qty > 0">
+                                <span class="text-sm font-medium text-zinc-700">
+                                    x@{{ gift.reserved_qty }}
+                                </span>
+
+                                <button
+                                    type="button"
+                                    class="cursor-pointer text-sm font-medium text-blue-700 hover:underline"
+                                    @click="removeGift(gift)"
+                                >
+                                    @lang('free_gift::app.cart.gift-remove')
+                                </button>
+                            </template>
+
+                            <button
+                                v-else-if="canAdd(gift)"
+                                type="button"
+                                class="secondary-button rounded-2xl px-5 py-2 text-sm"
+                                @click="addGift(gift)"
+                            >
+                                @lang('free_gift::app.cart.gift-add')
+                            </button>
+
+                            <span v-else class="text-xs text-zinc-400">
+                                @lang('free_gift::app.cart.gift-unavailable')
+                            </span>
+                        </div>
+                    </div>
+                </div>
+
+                <div class="flex justify-end border-t border-zinc-200 px-5 py-3">
+                    <button
+                        type="button"
+                        class="secondary-button rounded-2xl px-6 py-2 text-sm"
+                        @click="closePicker"
+                    >
+                        @lang('free_gift::app.cart.gift-close')
+                    </button>
+                </div>
+            </div>
+        </div>
+    </script>
+
+    <script type="module">
+        app.component('v-free-gift', {
+            template: '#v-free-gift-template',
+
+            data() {
+                return {
+                    groups: [],
+
+                    banners: [],
+
+                    activeRuleId: null,
+
+                    errorMessage: '',
+                };
+            },
+
+            computed: {
+                activeGroup() {
+                    return this.groups.find(group => group.rule_id === this.activeRuleId) || null;
+                },
+
+                /**
+                 * 没有 Banner 可点、但仍有剩余名额的规则。
+                 */
+                manualGroups() {
+                    return this.groups.filter(group => {
+                        if (group.remaining_qty <= 0) {
+                            return false;
+                        }
+
+                        return ! this.banners.some(banner => banner.rule_id === group.rule_id);
+                    });
+                },
+            },
+
+            mounted() {
+                this.load();
+            },
+
+            methods: {
+                /**
+                 * 拉取当前购物车的赠品候选。
+                 */
+                load() {
+                    this.$axios.get('{{ route('shop.free_gift.candidates') }}')
+                        .then(response => {
+                            const data = response.data.data ?? {};
+
+                            this.groups = data.groups ?? [];
+
+                            this.banners = data.banners ?? [];
+                        })
+                        .catch(() => {
+                            this.groups = [];
+
+                            this.banners = [];
+                        });
+                },
+
+                /**
+                 * 提示文案里的 :qty 用当前规则的剩余名额替换。
+                 */
+                hintFor(ruleId) {
+                    const group = this.groups.find(item => item.rule_id === ruleId);
+
+                    const qty = group
+                        ? (group.type === 1 ? group.remaining_qty : group.allowed_qty)
+                        : 0;
+
+                    return @json(trans('free_gift::app.cart.gift-select-hint')).replace(':qty', qty);
+                },
+
+                /**
+                 * 「每个 SKU 各送 N 个」只要该行还有名额就能继续加;
+                 * 「合计送 N 个」要整组还有名额。
+                 */
+                canAdd(gift) {
+                    if (gift.remaining_qty <= 0) {
+                        return false;
+                    }
+
+                    if (! this.activeGroup) {
+                        return false;
+                    }
+
+                    return this.activeGroup.type === 0 || this.activeGroup.remaining_qty > 0;
+                },
+
+                openPicker(ruleId) {
+                    this.errorMessage = '';
+
+                    this.activeRuleId = ruleId;
+                },
+
+                closePicker() {
+                    this.errorMessage = '';
+
+                    this.activeRuleId = null;
+                },
+
+                /**
+                 * 加入赠品:合计送 N 个的整组名额一次用完,逐 SKU 送的按剩余量补齐。
+                 */
+                addGift(gift) {
+                    const group = this.activeGroup;
+
+                    const qty = group && group.type === 0
+                        ? gift.remaining_qty
+                        : 1;
+
+                    this.submit('{{ route('shop.free_gift.add') }}', {
+                        sku:     gift.sku,
+                        rule_id: group ? group.rule_id : null,
+                        qty:     qty,
+                    });
+                },
+
+                removeGift(gift) {
+                    if (! gift.cart_item_id) {
+                        return;
+                    }
+
+                    this.submit('{{ route('shop.free_gift.remove') }}', {
+                        cart_item_id: gift.cart_item_id,
+                    });
+                },
+
+                /**
+                 * 赠品变动会影响小计/优惠,直接重载购物车页换取一致的金额展示。
+                 */
+                submit(url, payload) {
+                    this.errorMessage = '';
+
+                    this.$axios.post(url, payload)
+                        .then(() => {
+                            window.location.reload();
+                        })
+                        .catch(error => {
+                            this.errorMessage = error.response?.data?.message
+                                ?? @json(trans('free_gift::app.cart.gift-error'));
+                        });
+                },
+            },
+        });
+    </script>
+@endPushOnce

+ 18 - 0
packages/Longyi/FreeGift/src/Resources/views/shop/cart/gift-item-badge.blade.php

@@ -0,0 +1,18 @@
+{{--
+    购物车行内的赠品标记。
+
+    注入点 bagisto.shop.checkout.cart.item_name.after 位于 v-cart 组件模板内,
+    所以这里可以直接用 item(来自 CartItemResource 的 is_free_gift 字段)。
+--}}
+<div
+    v-if="item.is_free_gift"
+    class="mt-1.5 flex flex-wrap items-center gap-2"
+>
+    <span class="rounded-full bg-green-50 px-2.5 py-0.5 text-xs font-medium text-green-600">
+        @lang('free_gift::app.cart.gift')
+    </span>
+
+    <span class="text-xs text-zinc-500">
+        @lang('free_gift::app.cart.gift-note')
+    </span>
+</div>

+ 15 - 0
packages/Longyi/FreeGift/src/Routes/admin-routes.php

@@ -0,0 +1,15 @@
+<?php
+
+use Illuminate\Support\Facades\Route;
+use Longyi\FreeGift\Http\Controllers\Admin\ProductPickerController;
+
+/**
+ * 后台赠品相关路由(沿用 Longyi\Member / Longyi\Gift 的写法:
+ * 自带 web + admin 中间件组,prefix 自己写全)。
+ */
+Route::group(['middleware' => ['web', 'admin'], 'prefix' => 'admin/free-gift'], function () {
+    /**
+     * 赠品 SKU 选择器的数据源(只读,返回 JSON)。
+     */
+    Route::get('products', [ProductPickerController::class, 'index'])->name('admin.free_gift.products');
+});

+ 21 - 0
packages/Longyi/FreeGift/src/Routes/shop-routes.php

@@ -0,0 +1,21 @@
+<?php
+
+use Illuminate\Support\Facades\Route;
+use Longyi\FreeGift\Http\Controllers\Shop\GiftController;
+
+/**
+ * 前台赠品接口。
+ *
+ * 用和 Webkul\Shop 一样的 ['web', 'shop'] 中间件组,
+ * 保证拿到 Theme / Locale / Currency 和 session 里的购物车。
+ */
+Route::middleware(['web', 'shop'])->prefix('api/free-gift')->group(function () {
+    Route::get('candidates', [GiftController::class, 'candidates'])
+        ->name('shop.free_gift.candidates');
+
+    Route::post('add', [GiftController::class, 'add'])
+        ->name('shop.free_gift.add');
+
+    Route::post('remove', [GiftController::class, 'remove'])
+        ->name('shop.free_gift.remove');
+});

+ 131 - 0
packages/Longyi/FreeGift/src/Services/Actions/AbstractGiftAction.php

@@ -0,0 +1,131 @@
+<?php
+
+namespace Longyi\FreeGift\Services\Actions;
+
+use Longyi\FreeGift\Contracts\FreeGiftRule as FreeGiftRuleContract;
+
+/**
+ * 赠品数量算法基类。
+ *
+ * 对应 Amasty 的 Model\Rule\Action\Discount\AbstractAction。
+ *
+ * 输入是「命中规则条件的非赠品购物车行」,输出是 sku => 数量 的映射,
+ * 由 GiftProcessor 交给 GiftItemsGroup 注册。
+ */
+abstract class AbstractGiftAction
+{
+    /**
+     * 计算本条规则应该送出哪些 SKU、各多少个。
+     *
+     * @param  \Webkul\CartRule\Contracts\CartRule  $rule
+     * @param  array  $items  命中条件(且非赠品)的购物车行
+     * @param  FreeGiftRuleContract  $giftRule
+     * @param  \Webkul\Checkout\Contracts\Cart  $cart
+     * @return array<string, float>
+     */
+    public function getGiftQtyMap($rule, array $items, FreeGiftRuleContract $giftRule, $cart): array
+    {
+        if (empty($items)) {
+            return [];
+        }
+
+        $qty = $this->limit($rule, $this->calculate($rule, $items, $cart));
+
+        if ($qty < 1) {
+            return [];
+        }
+
+        return $this->mapToSkus($giftRule, $qty, $items);
+    }
+
+    /**
+     * 计算本条规则允许赠送的总数量。
+     *
+     * @param  \Webkul\CartRule\Contracts\CartRule  $rule
+     * @param  array  $items
+     * @param  \Webkul\Checkout\Contracts\Cart  $cart
+     */
+    abstract protected function calculate($rule, array $items, $cart): float;
+
+    /**
+     * 把总量摊到具体 SKU 上(默认给每个配置的 SKU 都发这么多)。
+     *
+     * @return array<string, float>
+     */
+    protected function mapToSkus(FreeGiftRuleContract $giftRule, float $qty, array $items): array
+    {
+        $skus = $giftRule->skuList();
+
+        if (empty($skus)) {
+            return [];
+        }
+
+        return array_fill_keys($skus, $qty);
+    }
+
+    /**
+     * 「每满 N」的步长。
+     *
+     * 注意 cart_rules.discount_step 是 string 类型,必须显式转 float。
+     *
+     * @param  \Webkul\CartRule\Contracts\CartRule  $rule
+     */
+    protected function getStep($rule): float
+    {
+        return max(1, (float) ($rule->discount_step ?: 1));
+    }
+
+    /**
+     * 每满一个步长赠送的数量。
+     *
+     * @param  \Webkul\CartRule\Contracts\CartRule  $rule
+     */
+    protected function getAmount($rule): float
+    {
+        return max(1, (float) ($rule->discount_amount ?: 1));
+    }
+
+    /**
+     * 上限裁剪:cart_rules.discount_quantity。
+     *
+     * @param  \Webkul\CartRule\Contracts\CartRule  $rule
+     */
+    protected function limit($rule, float $qty): float
+    {
+        $cap = (int) $rule->discount_quantity;
+
+        if ($cap > 0) {
+            $qty = min($qty, $cap);
+        }
+
+        return max(0, floor($qty));
+    }
+
+    /**
+     * 命中商品的数量合计。
+     */
+    protected function totalQty(array $items): float
+    {
+        $total = 0;
+
+        foreach ($items as $item) {
+            $total += (float) $item->quantity;
+        }
+
+        return $total;
+    }
+
+    /**
+     * 命中商品的金额合计(不含税,并扣掉已分摊的促销折扣)。
+     */
+    protected function totalAmount(array $items): float
+    {
+        $total = 0;
+
+        foreach ($items as $item) {
+            $total += (float) $item->base_total - (float) $item->base_discount_amount;
+        }
+
+        return $total;
+    }
+}

+ 22 - 0
packages/Longyi/FreeGift/src/Services/Actions/CartAction.php

@@ -0,0 +1,22 @@
+<?php
+
+namespace Longyi\FreeGift\Services\Actions;
+
+/**
+ * 整车送。
+ *
+ * 对应 Amasty 的 Rule\Action\Discount\Cart。
+ *
+ * 条件只作为开关(通常留空,即「车里有东西就送」),
+ * 数量直接取 discount_amount,不看件数也不看金额。
+ */
+class CartAction extends AbstractGiftAction
+{
+    /**
+     * 命中即送固定数量。
+     */
+    protected function calculate($rule, array $items, $cart): float
+    {
+        return $this->getAmount($rule);
+    }
+}

+ 38 - 0
packages/Longyi/FreeGift/src/Services/Actions/EachNAction.php

@@ -0,0 +1,38 @@
+<?php
+
+namespace Longyi\FreeGift\Services\Actions;
+
+use Longyi\FreeGift\Config\Gift;
+
+/**
+ * 每 N 件送。
+ *
+ * 对应 Amasty 的 Rule\Action\Discount\Eachn。
+ *
+ * 与 ProductAction 的区别:条件是「触发开关」,数量按**整个购物车**的非赠品总件数计算,
+ * 而不是只看命中条件的商品。例如「车里至少有 1 件 A 商品时,每买 3 件送 1 件」。
+ */
+class EachNAction extends AbstractGiftAction
+{
+    /**
+     * 以全车非赠品件数为基数。
+     */
+    protected function calculate($rule, array $items, $cart): float
+    {
+        $step = $this->getStep($rule);
+
+        $amount = $this->getAmount($rule);
+
+        $cartQty = 0;
+
+        foreach ($cart->all_items as $cartItem) {
+            if ($cartItem->additional[Gift::CART_ITEM_FLAG] ?? null) {
+                continue;
+            }
+
+            $cartQty += (float) $cartItem->quantity;
+        }
+
+        return floor($cartQty / $step) * $amount;
+    }
+}

+ 40 - 0
packages/Longyi/FreeGift/src/Services/Actions/GiftActionPool.php

@@ -0,0 +1,40 @@
+<?php
+
+namespace Longyi\FreeGift\Services\Actions;
+
+use Longyi\FreeGift\Config\Gift;
+
+/**
+ * action_type → 算法 的分发器。
+ */
+class GiftActionPool
+{
+    /**
+     * @var array<string, AbstractGiftAction>
+     */
+    protected array $actions;
+
+    public function __construct(
+        SameProductAction $sameProduct,
+        ProductAction $product,
+        EachNAction $eachN,
+        CartAction $cart,
+        SpentAction $spent
+    ) {
+        $this->actions = [
+            Gift::ACTION_SAME_PRODUCT  => $sameProduct,
+            Gift::ACTION_WITH_PRODUCTS => $product,
+            Gift::ACTION_EACH_N        => $eachN,
+            Gift::ACTION_WHOLE_CART    => $cart,
+            Gift::ACTION_SPENT         => $spent,
+        ];
+    }
+
+    /**
+     * 取 action_type 对应的算法。
+     */
+    public function get($actionType): ?AbstractGiftAction
+    {
+        return $this->actions[$actionType] ?? null;
+    }
+}

+ 26 - 0
packages/Longyi/FreeGift/src/Services/Actions/ProductAction.php

@@ -0,0 +1,26 @@
+<?php
+
+namespace Longyi\FreeGift\Services\Actions;
+
+/**
+ * 买指定商品送。
+ *
+ * 对应 Amasty 的 Rule\Action\Discount\Product。
+ *
+ * 条件筛选出的商品合计每满 discount_step 件,送 discount_amount 个赠品;
+ * 总额以 discount_quantity 封顶。
+ */
+class ProductAction extends AbstractGiftAction
+{
+    /**
+     * 命中商品合计数量每满一个步长,送一份。
+     */
+    protected function calculate($rule, array $items, $cart): float
+    {
+        $step = $this->getStep($rule);
+
+        $amount = $this->getAmount($rule);
+
+        return floor($this->totalQty($items) / $step) * $amount;
+    }
+}

+ 69 - 0
packages/Longyi/FreeGift/src/Services/Actions/SameProductAction.php

@@ -0,0 +1,69 @@
+<?php
+
+namespace Longyi\FreeGift\Services\Actions;
+
+use Longyi\FreeGift\Contracts\FreeGiftRule as FreeGiftRuleContract;
+
+/**
+ * 买它送它。
+ *
+ * 对应 Amasty 的 Rule\Action\Discount\SameProduct。
+ *
+ * 两种用法:
+ *  1. 配了赠品 SKU → 按「命中商品总件数」算总量,发给配置的 SKU
+ *  2. 没配赠品 SKU → 送的就是命中的商品本身,每个商品各自算各自的量
+ */
+class SameProductAction extends AbstractGiftAction
+{
+    /**
+     * 计算允许赠送的总量(每个命中商品各自算,再求和)。
+     */
+    protected function calculate($rule, array $items, $cart): float
+    {
+        $step = $this->getStep($rule);
+
+        $amount = $this->getAmount($rule);
+
+        $total = 0;
+
+        foreach ($items as $item) {
+            $total += floor(((float) $item->quantity) / $step) * $amount;
+        }
+
+        return $total;
+    }
+
+    /**
+     * 未配置赠品 SKU 时走「送商品本身」的分支。
+     *
+     * @return array<string, float>
+     */
+    public function getGiftQtyMap($rule, array $items, FreeGiftRuleContract $giftRule, $cart): array
+    {
+        if (! empty($giftRule->skuList())) {
+            return parent::getGiftQtyMap($rule, $items, $giftRule, $cart);
+        }
+
+        $step = $this->getStep($rule);
+
+        $amount = $this->getAmount($rule);
+
+        $cap = (int) $rule->discount_quantity;
+
+        $map = [];
+
+        foreach ($items as $item) {
+            $qty = floor(((float) $item->quantity) / $step) * $amount;
+
+            if ($cap > 0) {
+                $qty = min($qty, $cap);
+            }
+
+            if ($qty >= 1) {
+                $map[$item->sku] = ($map[$item->sku] ?? 0) + $qty;
+            }
+        }
+
+        return $map;
+    }
+}

+ 27 - 0
packages/Longyi/FreeGift/src/Services/Actions/SpentAction.php

@@ -0,0 +1,27 @@
+<?php
+
+namespace Longyi\FreeGift\Services\Actions;
+
+/**
+ * 满额送。
+ *
+ * 对应 Amasty 的 Rule\Action\Discount\Spent。
+ *
+ * 命中条件的商品金额合计每满 discount_step,送 discount_amount 个赠品。
+ * 金额口径:base_total - base_discount_amount(不含税,且已扣掉原生促销折扣),
+ * 与 Bagisto 原生 by_percent 规则的口径保持一致。
+ */
+class SpentAction extends AbstractGiftAction
+{
+    /**
+     * 命中商品金额每满一个步长,送一份。
+     */
+    protected function calculate($rule, array $items, $cart): float
+    {
+        $step = $this->getStep($rule);
+
+        $amount = $this->getAmount($rule);
+
+        return floor($this->totalAmount($items) / $step) * $amount;
+    }
+}

+ 235 - 0
packages/Longyi/FreeGift/src/Services/GiftCartSummary.php

@@ -0,0 +1,235 @@
+<?php
+
+namespace Longyi\FreeGift\Services;
+
+use Longyi\FreeGift\ApiResource\GiftBannerData;
+use Longyi\FreeGift\ApiResource\GiftGroupData;
+use Longyi\FreeGift\ApiResource\GiftProductData;
+use Longyi\FreeGift\Config\Gift;
+use Longyi\FreeGift\Repositories\FreeGiftItemRepository;
+use Longyi\FreeGift\Repositories\FreeGiftRuleRepository;
+use Webkul\Product\ProductImage;
+use Webkul\Product\Repositories\ProductRepository;
+
+/**
+ * 购物车的赠品候选快照(规则名额 + 每个赠品是否已加购)。
+ *
+ * 只读 free_gift_items / cart_items 的现状,不触发重算 —— 调用前购物车
+ * 应已经过 collectTotals()(readCart / 加购 / 更新数量内部都会跑)。
+ *
+ * 被两处复用:
+ *  - GraphQL 的 giftCandidates / giftProducts / giftBanners(GiftCartProvider)
+ *  - 购物车接口本身的 CartData::freeGifts(直接内嵌,省一次请求)
+ */
+class GiftCartSummary
+{
+    public function __construct(
+        protected FreeGiftItemRepository $giftItemRepository,
+        protected FreeGiftRuleRepository $giftRuleRepository,
+        protected ProductRepository $productRepository,
+        protected ProductImage $productImage,
+        protected GiftProductResolver $productResolver
+    ) {}
+
+    /**
+     * @return array{0: GiftGroupData[], 1: GiftProductData[], 2: GiftBannerData[]}
+     */
+    public function forCart(int $cartId): array
+    {
+        [$groups, $gifts] = $this->buildGroups($cartId);
+
+        return [$groups, $gifts, $this->buildBanners($groups)];
+    }
+
+    /**
+     * 只取赠品候选列表(购物车接口内嵌用)。
+     *
+     * @return GiftProductData[]
+     */
+    public function gifts(int $cartId): array
+    {
+        return $this->buildGroups($cartId)[1];
+    }
+
+    /**
+     * 按规则聚合:groups 只放名额信息,gifts 是全部候选(带 rule_id / cart_item_id)。
+     *
+     * @return array{0: GiftGroupData[], 1: GiftProductData[]}
+     */
+    protected function buildGroups(int $cartId): array
+    {
+        $groups = [];
+
+        $gifts = [];
+
+        $allowedByRule = [];
+        $reservedByRule = [];
+        $giftsByRule = [];
+
+        foreach ($this->giftItemRepository->getByCart($cartId) as $row) {
+            if ($row->allowed_qty < 1) {
+                continue;
+            }
+
+            $giftRule = $this->giftRuleRepository->findByCartRuleId($row->rule_id);
+
+            if (! $giftRule) {
+                continue;
+            }
+
+            $resolved = $this->resolveRowProduct($row);
+
+            if (! $resolved) {
+                continue;
+            }
+
+            $product = $resolved['product'];
+            $variant = $resolved['variant'];
+
+            $ruleId = (int) $row->rule_id;
+            $ruleName = $giftRule->cart_rule?->name;
+
+            if (! isset($groups[$ruleId])) {
+                $group = new GiftGroupData;
+
+                $group->id = (string) $ruleId;
+                $group->rule_id = $ruleId;
+                $group->rule_name = $ruleName;
+                $group->type = (int) $row->rule_type;
+                $group->auto_add = (bool) $row->auto_add;
+                $group->allowed_qty = 0;
+                $group->reserved_qty = 0;
+                $group->remaining_qty = 0;
+
+                $groups[$ruleId] = $group;
+
+                $allowedByRule[$ruleId] = [];
+                $reservedByRule[$ruleId] = 0.0;
+            }
+
+            $allowedByRule[$ruleId][] = (float) $row->allowed_qty;
+            $reservedByRule[$ruleId] += (float) $row->reserved_qty;
+
+            $image = $this->productImage->getProductBaseImage($product);
+
+            $gift = new GiftProductData;
+
+            $gift->id = $ruleId.':'.$row->sku;
+            $gift->sku = $row->sku;
+            $gift->rule_id = $ruleId;
+            $gift->rule_name = $ruleName;
+            $gift->product_id = (int) $product->id;
+            $gift->name = $variant->name ?? $product->name;
+            $gift->url = route('shop.product_or_category.index', $product->url_key);
+            $gift->image = $image['medium_image_url'] ?? null;
+            $gift->price = core()->formatPrice(
+                $variant ? $this->productResolver->variantPrice($variant) : (float) $product->price
+            );
+            $gift->allowed_qty = (float) $row->allowed_qty;
+            $gift->reserved_qty = (float) $row->reserved_qty;
+            // 「一组合计送 N 个」的规则下面会按整组名额修正,这里先放逐行差值
+            $gift->remaining_qty = max(0, (float) $row->allowed_qty - (float) $row->reserved_qty);
+            $gift->cart_item_id = $row->cart_item_id ? (int) $row->cart_item_id : null;
+            $gift->is_deleted = (bool) $row->is_item_deleted;
+
+            $gifts[] = $gift;
+            $giftsByRule[$ruleId][] = $gift;
+        }
+
+        foreach ($groups as $ruleId => $group) {
+            $allowed = $allowedByRule[$ruleId] ?: [0.0];
+
+            // 「一组合计送 N 个」:整组共用 N 个名额,N 取单条的允许量而不是求和
+            $group->allowed_qty = $group->type === Gift::TYPE_ONE
+                ? (float) max($allowed)
+                : (float) array_sum($allowed);
+
+            $group->reserved_qty = (float) ($reservedByRule[$ruleId] ?? 0);
+            $group->remaining_qty = max(0, $group->allowed_qty - $group->reserved_qty);
+
+            /*
+             * 「一组合计送 N 个」:名额是整组共享的,逐候选的 remaining_qty 也必须
+             * 按组修正。否则名额已经被某个 SKU 用掉的组里,其余候选还会各自显示
+             * "可领 N 件",接口就变成「已送 1 件 + 还能领 1 件」,
+             * 前端展示出来就像送了 2 件。
+             *
+             * 已加购 / 被用户删掉的候选一律 0;剩下的名额按顺序分配给未加购的候选。
+             */
+            if ($group->type === Gift::TYPE_ONE) {
+                $remaining = $group->remaining_qty;
+
+                foreach ($giftsByRule[$ruleId] ?? [] as $gift) {
+                    if ($gift->reserved_qty > 0 || $gift->cart_item_id || $gift->is_deleted) {
+                        $gift->remaining_qty = 0;
+
+                        continue;
+                    }
+
+                    $take = min((float) $gift->allowed_qty, $remaining);
+
+                    $gift->remaining_qty = max(0, $take);
+                    $remaining -= $take;
+                }
+            }
+        }
+
+        return [array_values($groups), $gifts];
+    }
+
+    /**
+     * 把 free_gift_items 的一行解析成「商品 + 变体」。
+     *
+     * 优先按 SKU 解析(flexible_variant 能拿到具体变体与变体价),
+     * 解析不到再退回 product_id(历史数据 / 规则里 SKU 写错的情况)。
+     *
+     * @return array{product: object, variant: ?\Longyi\Core\Models\ProductVariant}|null
+     */
+    protected function resolveRowProduct($row): ?array
+    {
+        $resolved = $this->productResolver->resolve((string) $row->sku);
+
+        if ($resolved) {
+            return $resolved;
+        }
+
+        $product = $row->product_id ? $this->productRepository->find((int) $row->product_id) : null;
+
+        return $product ? ['product' => $product, 'variant' => null] : null;
+    }
+
+    /**
+     * 需要展示 Banner 的规则(受总开关 + 规则自身 show_banner 控制)。
+     *
+     * @param  GiftGroupData[]  $groups
+     * @return GiftBannerData[]
+     */
+    protected function buildBanners(array $groups): array
+    {
+        $banners = [];
+
+        if (! core()->getConfigData('sales.free_gift.general.show_banner')) {
+            return $banners;
+        }
+
+        foreach ($groups as $group) {
+            $giftRule = $this->giftRuleRepository->findByCartRuleId($group->rule_id);
+
+            if (! $giftRule || ! $giftRule->show_banner) {
+                continue;
+            }
+
+            $banner = new GiftBannerData;
+
+            $banner->id = (string) $group->rule_id;
+            $banner->rule_id = $group->rule_id;
+            $banner->text = $giftRule->banner_alt ?: trans('free_gift::app.cart.banner-default');
+            $banner->image = $giftRule->bannerImageUrl();
+            $banner->allowed_qty = $group->allowed_qty;
+            $banner->remaining_qty = $group->remaining_qty;
+
+            $banners[] = $banner;
+        }
+
+        return $banners;
+    }
+}

+ 77 - 0
packages/Longyi/FreeGift/src/Services/GiftDiscountCalculator.php

@@ -0,0 +1,77 @@
+<?php
+
+namespace Longyi\FreeGift\Services;
+
+/**
+ * 赠品折扣计算。
+ *
+ * 对应 Amasty 的 Model\DiscountCalculator,保持同样的 4 种写法:
+ *  - ''        → 全免(等同 100%)
+ *  - '100%'    → 全免
+ *  - '50%'     → 半价
+ *  - '-10'     → 立减 10
+ *  - '99'      → 一口价 99(原价 - 99)
+ */
+class GiftDiscountCalculator
+{
+    /**
+     * 计算减免金额。
+     *
+     * @param  string|null  $promo
+     * @param  float  $price
+     */
+    public function discountFor($promo, float $price): float
+    {
+        $promo = trim((string) $promo);
+
+        if ($promo === '' || $promo === '100%') {
+            return $price;
+        }
+
+        if (str_contains($promo, '%')) {
+            $percent = (float) $promo;
+
+            // 支持 "0.5" 与 "50" 两种百分比写法
+            $percent = $percent > 1 ? $percent / 100 : $percent;
+
+            return $price * min(1, max(0, $percent));
+        }
+
+        if (str_starts_with($promo, '-')) {
+            return min(abs((float) $promo), $price);
+        }
+
+        // 一口价
+        return max(0, $price - (float) $promo);
+    }
+
+    /**
+     * minimal_items_price 兜底:折后单价不得低于最低价。
+     */
+    public function clampToMinimal(float $discount, float $price, $minimal): float
+    {
+        $minimal = (float) $minimal;
+
+        if ($minimal > 0 && ($price - $discount) < $minimal) {
+            return max(0, $price - $minimal);
+        }
+
+        return $discount;
+    }
+
+    /**
+     * 一步算出赠品的实际单价。
+     *
+     * @param  string|null  $promo
+     * @param  float  $price
+     * @param  float|null  $minimal
+     */
+    public function priceFor($promo, float $price, $minimal = null): float
+    {
+        $discount = $this->discountFor($promo, $price);
+
+        $discount = $this->clampToMinimal($discount, $price, $minimal);
+
+        return round(max(0, $price - $discount), 4);
+    }
+}

+ 379 - 0
packages/Longyi/FreeGift/src/Services/GiftItem.php

@@ -0,0 +1,379 @@
+<?php
+
+namespace Longyi\FreeGift\Services;
+
+use Longyi\FreeGift\Config\Gift;
+
+/**
+ * 单个赠品候选(购物车 + SKU + 规则)。
+ *
+ * 对应 Amasty 的 ItemRegistry\PromoItemData。
+ *
+ * 数量口径说明:
+ *  - qty         本轮新注册进来的增量
+ *  - allowedQty  本轮累计允许赠送的总量(规则重算一次就重置一次)
+ *  - storedQty   上一轮持久化到 free_gift_items 的 allowed_qty,用来判断"是变多还是变少"
+ *  - reservedQty 已经真实躺在 cart_items 里的数量
+ *  - qtyToProcess = allowedQty - reservedQty(还差几个要补)
+ *
+ * 变体(flexible_variant)说明:
+ *  - sku 永远是规则里配置的 SKU(free_gift_items 的唯一键之一,不能变)
+ *  - variantId 是解析出来的具体变体;配置父商品 SKU 时,cart_items 行的 sku 是
+ *    variant sku,所以「认领购物车行」必须用 ruleId + variantId 而不是 ruleId + sku
+ */
+class GiftItem
+{
+    /**
+     * free_gift_items.id(首次注册时为 null)。
+     */
+    protected ?int $id = null;
+
+    /**
+     * 赠品的 product.id,注册时可能还没查到。
+     */
+    protected ?int $productId = null;
+
+    /**
+     * flexible_variant 赠品解析出的 product_variants.id。
+     */
+    protected ?int $variantId = null;
+
+    /**
+     * 本轮注册的增量。
+     */
+    protected float $qty = 0;
+
+    /**
+     * 累计允许赠送的总量。
+     */
+    protected float $allowedQty = 0;
+
+    /**
+     * 已加入购物车的数量。
+     */
+    protected float $reservedQty = 0;
+
+    /**
+     * 上一轮持久化的允许量。
+     */
+    protected float $storedQty = 0;
+
+    /**
+     * 该赠品行对应的购物车行 ID。
+     */
+    protected ?int $cartItemId = null;
+
+    /**
+     * 赠品折后最低价(free_gift_rules.minimal_items_price)。
+     */
+    protected ?float $minimalPrice = null;
+
+    /**
+     * 用户是否手动删除过。
+     */
+    protected bool $deleted = false;
+
+    /**
+     * 是否自动加购。
+     */
+    protected bool $autoAdd = true;
+
+    /**
+     * 排序(按规则 sort_order 递增),保证自动加购顺序稳定。
+     */
+    protected int $sortOrder = 0;
+
+    /**
+     * 本轮的"实际要处理数量"。
+     *
+     * 「一组合计送 N 个」的规则里,同组 SKU 共享名额,需要由分组统一裁剪,
+     * 这时会显式 setProcessQty();为 null 时退化成 allowedQty - reservedQty。
+     */
+    protected ?float $processQty = null;
+
+    public function __construct(
+        protected string $sku,
+        protected int $ruleId,
+        protected ?string $discount = null,
+        protected int $ruleType = Gift::TYPE_ALL,
+        ?bool $autoAdd = true,
+    ) {
+        $this->autoAdd = $autoAdd !== false;
+    }
+
+    public function getId(): ?int
+    {
+        return $this->id;
+    }
+
+    public function setId(?int $id): self
+    {
+        $this->id = $id;
+
+        return $this;
+    }
+
+    public function getSku(): string
+    {
+        return $this->sku;
+    }
+
+    public function getRuleId(): int
+    {
+        return $this->ruleId;
+    }
+
+    public function getDiscount(): ?string
+    {
+        return $this->discount;
+    }
+
+    public function setDiscount(?string $discount): self
+    {
+        $this->discount = $discount;
+
+        return $this;
+    }
+
+    public function getRuleType(): int
+    {
+        return $this->ruleType;
+    }
+
+    public function setRuleType(int $ruleType): self
+    {
+        $this->ruleType = $ruleType;
+
+        return $this;
+    }
+
+    public function isOneOf(): bool
+    {
+        return $this->ruleType === Gift::TYPE_ONE;
+    }
+
+    public function isAllOf(): bool
+    {
+        return $this->ruleType === Gift::TYPE_ALL;
+    }
+
+    public function getProductId(): ?int
+    {
+        return $this->productId;
+    }
+
+    public function setProductId(?int $productId): self
+    {
+        $this->productId = $productId;
+
+        return $this;
+    }
+
+    public function getVariantId(): ?int
+    {
+        return $this->variantId;
+    }
+
+    public function setVariantId(?int $variantId): self
+    {
+        $this->variantId = $variantId;
+
+        return $this;
+    }
+
+    public function getQty(): float
+    {
+        return $this->qty;
+    }
+
+    /**
+     * 追加允许数量(同一条规则命中多次时累加)。
+     */
+    public function addQty(float $qty): self
+    {
+        $this->qty += $qty;
+        $this->allowedQty += $qty;
+
+        return $this;
+    }
+
+    /**
+     * 规则重算前清空允许量。
+     */
+    public function resetQtyAllowed(): self
+    {
+        $this->qty = 0;
+        $this->allowedQty = 0;
+
+        return $this;
+    }
+
+    public function getAllowedQty(): float
+    {
+        return $this->allowedQty;
+    }
+
+    public function setAllowedQty(float $qty): self
+    {
+        $this->allowedQty = $qty;
+
+        return $this;
+    }
+
+    public function getReservedQty(): float
+    {
+        return $this->reservedQty;
+    }
+
+    public function setReservedQty(float $qty): self
+    {
+        $this->reservedQty = $qty;
+
+        return $this;
+    }
+
+    public function addReservedQty(float $qty): self
+    {
+        $this->reservedQty += $qty;
+
+        return $this;
+    }
+
+    public function removeReservedQty(float $qty): self
+    {
+        $this->reservedQty = max(0, $this->reservedQty - $qty);
+
+        return $this;
+    }
+
+    public function getStoredQty(): float
+    {
+        return $this->storedQty;
+    }
+
+    public function setStoredQty(float $qty): self
+    {
+        $this->storedQty = $qty;
+
+        return $this;
+    }
+
+    public function getMinimalPrice(): ?float
+    {
+        return $this->minimalPrice;
+    }
+
+    public function setMinimalPrice(?float $minimalPrice): self
+    {
+        $this->minimalPrice = $minimalPrice;
+
+        return $this;
+    }
+
+    public function getCartItemId(): ?int
+    {
+        return $this->cartItemId;
+    }
+
+    public function setCartItemId(?int $cartItemId): self
+    {
+        $this->cartItemId = $cartItemId;
+
+        return $this;
+    }
+
+    public function isDeleted(): bool
+    {
+        return $this->deleted;
+    }
+
+    public function setDeleted(bool $deleted): self
+    {
+        $this->deleted = $deleted;
+
+        return $this;
+    }
+
+    public function isAutoAdd(): bool
+    {
+        return $this->autoAdd;
+    }
+
+    public function setAutoAdd(bool $autoAdd): self
+    {
+        $this->autoAdd = $autoAdd;
+
+        return $this;
+    }
+
+    public function getSortOrder(): int
+    {
+        return $this->sortOrder;
+    }
+
+    public function setSortOrder(int $sortOrder): self
+    {
+        $this->sortOrder = $sortOrder;
+
+        return $this;
+    }
+
+    /**
+     * 还差几个需要补进购物车。
+     */
+    public function getQtyToProcess(): float
+    {
+        return max(0, $this->allowedQty - $this->reservedQty);
+    }
+
+    /**
+     * 由分组裁剪后的实际处理数量。
+     */
+    public function setProcessQty(?float $qty): self
+    {
+        $this->processQty = $qty === null ? null : max(0, $qty);
+
+        return $this;
+    }
+
+    /**
+     * 本轮实际要加购的数量。
+     */
+    public function getProcessQty(): float
+    {
+        if ($this->processQty !== null) {
+            return $this->processQty;
+        }
+
+        return $this->isDeleted() ? 0 : $this->getQtyToProcess();
+    }
+
+    /**
+     * free_gift_items 的唯一键(cart_id + rule_id + sku)。
+     */
+    public function getKey(): string
+    {
+        return $this->ruleId.':'.$this->sku;
+    }
+
+    /**
+     * 认领 cart_items 行用的键:有变体时用 variant_id,否则退回 sku。
+     *
+     * flexible_variant 的购物车行 sku 存的是 variant sku,而配置里可能是父商品
+     * sku,两者对不上,只能靠 variant_id 认领。
+     */
+    public function getCartKey(): string
+    {
+        return static::cartKey($this->ruleId, $this->variantId, $this->sku);
+    }
+
+    /**
+     * @param  int  $ruleId
+     * @param  int|null  $variantId
+     * @param  string  $sku  cart_items.sku(变体商品是 variant sku)
+     */
+    public static function cartKey(int $ruleId, ?int $variantId, string $sku): string
+    {
+        return $ruleId.':'.($variantId ? 'v'.$variantId : $sku);
+    }
+}

+ 0 - 0
packages/Longyi/FreeGift/src/Services/GiftItemsGroup.php


Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio