diff --git a/backend/app/Controller/Api/V1/OrderItemController.php b/backend/app/Controller/Api/V1/OrderItemController.php new file mode 100644 index 0000000..6f274a1 --- /dev/null +++ b/backend/app/Controller/Api/V1/OrderItemController.php @@ -0,0 +1,178 @@ + 'exact', + 'platform_id' => 'exact', + 'store_id' => 'exact', + 'platform_order_id' => 'exact', + 'platform_product_id' => 'exact', + 'product_sku' => 'exact', + 'created_date_from' => 'date_from', + 'created_date_to' => 'date_to', + ]; + } + + protected function getDefaultSort(): string + { + return 'created_date'; + } + + /** + * 订单项列表 + */ + #[OA\Get( + path: '/order-items', + summary: '订单项列表', + description: '获取订单项列表,支持分页和多维度筛选。OrderItem 无 raw/hash 字段,仅提供 Normal 接口。', + security: [['bearerAuth' => []]], + tags: ['Order Items'], + parameters: [ + new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)), + new OA\Parameter(name: 'per_page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15, maximum: 100)), + new OA\Parameter(name: 'company_id', in: 'query', required: false, description: '公司 ID 精确筛选', schema: new OA\Schema(type: 'integer')), + new OA\Parameter(name: 'platform_id', in: 'query', required: false, description: '平台 ID 精确筛选', schema: new OA\Schema(type: 'integer')), + new OA\Parameter(name: 'store_id', in: 'query', required: false, description: '店铺 ID 精确筛选', schema: new OA\Schema(type: 'integer')), + new OA\Parameter(name: 'platform_order_id', in: 'query', required: false, description: '平台订单 ID 精确筛选', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'platform_product_id', in: 'query', required: false, description: '平台商品 ID 精确筛选', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'product_sku', in: 'query', required: false, description: 'SKU 编码精确筛选', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'created_date_from', in: 'query', required: false, description: '创建时间起始(含)', schema: new OA\Schema(type: 'string', format: 'date', example: '2026-01-01')), + new OA\Parameter(name: 'created_date_to', in: 'query', required: false, description: '创建时间截止(含)', schema: new OA\Schema(type: 'string', format: 'date', example: '2026-12-31')), + ], + responses: [ + new OA\Response( + response: 200, + description: '获取成功', + content: new OA\JsonContent(properties: [ + new OA\Property(property: 'code', type: 'integer', example: 0), + new OA\Property(property: 'message', type: 'string', example: '获取成功'), + new OA\Property(property: 'data', properties: [ + new OA\Property(property: 'items', type: 'array', items: new OA\Items(ref: '#/components/schemas/OrderItem')), + new OA\Property(property: 'total', type: 'integer', example: 100), + new OA\Property(property: 'page', type: 'integer', example: 1), + new OA\Property(property: 'per_page', type: 'integer', example: 15), + ], type: 'object'), + ]) + ), + new OA\Response(response: 401, description: '未认证', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + new OA\Response(response: 403, description: '无权限', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + ] + )] + #[RequestMapping(path: "", methods: "GET")] + public function index(): ResponseInterface|array + { + return parent::index(); + } + + /** + * 订单项详情(含父订单摘要) + */ + #[OA\Get( + path: '/order-items/{id}', + summary: '订单项详情', + description: '获取订单项详情,返回完整字段和关联的父订单摘要信息。', + security: [['bearerAuth' => []]], + tags: ['Order Items'], + parameters: [ + new OA\Parameter(name: 'id', in: 'path', required: true, description: '订单项 ID', schema: new OA\Schema(type: 'integer')), + ], + responses: [ + new OA\Response( + response: 200, + description: '获取成功', + content: new OA\JsonContent(properties: [ + new OA\Property(property: 'code', type: 'integer', example: 0), + new OA\Property(property: 'message', type: 'string', example: '获取成功'), + new OA\Property(property: 'data', type: 'object', allOf: [ + new OA\Schema(ref: '#/components/schemas/OrderItem'), + new OA\Schema(properties: [ + new OA\Property(property: 'parent_order', type: 'object', nullable: true, description: '父订单摘要信息'), + ]), + ]), + ]) + ), + new OA\Response(response: 401, description: '未认证', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + new OA\Response(response: 403, description: '无权限', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + new OA\Response(response: 404, description: '数据不存在', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + ] + )] + #[RequestMapping(path: "{id}", methods: "GET")] + public function show(int $id): ResponseInterface|array + { + $result = parent::show($id); + + // 404 响应直接返回 + if ($result instanceof ResponseInterface) { + return $result; + } + + // 追加父订单摘要 + $order_item = $result['data']; + $result['data'] = $order_item->toArray(); + + $parent_order = $order_item->getParentOrder(); + $result['data']['parent_order'] = $parent_order ? [ + 'id' => $parent_order->id, + 'platform_order_id' => $parent_order->platform_order_id, + 'order_status_id' => $parent_order->order_status_id, + 'total_amount' => $parent_order->total_amount, + 'total_paid' => $parent_order->total_paid, + 'created_date' => $parent_order->created_date?->toDateTimeString(), + 'paid_date' => $parent_order->paid_date?->toDateTimeString(), + ] : null; + + return $result; + } +} diff --git a/backend/app/Model/OrderItem.php b/backend/app/Model/OrderItem.php index 0f740b5..2965858 100644 --- a/backend/app/Model/OrderItem.php +++ b/backend/app/Model/OrderItem.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace App\Model; +use OpenApi\Attributes as OA; + /** * @property int $id 主键 * @property int $company_id 公司 ID 与 Tools 保持一致 @@ -27,6 +29,32 @@ namespace App\Model; * @property \Carbon\Carbon $updated_at * @mixin \App_Model_OrderItem */ +#[OA\Schema( + schema: 'OrderItem', + type: 'object', + properties: [ + new OA\Property(property: 'id', type: 'integer', example: 1), + new OA\Property(property: 'company_id', type: 'integer', example: 1), + new OA\Property(property: 'platform_id', type: 'integer', example: 2), + new OA\Property(property: 'store_id', type: 'integer', example: 100), + new OA\Property(property: 'order_id', type: 'integer', example: 1000), + new OA\Property(property: 'platform_order_id', type: 'string', example: 'ORD-20260101-001'), + new OA\Property(property: 'sub_order_id', type: 'string', nullable: true, example: 'SUB-001'), + new OA\Property(property: 'sub_order_type_id', type: 'integer', example: 1), + new OA\Property(property: 'product_id', type: 'integer', example: 500), + new OA\Property(property: 'platform_product_id', type: 'string', example: 'PROD-001'), + new OA\Property(property: 'product_sku', type: 'string', nullable: true, example: 'SKU-ABC-001'), + new OA\Property(property: 'product_barcode', type: 'string', nullable: true, example: '6901234567890'), + new OA\Property(property: 'unit_price', type: 'number', format: 'decimal', example: 49.99), + new OA\Property(property: 'quantity', type: 'integer', example: 2), + new OA\Property(property: 'discount', type: 'number', format: 'decimal', example: 5.00), + new OA\Property(property: 'total', type: 'number', format: 'decimal', example: 94.98), + new OA\Property(property: 'created_date', type: 'string', format: 'date-time'), + new OA\Property(property: 'ext', type: 'object', nullable: true, description: '扩展字段'), + new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), + new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'), + ] +)] class OrderItem extends Model { /** diff --git a/backend/test/Cases/Integration/Order/OrderItemControllerTest.php b/backend/test/Cases/Integration/Order/OrderItemControllerTest.php new file mode 100644 index 0000000..56ec977 --- /dev/null +++ b/backend/test/Cases/Integration/Order/OrderItemControllerTest.php @@ -0,0 +1,416 @@ +fetchAdminRole(); + $user = $this->fetchUser(static function ($query) use ($admin_role): void { + $query->where('status', 1)->where('role_id', $admin_role->id); + }); + if (!$user) { + $this->markTestSkipped('没有可用的 administrator 用户'); + } + + $auth = make(AuthManager::class); + return $auth->guard('jwt')->login($user); + } + + protected function fetchAdminRole(): Role + { + if (\Swoole\Coroutine::getCid() > 0) { + return Role::query()->where('name', 'administrator')->firstOrFail(); + } + + $role = null; + \Swoole\Coroutine\run(static function () use (&$role): void { + $role = Role::query()->where('name', 'administrator')->firstOrFail(); + }); + return $role; + } + + protected function fetchUser(?callable $callback = null): ?User + { + if (\Swoole\Coroutine::getCid() > 0) { + $query = User::query(); + if ($callback !== null) { + $callback($query); + } + return $query->first(); + } + + $user = null; + \Swoole\Coroutine\run(static function () use ($callback, &$user): void { + $query = User::query(); + if ($callback !== null) { + $callback($query); + } + $user = $query->first(); + }); + return $user; + } + + protected function authHeaders(): array + { + return ['Authorization' => 'Bearer ' . $this->getAdminToken()]; + } + + protected function hasOrderItemData(): bool + { + if (\Swoole\Coroutine::getCid() > 0) { + return OrderItem::query()->exists(); + } + + $exists = false; + \Swoole\Coroutine\run(static function () use (&$exists): void { + $exists = OrderItem::query()->exists(); + }); + return $exists; + } + + protected function getFirstOrderItemId(): ?int + { + if (\Swoole\Coroutine::getCid() > 0) { + return OrderItem::query()->value('id'); + } + + $id = null; + \Swoole\Coroutine\run(static function () use (&$id): void { + $id = OrderItem::query()->value('id'); + }); + return $id; + } + + // ========== 列表接口 ========== + + public function test_list_returns_paginated_data(): void + { + $response = $this->get('/api/v1/order-items', [], $this->authHeaders()); + + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + $response->assertJsonStructure([ + 'code', + 'message', + 'data' => [ + 'items', + 'total', + 'page', + 'per_page', + ], + ]); + } + + public function test_list_respects_per_page(): void + { + $response = $this->get('/api/v1/order-items', ['per_page' => 5], $this->authHeaders()); + + $response->assertStatus(200); + $response->assertJsonPath('data.per_page', 5); + } + + public function test_list_per_page_max_100(): void + { + $response = $this->get('/api/v1/order-items', ['per_page' => 999], $this->authHeaders()); + + $response->assertStatus(200); + $response->assertJsonPath('data.per_page', 100); + } + + public function test_list_contains_expected_fields(): void + { + if (!$this->hasOrderItemData()) { + $this->markTestSkipped('没有订单项数据'); + } + + $response = $this->get('/api/v1/order-items', [], $this->authHeaders()); + + $response->assertStatus(200); + $body = json_decode($response->getContent(), true); + $first_item = $body['data']['items'][0]; + + $expected_keys = [ + 'id', 'company_id', 'platform_id', 'store_id', 'order_id', + 'platform_order_id', 'sub_order_id', 'platform_product_id', + 'product_sku', 'unit_price', 'quantity', 'discount', 'total', + 'created_date', + ]; + + foreach ($expected_keys as $key) { + $this->assertArrayHasKey($key, $first_item, "列表缺少字段: {$key}"); + } + } + + public function test_list_excludes_detail_only_fields(): void + { + if (!$this->hasOrderItemData()) { + $this->markTestSkipped('没有订单项数据'); + } + + $response = $this->get('/api/v1/order-items', [], $this->authHeaders()); + + $response->assertStatus(200); + $body = json_decode($response->getContent(), true); + $first_item = $body['data']['items'][0]; + + // 列表不含仅详情字段 + $this->assertArrayNotHasKey('ext', $first_item); + $this->assertArrayNotHasKey('product_barcode', $first_item); + $this->assertArrayNotHasKey('sub_order_type_id', $first_item); + } + + // ========== 列表筛选 ========== + + public function test_list_filter_by_company_id(): void + { + if (!$this->hasOrderItemData()) { + $this->markTestSkipped('没有订单项数据'); + } + + $company_id = null; + if (\Swoole\Coroutine::getCid() > 0) { + $company_id = OrderItem::query()->value('company_id'); + } else { + \Swoole\Coroutine\run(static function () use (&$company_id): void { + $company_id = OrderItem::query()->value('company_id'); + }); + } + + $response = $this->get('/api/v1/order-items', ['company_id' => $company_id], $this->authHeaders()); + + $response->assertStatus(200); + $body = json_decode($response->getContent(), true); + + foreach ($body['data']['items'] as $item) { + $this->assertSame($company_id, $item['company_id']); + } + } + + public function test_list_filter_by_platform_order_id(): void + { + if (!$this->hasOrderItemData()) { + $this->markTestSkipped('没有订单项数据'); + } + + $platform_order_id = null; + if (\Swoole\Coroutine::getCid() > 0) { + $platform_order_id = OrderItem::query()->value('platform_order_id'); + } else { + \Swoole\Coroutine\run(static function () use (&$platform_order_id): void { + $platform_order_id = OrderItem::query()->value('platform_order_id'); + }); + } + + $response = $this->get('/api/v1/order-items', ['platform_order_id' => $platform_order_id], $this->authHeaders()); + + $response->assertStatus(200); + $body = json_decode($response->getContent(), true); + + foreach ($body['data']['items'] as $item) { + $this->assertSame($platform_order_id, $item['platform_order_id']); + } + } + + public function test_list_filter_by_platform_product_id(): void + { + if (!$this->hasOrderItemData()) { + $this->markTestSkipped('没有订单项数据'); + } + + $platform_product_id = null; + if (\Swoole\Coroutine::getCid() > 0) { + $platform_product_id = OrderItem::query()->value('platform_product_id'); + } else { + \Swoole\Coroutine\run(static function () use (&$platform_product_id): void { + $platform_product_id = OrderItem::query()->value('platform_product_id'); + }); + } + + $response = $this->get('/api/v1/order-items', ['platform_product_id' => $platform_product_id], $this->authHeaders()); + + $response->assertStatus(200); + $body = json_decode($response->getContent(), true); + + foreach ($body['data']['items'] as $item) { + $this->assertSame($platform_product_id, $item['platform_product_id']); + } + } + + public function test_list_filter_by_product_sku(): void + { + if (!$this->hasOrderItemData()) { + $this->markTestSkipped('没有订单项数据'); + } + + $product_sku = null; + if (\Swoole\Coroutine::getCid() > 0) { + $product_sku = OrderItem::query()->whereNotNull('product_sku')->where('product_sku', '!=', '')->value('product_sku'); + } else { + \Swoole\Coroutine\run(static function () use (&$product_sku): void { + $product_sku = OrderItem::query()->whereNotNull('product_sku')->where('product_sku', '!=', '')->value('product_sku'); + }); + } + + if (!$product_sku) { + $this->markTestSkipped('没有有 SKU 的订单项数据'); + } + + $response = $this->get('/api/v1/order-items', ['product_sku' => $product_sku], $this->authHeaders()); + + $response->assertStatus(200); + $body = json_decode($response->getContent(), true); + + foreach ($body['data']['items'] as $item) { + $this->assertSame($product_sku, $item['product_sku']); + } + } + + public function test_list_filter_by_created_date_range(): void + { + if (!$this->hasOrderItemData()) { + $this->markTestSkipped('没有订单项数据'); + } + + $response = $this->get('/api/v1/order-items', [ + 'created_date_from' => '2020-01-01', + 'created_date_to' => '2099-12-31', + ], $this->authHeaders()); + + $response->assertStatus(200); + $body = json_decode($response->getContent(), true); + + $this->assertGreaterThanOrEqual(0, $body['data']['total']); + } + + // ========== 详情接口 ========== + + public function test_detail_returns_order_item(): void + { + $id = $this->getFirstOrderItemId(); + if (!$id) { + $this->markTestSkipped('没有订单项数据'); + } + + $response = $this->get('/api/v1/order-items/' . $id, [], $this->authHeaders()); + + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + $response->assertJsonPath('data.id', $id); + } + + public function test_detail_contains_parent_order(): void + { + $id = $this->getFirstOrderItemId(); + if (!$id) { + $this->markTestSkipped('没有订单项数据'); + } + + $response = $this->get('/api/v1/order-items/' . $id, [], $this->authHeaders()); + + $response->assertStatus(200); + $body = json_decode($response->getContent(), true); + + $this->assertArrayHasKey('parent_order', $body['data'], '订单项详情应包含 parent_order'); + } + + public function test_detail_parent_order_has_expected_fields(): void + { + $id = $this->getFirstOrderItemId(); + if (!$id) { + $this->markTestSkipped('没有订单项数据'); + } + + $response = $this->get('/api/v1/order-items/' . $id, [], $this->authHeaders()); + + $response->assertStatus(200); + $body = json_decode($response->getContent(), true); + + $parent_order = $body['data']['parent_order']; + if ($parent_order === null) { + $this->markTestSkipped('该订单项无关联父订单'); + } + + $expected_keys = ['id', 'platform_order_id', 'order_status_id', 'total_amount', 'total_paid', 'created_date', 'paid_date']; + foreach ($expected_keys as $key) { + $this->assertArrayHasKey($key, $parent_order, "父订单摘要缺少字段: {$key}"); + } + } + + public function test_detail_contains_ext(): void + { + $id = $this->getFirstOrderItemId(); + if (!$id) { + $this->markTestSkipped('没有订单项数据'); + } + + $response = $this->get('/api/v1/order-items/' . $id, [], $this->authHeaders()); + + $response->assertStatus(200); + $body = json_decode($response->getContent(), true); + + $this->assertArrayHasKey('ext', $body['data']); + } + + public function test_detail_contains_full_fields(): void + { + $id = $this->getFirstOrderItemId(); + if (!$id) { + $this->markTestSkipped('没有订单项数据'); + } + + $response = $this->get('/api/v1/order-items/' . $id, [], $this->authHeaders()); + + $response->assertStatus(200); + $body = json_decode($response->getContent(), true); + + // 详情应包含完整字段(列表中不含的额外字段) + $this->assertArrayHasKey('product_barcode', $body['data']); + $this->assertArrayHasKey('sub_order_type_id', $body['data']); + $this->assertArrayHasKey('created_at', $body['data']); + $this->assertArrayHasKey('updated_at', $body['data']); + } + + public function test_detail_not_found_returns_404(): void + { + $response = $this->get('/api/v1/order-items/999999999', [], $this->authHeaders()); + + $response->assertStatus(404); + $response->assertJsonPath('code', 404); + } + + // ========== 认证拦截 ========== + + public function test_list_without_token_returns_401(): void + { + $response = $this->get('/api/v1/order-items'); + + $response->assertStatus(401); + } + + public function test_detail_without_token_returns_401(): void + { + $response = $this->get('/api/v1/order-items/1'); + + $response->assertStatus(401); + } +}