Procházet zdrojové kódy

取消订单支持 userManual,用户手动取消时直接取消订单。

Co-authored-by: Cursor <cursoragent@cursor.com>
chengwl před 3 dny
rodič
revize
781c8a1389

+ 1 - 1
packages/Webkul/BagistoApi/README.md

@@ -115,7 +115,7 @@ Both paths use the same state machine:
 | `paymentInitiateCreate`   | Create the Bagisto order + a gateway order id in one shot. Returns the new cart token so the buyer can keep adding products to a fresh cart. Set `expressCheckout: true` to skip shipping/email validation. |
 | `paymentCallbackCreate`   | Frontend hits this after the gateway redirect. `status = success` triggers capture + (for express orders) writes the real shipping/billing address. `status = cancel | failure` leaves the order pending. |
 | `paymentReplayCreate`     | Authenticated. Generates a fresh gateway order id for a still-pending order so the buyer can retry payment. |
-| `cancelOrderCreate`       | Existing mutation; now strategy-aware: guest cancels immediately and reactivates the old cart; customer with shipping address stays pending; customer without shipping address follows the `bagistoapi.express_checkout.cancel_without_address` config (`cancel` by default, switchable to `keep_pending`). |
+| `cancelOrderCreate`       | Existing mutation; now strategy-aware: `userManual: true` always cancels immediately; guest cancels immediately and reactivates the old cart; customer with shipping address stays pending; customer without shipping address follows the `bagistoapi.express_checkout.cancel_without_address` config (`cancel` by default, switchable to `keep_pending`). |
 
 ### `payment.additional` keys
 

+ 14 - 0
packages/Webkul/BagistoApi/src/Dto/CancelOrderInput.php

@@ -5,6 +5,7 @@ namespace Webkul\BagistoApi\Dto;
 use ApiPlatform\Metadata\ApiProperty;
 use ApiPlatform\Metadata\ApiResource;
 use Symfony\Component\Serializer\Annotation\Groups;
+use Symfony\Component\Serializer\Annotation\SerializedName;
 
 /**
  * DTO for canceling a customer order
@@ -22,5 +23,18 @@ class CancelOrderInput
         required: true
     )]
     #[Groups(['mutation'])]
+    #[SerializedName('orderId')]
     public ?int $orderId = null;
+
+    /**
+     * When true, skip keep-pending and cancel the order immediately.
+     * Used when the customer explicitly cancels from the order list.
+     */
+    #[ApiProperty(
+        description: 'When true, cancel the order immediately instead of keeping it pending for payment retry',
+        required: false
+    )]
+    #[Groups(['mutation'])]
+    #[SerializedName('userManual')]
+    public ?bool $userManual = null;
 }

+ 6 - 1
packages/Webkul/BagistoApi/src/Models/CancelOrder.php

@@ -47,9 +47,14 @@ use ApiPlatform\OpenApi\Model\Operation;
                                         'description' => 'The ID of the order to cancel.',
                                         'example'     => 411,
                                     ],
+                                    'userManual' => [
+                                        'type'        => 'boolean',
+                                        'description' => 'When true, cancel immediately instead of keeping the order pending for payment retry.',
+                                        'example'     => true,
+                                    ],
                                 ],
                             ],
-                            'example' => ['orderId' => 411],
+                            'example' => ['orderId' => 411, 'userManual' => true],
                         ],
                     ]),
                 ),

+ 13 - 1
packages/Webkul/BagistoApi/src/Services/PaymentService.php

@@ -291,9 +291,21 @@ class PaymentService
      * Decide what to do with a pending order when the cancel mutation
      * is invoked. Returns an array describing the action so the caller
      * can report it back to the client.
+     *
+     * A user-initiated cancel (`userManual`) always cancels immediately,
+     * including the customer-with-address path that would otherwise keep
+     * the order pending for paymentReplay.
      */
-    public function decideCancelStrategy($order, bool $isGuest): array
+    public function decideCancelStrategy($order, bool $isGuest, bool $userManual = false): array
     {
+        if ($userManual) {
+            return [
+                'action'          => 'cancel',
+                'reason'          => 'user_manual',
+                'reactivate_cart' => $isGuest ? $this->resolveOldCartId($order) : null,
+            ];
+        }
+
         $hasShippingAddress = (bool) $order->shipping_address && ! $this->isPlaceholderAddress($order->shipping_address);
 
         if ($isGuest) {

+ 58 - 15
packages/Webkul/BagistoApi/src/State/CancelOrderProcessor.php

@@ -21,6 +21,7 @@ use Webkul\Sales\Repositories\OrderRepository;
  * CancelOrderProcessor — Handles the cancel order mutation
  *
  * Strategy:
+ *  - userManual=true: always cancel immediately (customer clicked cancel).
  *  - Guest cancel: cancel immediately + reactivate the original cart so
  *    the buyer can keep going if they change their mind.
  *  - Customer with a real shipping address: keep order PENDING so the
@@ -66,7 +67,11 @@ class CancelOrderProcessor implements ProcessorInterface
             );
         }
 
