feat: add enterprise order cancel open api
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
# 第三方订单对接文档
|
# 第三方订单对接文档
|
||||||
|
|
||||||
版本:v1.1
|
版本:v1.2
|
||||||
更新日期:2026-06-11
|
更新日期:2026-06-16
|
||||||
|
|
||||||
## 1. 对接说明
|
## 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` 仍然可用;但如果订单创建和商品寄出不是同一时点,建议创建订单时只建单,实际寄出后再调用本接口提交快递信息。
|
第三方在商品实际寄出后,可以调用本接口通知平台写入寄入物流。创建订单接口中的 `inbound_logistics` 仍然可用;但如果订单创建和商品寄出不是同一时点,建议创建订单时只建单,实际寄出后再调用本接口提交快递信息。
|
||||||
|
|
||||||
@@ -515,7 +577,7 @@ GET /api/open/v1/orders?order_no=AXY20260508120000123
|
|||||||
POST /api/open/v1/orders/shipping
|
POST /api/open/v1/orders/shipping
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7.1 请求参数
|
### 8.1 请求参数
|
||||||
|
|
||||||
| 字段 | 类型 | 必填 | 说明 |
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
@@ -523,7 +585,7 @@ POST /api/open/v1/orders/shipping
|
|||||||
| `express_company` | string | 是 | 寄入快递公司 |
|
| `express_company` | string | 是 | 寄入快递公司 |
|
||||||
| `tracking_no` | string | 是 | 寄入运单号 |
|
| `tracking_no` | string | 是 | 寄入运单号 |
|
||||||
|
|
||||||
### 7.2 请求示例
|
### 8.2 请求示例
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -533,7 +595,7 @@ POST /api/open/v1/orders/shipping
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7.3 成功响应示例
|
### 8.3 成功响应示例
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -568,14 +630,14 @@ POST /api/open/v1/orders/shipping
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7.4 重复提交规则
|
### 8.4 重复提交规则
|
||||||
|
|
||||||
- 相同 `external_order_no`、`express_company`、`tracking_no` 重复提交时,接口返回成功,`idempotent` 为 `true`,不会重复写物流节点或订单时间线。
|
- 相同 `external_order_no`、`express_company`、`tracking_no` 重复提交时,接口返回成功,`idempotent` 为 `true`,不会重复写物流节点或订单时间线。
|
||||||
- 同一订单在 `pending_shipping` 状态下提交不同快递公司或运单号时,会更新最新一条寄入物流,`updated` 为 `true`,并追加“已更新运单”时间线。
|
- 同一订单在 `pending_shipping` 状态下提交不同快递公司或运单号时,会更新最新一条寄入物流,`updated` 为 `true`,并追加“已更新运单”时间线。
|
||||||
- 非 `pending_shipping` 状态的订单不允许提交或更新寄入运单,返回 `422`。
|
- 非 `pending_shipping` 状态的订单不允许提交或更新寄入运单,返回 `422`。
|
||||||
- 找不到当前客户下的 `external_order_no` 时返回 `404`。
|
- 找不到当前客户下的 `external_order_no` 时返回 `404`。
|
||||||
|
|
||||||
## 8. 订单状态
|
## 9. 订单状态
|
||||||
|
|
||||||
常见订单状态如下,最终以接口返回的 `order_status` 和 `display_status` 为准。
|
常见订单状态如下,最终以接口返回的 `order_status` 和 `display_status` 为准。
|
||||||
|
|
||||||
@@ -589,8 +651,9 @@ POST /api/open/v1/orders/shipping
|
|||||||
| `return_shipped` | 物品已寄回 | 物品已退回寄出 |
|
| `return_shipped` | 物品已寄回 | 物品已退回寄出 |
|
||||||
| `completed` | 已完成 | 订单完成 |
|
| `completed` | 已完成 | 订单完成 |
|
||||||
| `pending_supplement` | 需要补充资料 | 需要补充资料 |
|
| `pending_supplement` | 需要补充资料 | 需要补充资料 |
|
||||||
|
| `cancelled` | 已取消 | 订单已取消 |
|
||||||
|
|
||||||
## 9. Webhook 事件回调
|
## 10. Webhook 事件回调
|
||||||
|
|
||||||
如需接收订单状态变化通知,第三方需向平台提供可公网访问的 `webhook_url`,并由平台开启回调。
|
如需接收订单状态变化通知,第三方需向平台提供可公网访问的 `webhook_url`,并由平台开启回调。
|
||||||
|
|
||||||
@@ -611,7 +674,7 @@ POST /api/open/v1/orders/shipping
|
|||||||
| 总超时 | 6 秒 |
|
| 总超时 | 6 秒 |
|
||||||
| 成功判定 | HTTP 状态码为 2xx 且无网络错误 |
|
| 成功判定 | HTTP 状态码为 2xx 且无网络错误 |
|
||||||
|
|
||||||
### 9.1 回调报文
|
### 10.1 回调报文
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -630,7 +693,7 @@ POST /api/open/v1/orders/shipping
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 9.2 事件类型
|
### 10.2 事件类型
|
||||||
|
|
||||||
| event_code | event_text | status_code | status_text |
|
| event_code | event_text | status_code | status_text |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
@@ -643,7 +706,7 @@ POST /api/open/v1/orders/shipping
|
|||||||
| `completed` | 订单已完成 | `completed` | 已完成 |
|
| `completed` | 订单已完成 | `completed` | 已完成 |
|
||||||
| `supplement_required` | 需要补充资料 | `pending_supplement` | 需要补充资料 |
|
| `supplement_required` | 需要补充资料 | `pending_supplement` | 需要补充资料 |
|
||||||
|
|
||||||
### 9.3 回调接收建议
|
### 10.3 回调接收建议
|
||||||
|
|
||||||
第三方接收 webhook 时建议:
|
第三方接收 webhook 时建议:
|
||||||
|
|
||||||
@@ -651,13 +714,14 @@ POST /api/open/v1/orders/shipping
|
|||||||
- 收到事件后返回 HTTP 2xx。
|
- 收到事件后返回 HTTP 2xx。
|
||||||
- 如需强一致的最新状态,可以收到 webhook 后再调用订单查询接口确认。
|
- 如需强一致的最新状态,可以收到 webhook 后再调用订单查询接口确认。
|
||||||
|
|
||||||
## 10. 对接流程建议
|
## 11. 对接流程建议
|
||||||
|
|
||||||
1. 平台分配 `app_key` 和 `app_secret`。
|
1. 平台分配 `app_key` 和 `app_secret`。
|
||||||
2. 第三方完成签名调试。
|
2. 第三方完成签名调试。
|
||||||
3. 第三方调用套餐获取接口,确认可用套餐和 `price_package_code`。
|
3. 第三方调用套餐获取接口,确认可用套餐和 `price_package_code`。
|
||||||
4. 第三方调用创建订单接口。最小只传 `external_order_no` 即可;如需要减少后续人工补录,建议同步传 `price_package_code`、`product_info`、`return_address`、`materials` 和 `inbound_logistics`。
|
4. 第三方调用创建订单接口。最小只传 `external_order_no` 即可;如需要减少后续人工补录,建议同步传 `price_package_code`、`product_info`、`return_address`、`materials` 和 `inbound_logistics`。
|
||||||
5. 如建单时未提供寄回地址,或后续需要变更,可调用寄回地址接口补录或更新 `return_address`。
|
5. 如订单尚未寄送且需要取消,可调用取消订单接口;已提交寄入物流后不再支持取消。
|
||||||
6. 商品实际寄出后,第三方调用发货通知接口提交 `express_company` 和 `tracking_no`。
|
6. 如建单时未提供寄回地址,或后续需要变更,可调用寄回地址接口补录或更新 `return_address`。
|
||||||
7. 第三方可通过查询接口主动查询订单状态,并核对 `return_address`、物流和报告结果。
|
7. 商品实际寄出后,第三方调用发货通知接口提交 `express_company` 和 `tracking_no`。
|
||||||
8. 如启用 webhook,平台在订单状态变化时主动通知第三方。
|
8. 第三方可通过查询接口主动查询订单状态,并核对 `return_address`、物流和报告结果。
|
||||||
|
9. 如启用 webhook,平台在订单状态变化时主动通知第三方。
|
||||||
|
|||||||
@@ -65,6 +65,34 @@ class OrdersController
|
|||||||
return api_success($result, '运单已提交');
|
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)
|
public function saveReturnAddress(Request $request)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -169,6 +169,93 @@ class EnterpriseOrderService
|
|||||||
return $this->buildOrderProgress((int)$customer['id'], $ref, (string)$customer['customer_code']);
|
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
|
public function submitShipping(array $customer, array $payload): array
|
||||||
{
|
{
|
||||||
$externalOrderNo = trim((string)($payload['external_order_no'] ?? ''));
|
$externalOrderNo = trim((string)($payload['external_order_no'] ?? ''));
|
||||||
@@ -220,69 +307,103 @@ class EnterpriseOrderService
|
|||||||
$updated = (bool)$existing;
|
$updated = (bool)$existing;
|
||||||
$logisticsId = 0;
|
$logisticsId = 0;
|
||||||
$resetLogisticsSync = false;
|
$resetLogisticsSync = false;
|
||||||
|
$idempotentLogistics = null;
|
||||||
|
|
||||||
Db::startTrans();
|
Db::startTrans();
|
||||||
try {
|
try {
|
||||||
if ($existing) {
|
$lockedOrder = Db::name('orders')->where('id', (int)$order['id'])->lock(true)->find();
|
||||||
$logisticsId = (int)$existing['id'];
|
if (!$lockedOrder) {
|
||||||
$resetLogisticsSync = true;
|
throw new \RuntimeException('订单不存在');
|
||||||
Db::name('order_logistics')->where('id', $logisticsId)->update([
|
}
|
||||||
'logistics_type' => 'send_to_center',
|
if ((string)$lockedOrder['order_status'] !== 'pending_shipping') {
|
||||||
'express_company' => $expressCompany,
|
throw new \InvalidArgumentException('当前订单状态不支持提交运单');
|
||||||
'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_logistics_nodes')->insert([
|
$existing = Db::name('order_logistics')
|
||||||
'logistics_id' => $logisticsId,
|
->where('order_id', (int)$order['id'])
|
||||||
'node_time' => $now,
|
->where('logistics_type', 'send_to_center')
|
||||||
'node_desc' => $latestDesc,
|
->order('id', 'desc')
|
||||||
'node_location' => '第三方',
|
->lock(true)
|
||||||
'created_at' => $now,
|
->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([
|
if ($existing) {
|
||||||
'display_status' => '已提交运单',
|
$logisticsId = (int)$existing['id'];
|
||||||
'updated_at' => $now,
|
$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([
|
Db::name('order_logistics_nodes')->insert([
|
||||||
'order_id' => (int)$order['id'],
|
'logistics_id' => $logisticsId,
|
||||||
'node_code' => 'tracking_submitted',
|
'node_time' => $now,
|
||||||
'node_text' => $nodeText,
|
'node_desc' => $latestDesc,
|
||||||
'node_desc' => $nodeDesc,
|
'node_location' => '第三方',
|
||||||
'operator_type' => 'system',
|
'created_at' => $now,
|
||||||
'operator_id' => null,
|
]);
|
||||||
'occurred_at' => $now,
|
|
||||||
'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) {
|
} catch (\Throwable $e) {
|
||||||
Db::rollback();
|
Db::rollback();
|
||||||
throw $e;
|
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();
|
$syncService = new OrderLogisticsSyncService();
|
||||||
if ($resetLogisticsSync) {
|
if ($resetLogisticsSync) {
|
||||||
Db::name('order_logistics_syncs')->where('logistics_id', $logisticsId)->delete();
|
Db::name('order_logistics_syncs')->where('logistics_id', $logisticsId)->delete();
|
||||||
|
|||||||
@@ -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/app/address/delete', [AppAddressesController::class, 'delete']);
|
||||||
|
|
||||||
Route::post('/api/open/v1/orders', [OpenOrdersController::class, 'create']);
|
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/return-address', [OpenOrdersController::class, 'saveReturnAddress']);
|
||||||
Route::post('/api/open/v1/orders/shipping', [OpenOrdersController::class, 'shipping']);
|
Route::post('/api/open/v1/orders/shipping', [OpenOrdersController::class, 'shipping']);
|
||||||
Route::get('/api/open/v1/orders', [OpenOrdersController::class, 'detail']);
|
Route::get('/api/open/v1/orders', [OpenOrdersController::class, 'detail']);
|
||||||
|
|||||||
275
server-api/tools/enterprise_order_cancel_mock_test.php
Normal file
275
server-api/tools/enterprise_order_cancel_mock_test.php
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
$dotenv = Dotenv\Dotenv::createImmutable(dirname(__DIR__));
|
||||||
|
$dotenv->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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user