feat: add enterprise order cancel open api

This commit is contained in:
wushumin
2026-06-16 16:35:55 +08:00
parent 9be60fbe17
commit a982ee2b60
5 changed files with 556 additions and 67 deletions

View File

@@ -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 {

View File

@@ -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();

View File

@@ -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']);

View 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();
}