-        $strategy = $this->paymentService->decideCancelStrategy($order, $isGuest);
+        $strategy = $this->paymentService->decideCancelStrategy(
+            $order,
+            $isGuest,
+            (bool) $input->userManual,
+        );
 
         if ($strategy['action'] === 'keep_pending') {
             Event::dispatch('bagistoapi.order.cancel.kept-pending', [
@@ -185,27 +190,44 @@ class CancelOrderProcessor implements ProcessorInterface
      */
     private function hydrateInputFromContext(CancelOrderInput $data, array $context): void
     {
-        if (! empty($data->orderId)) {
-            return;
-        }
-
         $input = $context['args']['input'] ?? $context['args'] ?? null;
 
-        $orderId = $this->extractOrderId($input);
+        if (empty($data->orderId)) {
+            $orderId = $this->extractOrderId($input);
 
-        if ($orderId === null) {
-            $request = Request::instance();
+            if ($orderId === null) {
+                $request = Request::instance();
+
+                if ($request) {
+                    $orderId = $this->extractOrderId($request->input('variables.input'))
+                        ?? $this->extractOrderId($request->input('input'))
+                        ?? $this->extractOrderId($request->input('extensions.variables.input'))
+                        ?? $this->extractOrderId($request->all());
+                }
+            }
 
-            if ($request) {
-                $orderId = $this->extractOrderId($request->input('variables.input'))
-                    ?? $this->extractOrderId($request->input('input'))
-                    ?? $this->extractOrderId($request->input('extensions.variables.input'))
-                    ?? $this->extractOrderId($request->all());
+            if ($orderId !== null) {
+                $data->orderId = $orderId;
             }
         }
 
-        if ($orderId !== null) {
-            $data->orderId = $orderId;
+        if ($data->userManual === null) {
+            $userManual = $this->extractUserManual($input);
+
+            if ($userManual === null) {
+                $request = Request::instance();
+
+                if ($request) {
+                    $userManual = $this->extractUserManual($request->input('variables.input'))
+                        ?? $this->extractUserManual($request->input('input'))
+                        ?? $this->extractUserManual($request->input('extensions.variables.input'))
+                        ?? $this->extractUserManual($request->all());
+                }
+            }
+
+            if ($userManual !== null) {
+                $data->userManual = $userManual;
+            }
         }
     }
 
@@ -225,4 +247,25 @@ class CancelOrderProcessor implements ProcessorInterface
 
         return null;
     }
+
+    private function extractUserManual(mixed $input): ?bool
+    {
+        if (is_array($input)) {
+            $value = $input['userManual'] ?? $input['user_manual'] ?? null;
+        } elseif (is_object($input)) {
+            $value = $input->userManual ?? $input->user_manual ?? null;
+        } else {
+            return null;
+        }
+
+        if ($value === null) {
+            return null;
+        }
+
+        if (is_bool($value)) {
+            return $value;
+        }
+
+        return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
+    }
 }

+ 33 - 0
packages/Webkul/BagistoApi/tests/Unit/Services/PaymentServiceCancelStrategyTest.php

@@ -17,6 +17,7 @@ use Webkul\Sales\Repositories\OrderTransactionRepository;
  * Coverage for the cancel-order strategy branches required by the
  * "支付流程" spec:
  *
+ *   - userManual     -> always cancel immediately (overrides keep_pending)
  *   - guest          -> immediate cancel + reactivate old cart
  *   - customer + addr -> keep order pending
  *   - customer + !addr + config=cancel       -> cancel
@@ -58,6 +59,38 @@ class PaymentServiceCancelStrategyTest extends TestCase
         };
     }
 
+    public function test_user_manual_cancel_overrides_keep_pending(): void
+    {
+        Event::fake();
+
+        $order = $this->fakeOrder([
+            'city'     => 'San Francisco',
+            'postcode' => '94016',
+        ]);
+
+        $result = $this->makeService()->decideCancelStrategy($order, isGuest: false, userManual: true);
+
+        $this->assertSame('cancel', $result['action']);
+        $this->assertSame('user_manual', $result['reason']);
+        Event::assertNotDispatched('bagistoapi.express.cancel.no-address');
+    }
+
+    public function test_user_manual_guest_cancel_still_reactivates_cart(): void
+    {
+        Event::fake();
+
+        $order = $this->fakeOrder([
+            'city'     => 'NYC',
+            'postcode' => '10001',
+        ], ['cart_id' => 42]);
+
+        $result = $this->makeService()->decideCancelStrategy($order, isGuest: true, userManual: true);
+
+        $this->assertSame('cancel', $result['action']);
+        $this->assertSame('user_manual', $result['reason']);
+        $this->assertSame(42, $result['reactivate_cart']);
+    }
+
     public function test_guest_cancel_reactivates_old_cart(): void
     {
         Event::fake();