From a982ee2b608119a07955d803a77ca276a6cf6b00 Mon Sep 17 00:00:00 2001 From: wushumin Date: Tue, 16 Jun 2026 16:35:55 +0800 Subject: [PATCH] feat: add enterprise order cancel open api --- docs/api/third-party-openapi.md | 98 +++++-- .../app/controller/open/OrdersController.php | 28 ++ .../app/support/EnterpriseOrderService.php | 221 ++++++++++---- server-api/config/route.php | 1 + .../enterprise_order_cancel_mock_test.php | 275 ++++++++++++++++++ 5 files changed, 556 insertions(+), 67 deletions(-) create mode 100644 server-api/tools/enterprise_order_cancel_mock_test.php diff --git a/docs/api/third-party-openapi.md b/docs/api/third-party-openapi.md index 6a8f1e0..62f8ca2 100644 --- a/docs/api/third-party-openapi.md +++ b/docs/api/third-party-openapi.md @@ -1,7 +1,7 @@ # 第三方订单对接文档 -版本:v1.1 -更新日期:2026-06-11 +版本:v1.2 +更新日期:2026-06-16 ## 1. 对接说明 @@ -507,7 +507,69 @@ GET /api/open/v1/orders?order_no=AXY20260508120000123 } ``` -## 7. 发货通知 +## 7. 取消订单 + +第三方订单尚未寄送前,可以调用本接口取消订单。取消成功后订单状态变为 `cancelled`,后台待处理鉴定任务会同步移除。 + +```text +POST /api/open/v1/orders/cancel +``` + +### 7.1 请求参数 + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `external_order_no` | string | 是 | 第三方订单号。只能取消当前 `app_key` 所属客户下的订单 | +| `cancel_reason` | string | 否 | 取消原因,最长 255 个字符 | + +### 7.2 请求示例 + +```json +{ + "external_order_no": "THIRD202606160001", + "cancel_reason": "客户取消鉴定" +} +``` + +### 7.3 成功响应示例 + +```json +{ + "code": 0, + "message": "订单已取消", + "data": { + "cancelled": true, + "order": { + "customer_id": "CUST001", + "customer_code": "CUST001", + "external_order_no": "THIRD202606160001", + "order_no": "AXY20260616120000123", + "order_status": "cancelled", + "display_status": "已取消", + "payment_status": "paid", + "timeline": [ + { + "node_code": "cancelled", + "node_text": "订单已取消", + "node_desc": "第三方客户取消订单:客户取消鉴定", + "occurred_at": "2026-06-16 12:00:00" + } + ] + } + } +} +``` + +### 7.4 取消规则 + +- 仅 `pending_shipping` 且尚未提交寄入运单的订单允许取消。 +- 创建订单时已传 `inbound_logistics`,或已调用发货通知接口提交 `express_company`、`tracking_no` 的订单不允许取消,返回 `422`。 +- 已到仓、鉴定中、补料中、已出报告、已完成等状态不允许取消,返回 `422`。 +- 找不到当前客户下的 `external_order_no` 时返回 `404`。 +- 重复取消已取消订单会返回成功,`data.cancelled` 为 `false`,不会重复写入取消时间线。 +- 取消接口不触发 webhook 回调;调用方以接口响应或订单查询结果为准。 + +## 8. 发货通知 第三方在商品实际寄出后,可以调用本接口通知平台写入寄入物流。创建订单接口中的 `inbound_logistics` 仍然可用;但如果订单创建和商品寄出不是同一时点,建议创建订单时只建单,实际寄出后再调用本接口提交快递信息。 @@ -515,7 +577,7 @@ GET /api/open/v1/orders?order_no=AXY20260508120000123 POST /api/open/v1/orders/shipping ``` -### 7.1 请求参数 +### 8.1 请求参数 | 字段 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | @@ -523,7 +585,7 @@ POST /api/open/v1/orders/shipping | `express_company` | string | 是 | 寄入快递公司 | | `tracking_no` | string | 是 | 寄入运单号 | -### 7.2 请求示例 +### 8.2 请求示例 ```json { @@ -533,7 +595,7 @@ POST /api/open/v1/orders/shipping } ``` -### 7.3 成功响应示例 +### 8.3 成功响应示例 ```json { @@ -568,14 +630,14 @@ POST /api/open/v1/orders/shipping } ``` -### 7.4 重复提交规则 +### 8.4 重复提交规则 - 相同 `external_order_no`、`express_company`、`tracking_no` 重复提交时,接口返回成功,`idempotent` 为 `true`,不会重复写物流节点或订单时间线。 - 同一订单在 `pending_shipping` 状态下提交不同快递公司或运单号时,会更新最新一条寄入物流,`updated` 为 `true`,并追加“已更新运单”时间线。 - 非 `pending_shipping` 状态的订单不允许提交或更新寄入运单,返回 `422`。 - 找不到当前客户下的 `external_order_no` 时返回 `404`。 -## 8. 订单状态 +## 9. 订单状态 常见订单状态如下,最终以接口返回的 `order_status` 和 `display_status` 为准。 @@ -589,8 +651,9 @@ POST /api/open/v1/orders/shipping | `return_shipped` | 物品已寄回 | 物品已退回寄出 | | `completed` | 已完成 | 订单完成 | | `pending_supplement` | 需要补充资料 | 需要补充资料 | +| `cancelled` | 已取消 | 订单已取消 | -## 9. Webhook 事件回调 +## 10. Webhook 事件回调 如需接收订单状态变化通知,第三方需向平台提供可公网访问的 `webhook_url`,并由平台开启回调。 @@ -611,7 +674,7 @@ POST /api/open/v1/orders/shipping | 总超时 | 6 秒 | | 成功判定 | HTTP 状态码为 2xx 且无网络错误 | -### 9.1 回调报文 +### 10.1 回调报文 ```json { @@ -630,7 +693,7 @@ POST /api/open/v1/orders/shipping } ``` -### 9.2 事件类型 +### 10.2 事件类型 | event_code | event_text | status_code | status_text | | --- | --- | --- | --- | @@ -643,7 +706,7 @@ POST /api/open/v1/orders/shipping | `completed` | 订单已完成 | `completed` | 已完成 | | `supplement_required` | 需要补充资料 | `pending_supplement` | 需要补充资料 | -### 9.3 回调接收建议 +### 10.3 回调接收建议 第三方接收 webhook 时建议: @@ -651,13 +714,14 @@ POST /api/open/v1/orders/shipping - 收到事件后返回 HTTP 2xx。 - 如需强一致的最新状态,可以收到 webhook 后再调用订单查询接口确认。 -## 10. 对接流程建议 +## 11. 对接流程建议 1. 平台分配 `app_key` 和 `app_secret`。 2. 第三方完成签名调试。 3. 第三方调用套餐获取接口,确认可用套餐和 `price_package_code`。 4. 第三方调用创建订单接口。最小只传 `external_order_no` 即可;如需要减少后续人工补录,建议同步传 `price_package_code`、`product_info`、`return_address`、`materials` 和 `inbound_logistics`。 -5. 如建单时未提供寄回地址,或后续需要变更,可调用寄回地址接口补录或更新 `return_address`。 -6. 商品实际寄出后,第三方调用发货通知接口提交 `express_company` 和 `tracking_no`。 -7. 第三方可通过查询接口主动查询订单状态,并核对 `return_address`、物流和报告结果。 -8. 如启用 webhook,平台在订单状态变化时主动通知第三方。 +5. 如订单尚未寄送且需要取消,可调用取消订单接口;已提交寄入物流后不再支持取消。 +6. 如建单时未提供寄回地址,或后续需要变更,可调用寄回地址接口补录或更新 `return_address`。 +7. 商品实际寄出后,第三方调用发货通知接口提交 `express_company` 和 `tracking_no`。 +8. 第三方可通过查询接口主动查询订单状态,并核对 `return_address`、物流和报告结果。 +9. 如启用 webhook,平台在订单状态变化时主动通知第三方。 diff --git a/server-api/app/controller/open/OrdersController.php b/server-api/app/controller/open/OrdersController.php index d6dd249..e9642ce 100644 --- a/server-api/app/controller/open/OrdersController.php +++ b/server-api/app/controller/open/OrdersController.php @@ -65,6 +65,34 @@ class OrdersController return api_success($result, '运单已提交'); } + public function cancel(Request $request) + { + try { + $auth = (new EnterpriseOpenApiAuthService())->authenticate($request); + } catch (\Throwable $e) { + return api_error($e->getMessage(), 401); + } + + $payload = json_decode($request->rawBody(), true); + if (!is_array($payload)) { + return api_error('请求体必须是合法 JSON 对象', 422); + } + + try { + $result = (new EnterpriseOrderService())->cancelOrder($auth['customer'], $payload); + } catch (\InvalidArgumentException $e) { + return api_error($e->getMessage(), 422); + } catch (\RuntimeException $e) { + return api_error($e->getMessage(), 404); + } catch (\Throwable $e) { + return api_error('订单取消失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + + return api_success($result, '订单已取消'); + } + public function saveReturnAddress(Request $request) { try { diff --git a/server-api/app/support/EnterpriseOrderService.php b/server-api/app/support/EnterpriseOrderService.php index 1bfa4fb..21eb09f 100644 --- a/server-api/app/support/EnterpriseOrderService.php +++ b/server-api/app/support/EnterpriseOrderService.php @@ -169,6 +169,93 @@ class EnterpriseOrderService return $this->buildOrderProgress((int)$customer['id'], $ref, (string)$customer['customer_code']); } + public function cancelOrder(array $customer, array $payload): array + { + $externalOrderNo = trim((string)($payload['external_order_no'] ?? '')); + if ($externalOrderNo === '') { + throw new \InvalidArgumentException('external_order_no 不能为空'); + } + + $cancelReason = trim((string)($payload['cancel_reason'] ?? '')); + if (mb_strlen($cancelReason, 'UTF-8') > 255) { + throw new \InvalidArgumentException('cancel_reason 不能超过 255 个字符'); + } + + $ref = Db::name('enterprise_customer_order_refs') + ->where('customer_id', (int)$customer['id']) + ->where('external_order_no', $externalOrderNo) + ->find(); + if (!$ref) { + throw new \RuntimeException('订单不存在'); + } + + $now = date('Y-m-d H:i:s'); + $nodeDesc = $cancelReason === '' + ? '第三方客户取消订单。' + : sprintf('第三方客户取消订单:%s', $cancelReason); + $cancelled = true; + + Db::startTrans(); + try { + $order = Db::name('orders')->where('id', (int)$ref['order_id'])->lock(true)->find(); + if (!$order) { + throw new \RuntimeException('订单不存在'); + } + + if ((string)$order['order_status'] === 'cancelled') { + $cancelled = false; + Db::commit(); + } else { + if ((string)$order['order_status'] !== 'pending_shipping') { + throw new \InvalidArgumentException('当前订单状态不可取消'); + } + + $inboundLogistics = Db::name('order_logistics') + ->where('order_id', (int)$order['id']) + ->where('logistics_type', 'send_to_center') + ->where('tracking_no', '<>', '') + ->lock(true) + ->find(); + if ($inboundLogistics) { + throw new \InvalidArgumentException('订单已提交寄入运单,当前不可取消'); + } + + Db::name('orders')->where('id', (int)$order['id'])->update([ + 'order_status' => 'cancelled', + 'display_status' => '已取消', + 'cancelled_at' => $now, + 'updated_at' => $now, + ]); + + Db::name('appraisal_tasks') + ->where('order_id', (int)$order['id']) + ->where('status', 'pending') + ->delete(); + + Db::name('order_timelines')->insert([ + 'order_id' => (int)$order['id'], + 'node_code' => 'cancelled', + 'node_text' => '订单已取消', + 'node_desc' => $nodeDesc, + 'operator_type' => 'system', + 'operator_id' => null, + 'occurred_at' => $now, + 'created_at' => $now, + ]); + + Db::commit(); + } + } catch (\Throwable $e) { + Db::rollback(); + throw $e; + } + + return [ + 'cancelled' => $cancelled, + 'order' => $this->buildOrderProgress((int)$customer['id'], $ref, (string)$customer['customer_code']), + ]; + } + public function submitShipping(array $customer, array $payload): array { $externalOrderNo = trim((string)($payload['external_order_no'] ?? '')); @@ -220,69 +307,103 @@ class EnterpriseOrderService $updated = (bool)$existing; $logisticsId = 0; $resetLogisticsSync = false; + $idempotentLogistics = null; Db::startTrans(); try { - if ($existing) { - $logisticsId = (int)$existing['id']; - $resetLogisticsSync = true; - Db::name('order_logistics')->where('id', $logisticsId)->update([ - 'logistics_type' => 'send_to_center', - 'express_company' => $expressCompany, - 'tracking_no' => $trackingNo, - 'tracking_status' => 'submitted', - 'latest_desc' => $latestDesc, - 'latest_time' => $now, - 'updated_at' => $now, - ]); - $nodeText = '已更新运单'; - $nodeDesc = sprintf('客户更新了寄送运单:%s %s', $expressCompany, $trackingNo); - } else { - $logisticsId = (int)Db::name('order_logistics')->insertGetId([ - 'order_id' => (int)$order['id'], - 'logistics_type' => 'send_to_center', - 'express_company' => $expressCompany, - 'tracking_no' => $trackingNo, - 'tracking_status' => 'submitted', - 'latest_desc' => $latestDesc, - 'latest_time' => $now, - 'created_at' => $now, - 'updated_at' => $now, - ]); - $nodeText = '已提交运单'; - $nodeDesc = sprintf('客户已提交寄送运单:%s %s', $expressCompany, $trackingNo); + $lockedOrder = Db::name('orders')->where('id', (int)$order['id'])->lock(true)->find(); + if (!$lockedOrder) { + throw new \RuntimeException('订单不存在'); + } + if ((string)$lockedOrder['order_status'] !== 'pending_shipping') { + throw new \InvalidArgumentException('当前订单状态不支持提交运单'); } - Db::name('order_logistics_nodes')->insert([ - 'logistics_id' => $logisticsId, - 'node_time' => $now, - 'node_desc' => $latestDesc, - 'node_location' => '第三方', - 'created_at' => $now, - ]); + $existing = Db::name('order_logistics') + ->where('order_id', (int)$order['id']) + ->where('logistics_type', 'send_to_center') + ->order('id', 'desc') + ->lock(true) + ->find(); + $sameLogistics = $existing + && (string)$existing['express_company'] === $expressCompany + && (string)$existing['tracking_no'] === $trackingNo; + if ($sameLogistics) { + $idempotentLogistics = $existing; + Db::commit(); + } else { + $updated = (bool)$existing; - Db::name('orders')->where('id', (int)$order['id'])->update([ - 'display_status' => '已提交运单', - 'updated_at' => $now, - ]); + if ($existing) { + $logisticsId = (int)$existing['id']; + $resetLogisticsSync = true; + Db::name('order_logistics')->where('id', $logisticsId)->update([ + 'logistics_type' => 'send_to_center', + 'express_company' => $expressCompany, + 'tracking_no' => $trackingNo, + 'tracking_status' => 'submitted', + 'latest_desc' => $latestDesc, + 'latest_time' => $now, + 'updated_at' => $now, + ]); + $nodeText = '已更新运单'; + $nodeDesc = sprintf('客户更新了寄送运单:%s %s', $expressCompany, $trackingNo); + } else { + $logisticsId = (int)Db::name('order_logistics')->insertGetId([ + 'order_id' => (int)$order['id'], + 'logistics_type' => 'send_to_center', + 'express_company' => $expressCompany, + 'tracking_no' => $trackingNo, + 'tracking_status' => 'submitted', + 'latest_desc' => $latestDesc, + 'latest_time' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ]); + $nodeText = '已提交运单'; + $nodeDesc = sprintf('客户已提交寄送运单:%s %s', $expressCompany, $trackingNo); + } - Db::name('order_timelines')->insert([ - 'order_id' => (int)$order['id'], - 'node_code' => 'tracking_submitted', - 'node_text' => $nodeText, - 'node_desc' => $nodeDesc, - 'operator_type' => 'system', - 'operator_id' => null, - 'occurred_at' => $now, - 'created_at' => $now, - ]); + Db::name('order_logistics_nodes')->insert([ + 'logistics_id' => $logisticsId, + 'node_time' => $now, + 'node_desc' => $latestDesc, + 'node_location' => '第三方', + 'created_at' => $now, + ]); - Db::commit(); + Db::name('orders')->where('id', (int)$order['id'])->update([ + 'display_status' => '已提交运单', + 'updated_at' => $now, + ]); + + Db::name('order_timelines')->insert([ + 'order_id' => (int)$order['id'], + 'node_code' => 'tracking_submitted', + 'node_text' => $nodeText, + 'node_desc' => $nodeDesc, + 'operator_type' => 'system', + 'operator_id' => null, + 'occurred_at' => $now, + 'created_at' => $now, + ]); + + Db::commit(); + } } catch (\Throwable $e) { Db::rollback(); throw $e; } + if ($idempotentLogistics) { + return [ + 'idempotent' => true, + 'updated' => false, + 'logistics' => $this->formatLogistics($idempotentLogistics), + 'order' => $this->buildOrderProgress((int)$customer['id'], $ref, (string)$customer['customer_code']), + ]; + } + $syncService = new OrderLogisticsSyncService(); if ($resetLogisticsSync) { Db::name('order_logistics_syncs')->where('logistics_id', $logisticsId)->delete(); diff --git a/server-api/config/route.php b/server-api/config/route.php index 2e12921..a2cbeeb 100644 --- a/server-api/config/route.php +++ b/server-api/config/route.php @@ -212,6 +212,7 @@ Route::post('/api/app/address/default', [AppAddressesController::class, 'setDefa Route::post('/api/app/address/delete', [AppAddressesController::class, 'delete']); Route::post('/api/open/v1/orders', [OpenOrdersController::class, 'create']); +Route::post('/api/open/v1/orders/cancel', [OpenOrdersController::class, 'cancel']); Route::post('/api/open/v1/orders/return-address', [OpenOrdersController::class, 'saveReturnAddress']); Route::post('/api/open/v1/orders/shipping', [OpenOrdersController::class, 'shipping']); Route::get('/api/open/v1/orders', [OpenOrdersController::class, 'detail']); diff --git a/server-api/tools/enterprise_order_cancel_mock_test.php b/server-api/tools/enterprise_order_cancel_mock_test.php new file mode 100644 index 0000000..a70785d --- /dev/null +++ b/server-api/tools/enterprise_order_cancel_mock_test.php @@ -0,0 +1,275 @@ +safeLoad(); + +use app\support\EnterpriseOrderService; +use support\think\Db; + +Db::setConfig(require dirname(__DIR__) . '/config/think-orm.php'); + +function assertTrue(bool $condition, string $message): void +{ + if (!$condition) { + throw new RuntimeException($message); + } +} + +function cleanupMockData(): void +{ + $orderIds = Db::name('orders')->whereLike('order_no', 'EOCMOCK%')->column('id'); + if ($orderIds) { + Db::name('enterprise_order_events')->whereIn('order_id', $orderIds)->delete(); + Db::name('enterprise_customer_order_refs')->whereIn('order_id', $orderIds)->delete(); + Db::name('message_logs')->where('biz_type', 'order')->whereIn('biz_id', $orderIds)->delete(); + Db::name('user_messages')->where('biz_type', 'order')->whereIn('biz_id', $orderIds)->delete(); + Db::name('appraisal_tasks')->whereIn('order_id', $orderIds)->delete(); + $logisticsIds = Db::name('order_logistics')->whereIn('order_id', $orderIds)->column('id'); + if ($logisticsIds) { + Db::name('order_logistics_nodes')->whereIn('logistics_id', $logisticsIds)->delete(); + } + Db::name('order_logistics')->whereIn('order_id', $orderIds)->delete(); + Db::name('order_timelines')->whereIn('order_id', $orderIds)->delete(); + Db::name('order_shipping_targets')->whereIn('order_id', $orderIds)->delete(); + Db::name('order_return_addresses')->whereIn('order_id', $orderIds)->delete(); + Db::name('order_extras')->whereIn('order_id', $orderIds)->delete(); + Db::name('order_products')->whereIn('order_id', $orderIds)->delete(); + Db::name('orders')->whereIn('id', $orderIds)->delete(); + } + + $customerIds = Db::name('enterprise_customers')->whereLike('customer_code', 'EOCMOCK%')->column('id'); + if ($customerIds) { + Db::name('enterprise_customer_apps')->whereIn('customer_id', $customerIds)->delete(); + Db::name('enterprise_order_events')->whereIn('customer_id', $customerIds)->delete(); + Db::name('enterprise_customer_order_refs')->whereIn('customer_id', $customerIds)->delete(); + Db::name('enterprise_customers')->whereIn('id', $customerIds)->delete(); + } + + $userIds = Db::name('users')->whereLike('mobile', '1399920%')->column('id'); + if ($userIds) { + Db::name('user_auths')->whereIn('user_id', $userIds)->delete(); + Db::name('user_addresses')->whereIn('user_id', $userIds)->delete(); + Db::name('users')->whereIn('id', $userIds)->delete(); + } +} + +function createMockCustomer(string $suffix): array +{ + $now = date('Y-m-d H:i:s'); + $userId = (int)Db::name('users')->insertGetId([ + 'nickname' => '第三方取消测试客户', + 'avatar' => '', + 'mobile' => '1399920' . str_pad((string)random_int(1, 9999), 4, '0', STR_PAD_LEFT), + 'password' => '', + 'status' => 'enabled', + 'last_login_at' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $customerId = (int)Db::name('enterprise_customers')->insertGetId([ + 'customer_code' => 'EOCMOCK' . $suffix, + 'customer_name' => '第三方取消测试客户', + 'contact_name' => '', + 'contact_mobile' => '', + 'contact_email' => '', + 'settlement_type' => 'monthly', + 'user_id' => $userId, + 'webhook_url' => '', + 'webhook_enabled' => 0, + 'status' => 'enabled', + 'remark' => '第三方取消订单 mock 测试', + 'created_at' => $now, + 'updated_at' => $now, + ]); + + return Db::name('enterprise_customers')->where('id', $customerId)->find(); +} + +function createMockOrder(array $customer, string $suffix, string $status = 'pending_shipping', bool $withInboundLogistics = false): array +{ + $now = date('Y-m-d H:i:s'); + $orderNo = 'EOCMOCK' . $suffix; + $orderId = (int)Db::name('orders')->insertGetId([ + 'order_no' => $orderNo, + 'appraisal_no' => 'EOC-MOCK-' . $suffix, + 'user_id' => (int)$customer['user_id'], + 'service_mode' => 'physical', + 'service_provider' => 'anxinyan', + 'price_package_id' => null, + 'price_package_name' => '测试套餐', + 'price_package_code' => 'mock_basic', + 'price_package_price' => 0, + 'payment_status' => 'paid', + 'order_status' => $status, + 'display_status' => $status === 'pending_shipping' ? '待寄送商品' : '鉴定中心已收货', + 'estimated_finish_time' => date('Y-m-d H:i:s', strtotime('+48 hours')), + 'source_channel' => 'enterprise_push', + 'source_customer_id' => (string)$customer['customer_code'], + 'pay_amount' => 0, + 'paid_at' => $now, + 'cancelled_at' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + Db::name('enterprise_customer_order_refs')->insert([ + 'customer_id' => (int)$customer['id'], + 'external_order_no' => 'EXT-' . $suffix, + 'order_id' => $orderId, + 'order_no' => $orderNo, + 'appraisal_no' => 'EOC-MOCK-' . $suffix, + 'payload_hash' => '', + 'created_at' => $now, + 'updated_at' => $now, + ]); + + Db::name('order_products')->insert([ + 'order_id' => $orderId, + 'category_id' => null, + 'category_name' => '测试品类', + 'brand_id' => null, + 'brand_name' => '测试品牌', + 'color' => '', + 'size_spec' => '', + 'serial_no' => '', + 'product_name' => '第三方取消测试商品', + 'product_cover' => '', + 'created_at' => $now, + 'updated_at' => $now, + ]); + + Db::name('order_extras')->insert([ + 'order_id' => $orderId, + 'purchase_channel' => '', + 'purchase_price' => 0, + 'purchase_date' => null, + 'usage_status' => '', + 'condition_desc' => '', + 'has_accessories' => 0, + 'accessories_json' => json_encode([], JSON_UNESCAPED_UNICODE), + 'remark' => '', + 'created_at' => $now, + 'updated_at' => $now, + ]); + + Db::name('appraisal_tasks')->insert([ + 'order_id' => $orderId, + 'task_stage' => 'first_review', + 'service_provider' => 'anxinyan', + 'status' => 'pending', + 'assignee_id' => null, + 'assignee_name' => '未分配', + 'started_at' => null, + 'submitted_at' => null, + 'sla_deadline' => date('Y-m-d H:i:s', strtotime('+48 hours')), + 'is_overtime' => 0, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + Db::name('order_timelines')->insert([ + 'order_id' => $orderId, + 'node_code' => 'pending_shipping', + 'node_text' => '待寄送商品', + 'node_desc' => '请将商品寄送至鉴定中心', + 'operator_type' => 'system', + 'operator_id' => null, + 'occurred_at' => $now, + 'created_at' => $now, + ]); + + if ($withInboundLogistics) { + $logisticsId = (int)Db::name('order_logistics')->insertGetId([ + 'order_id' => $orderId, + 'logistics_type' => 'send_to_center', + 'express_company' => '顺丰速运', + 'tracking_no' => 'SF' . $suffix, + 'tracking_status' => 'submitted', + 'latest_desc' => '客户已提交寄送运单', + 'latest_time' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + Db::name('order_logistics_nodes')->insert([ + 'logistics_id' => $logisticsId, + 'node_time' => $now, + 'node_desc' => '客户已提交寄送运单', + 'node_location' => '第三方', + 'created_at' => $now, + ]); + } + + return [ + 'order_id' => $orderId, + 'external_order_no' => 'EXT-' . $suffix, + ]; +} + +try { + cleanupMockData(); + + $customer = createMockCustomer('CUSTOMER'); + $otherCustomer = createMockCustomer('OTHER'); + $service = new EnterpriseOrderService(); + + $cancelTarget = createMockOrder($customer, 'SUCCESS'); + $cancel = $service->cancelOrder($customer, [ + 'external_order_no' => $cancelTarget['external_order_no'], + 'cancel_reason' => '客户取消鉴定', + ]); + assertTrue(($cancel['cancelled'] ?? null) === true, 'cancel should mark first request as cancelled'); + assertTrue(($cancel['order']['order_status'] ?? '') === 'cancelled', 'cancel should return cancelled order status'); + assertTrue((int)Db::name('appraisal_tasks')->where('order_id', (int)$cancelTarget['order_id'])->count() === 0, 'cancel should delete pending appraisal task'); + assertTrue((int)Db::name('order_timelines')->where('order_id', (int)$cancelTarget['order_id'])->where('node_code', 'cancelled')->count() === 1, 'cancel should write cancelled timeline'); + + $repeat = $service->cancelOrder($customer, [ + 'external_order_no' => $cancelTarget['external_order_no'], + ]); + assertTrue(($repeat['cancelled'] ?? null) === false, 'repeat cancel should be idempotent'); + assertTrue((int)Db::name('order_timelines')->where('order_id', (int)$cancelTarget['order_id'])->where('node_code', 'cancelled')->count() === 1, 'repeat cancel should not duplicate cancelled timeline'); + + $shippedTarget = createMockOrder($customer, 'SHIPPED', 'pending_shipping', true); + $shippingRejected = false; + try { + $service->cancelOrder($customer, [ + 'external_order_no' => $shippedTarget['external_order_no'], + ]); + } catch (InvalidArgumentException $e) { + $shippingRejected = str_contains($e->getMessage(), '寄入运单'); + } + assertTrue($shippingRejected, 'cancel should reject orders with inbound logistics'); + + $receivedTarget = createMockOrder($customer, 'RECEIVED', 'received'); + $statusRejected = false; + try { + $service->cancelOrder($customer, [ + 'external_order_no' => $receivedTarget['external_order_no'], + ]); + } catch (InvalidArgumentException $e) { + $statusRejected = str_contains($e->getMessage(), '状态不可取消'); + } + assertTrue($statusRejected, 'cancel should reject non pending_shipping order'); + + $crossCustomerRejected = false; + try { + $service->cancelOrder($otherCustomer, [ + 'external_order_no' => $receivedTarget['external_order_no'], + ]); + } catch (RuntimeException $e) { + $crossCustomerRejected = str_contains($e->getMessage(), '订单不存在'); + } + assertTrue($crossCustomerRejected, 'cancel should reject external order from another customer'); + + echo "ENTERPRISE_ORDER_CANCEL_MOCK_TEST_OK\n"; +} catch (Throwable $e) { + fwrite(STDERR, "ENTERPRISE_ORDER_CANCEL_MOCK_TEST_FAIL: " . $e->getMessage() . "\n"); + exit(1); +} finally { + cleanupMockData(); +}