update sku mapping
This commit is contained in:
@@ -48,6 +48,7 @@ class SkuMappingController extends AbstractController
|
|||||||
new OA\Parameter(name: 'store_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: 'origin_sku', in: 'query', required: false, description: '原始 SKU 模糊搜索', schema: new OA\Schema(type: 'string')),
|
new OA\Parameter(name: 'origin_sku', in: 'query', required: false, description: '原始 SKU 模糊搜索', schema: new OA\Schema(type: 'string')),
|
||||||
new OA\Parameter(name: 'platform_outer_sku', in: 'query', required: false, description: '平台侧 SKU 模糊搜索', schema: new OA\Schema(type: 'string')),
|
new OA\Parameter(name: 'platform_outer_sku', in: 'query', required: false, description: '平台侧 SKU 模糊搜索', schema: new OA\Schema(type: 'string')),
|
||||||
|
new OA\Parameter(name: 'origin_sku_id', in: 'query', required: false, description: '内部 SKU ID 精确筛选', schema: new OA\Schema(type: 'integer')),
|
||||||
new OA\Parameter(name: 'enabled', in: 'query', required: false, description: '启用状态', schema: new OA\Schema(type: 'boolean')),
|
new OA\Parameter(name: 'enabled', in: 'query', required: false, description: '启用状态', schema: new OA\Schema(type: 'boolean')),
|
||||||
],
|
],
|
||||||
responses: [
|
responses: [
|
||||||
@@ -85,6 +86,7 @@ class SkuMappingController extends AbstractController
|
|||||||
'company_id' => 'exact',
|
'company_id' => 'exact',
|
||||||
'platform_id' => 'exact',
|
'platform_id' => 'exact',
|
||||||
'store_id' => 'exact',
|
'store_id' => 'exact',
|
||||||
|
'origin_sku_id' => 'exact',
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($filters as $field => $type) {
|
foreach ($filters as $field => $type) {
|
||||||
@@ -335,7 +337,7 @@ class SkuMappingController extends AbstractController
|
|||||||
requestBody: new OA\RequestBody(
|
requestBody: new OA\RequestBody(
|
||||||
required: true,
|
required: true,
|
||||||
content: new OA\JsonContent(
|
content: new OA\JsonContent(
|
||||||
required: ['company_id', 'platform_id', 'origin_sku', 'platform_product_id'],
|
required: ['company_id', 'platform_id', 'origin_sku'],
|
||||||
properties: [
|
properties: [
|
||||||
new OA\Property(property: 'company_id', type: 'integer', example: 3),
|
new OA\Property(property: 'company_id', type: 'integer', example: 3),
|
||||||
new OA\Property(property: 'platform_id', type: 'integer', example: 1),
|
new OA\Property(property: 'platform_id', type: 'integer', example: 1),
|
||||||
@@ -369,7 +371,7 @@ class SkuMappingController extends AbstractController
|
|||||||
{
|
{
|
||||||
$data = $this->request->all();
|
$data = $this->request->all();
|
||||||
|
|
||||||
$required_fields = ['company_id', 'platform_id', 'origin_sku', 'platform_product_id'];
|
$required_fields = ['company_id', 'platform_id', 'origin_sku'];
|
||||||
foreach ($required_fields as $field) {
|
foreach ($required_fields as $field) {
|
||||||
if (!isset($data[$field]) || $data[$field] === '') {
|
if (!isset($data[$field]) || $data[$field] === '') {
|
||||||
return $this->response->json([
|
return $this->response->json([
|
||||||
@@ -385,7 +387,7 @@ class SkuMappingController extends AbstractController
|
|||||||
'store_id' => isset($data['store_id']) ? (int) $data['store_id'] : null,
|
'store_id' => isset($data['store_id']) ? (int) $data['store_id'] : null,
|
||||||
'origin_sku' => $data['origin_sku'],
|
'origin_sku' => $data['origin_sku'],
|
||||||
'origin_sku_id' => isset($data['origin_sku_id']) ? (int) $data['origin_sku_id'] : null,
|
'origin_sku_id' => isset($data['origin_sku_id']) ? (int) $data['origin_sku_id'] : null,
|
||||||
'platform_product_id' => $data['platform_product_id'],
|
'platform_product_id' => $data['platform_product_id'] ?? null,
|
||||||
'platform_outer_sku' => $data['platform_outer_sku'] ?? null,
|
'platform_outer_sku' => $data['platform_outer_sku'] ?? null,
|
||||||
'generation_strategy' => $data['generation_strategy'] ?? null,
|
'generation_strategy' => $data['generation_strategy'] ?? null,
|
||||||
'warehouse_id' => isset($data['warehouse_id']) ? (int) $data['warehouse_id'] : null,
|
'warehouse_id' => isset($data['warehouse_id']) ? (int) $data['warehouse_id'] : null,
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Hyperf\Database\Schema\Schema;
|
||||||
|
use Hyperf\Database\Schema\Blueprint;
|
||||||
|
use Hyperf\Database\Migrations\Migration;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('skus_mapping', function (Blueprint $table) {
|
||||||
|
$table->text('platform_product_id')->nullable()->change();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 删除原有唯一约束(如果存在)并创建部分唯一索引
|
||||||
|
$connection = Schema::getConnection();
|
||||||
|
$connection->statement('DROP INDEX IF EXISTS uk_platform_product');
|
||||||
|
$connection->statement(
|
||||||
|
'CREATE UNIQUE INDEX uk_platform_product ON skus_mapping (platform_id, platform_product_id) WHERE platform_product_id IS NOT NULL'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
$connection = Schema::getConnection();
|
||||||
|
$connection->statement('DROP INDEX IF EXISTS uk_platform_product');
|
||||||
|
$connection->statement(
|
||||||
|
'CREATE UNIQUE INDEX uk_platform_product ON skus_mapping (platform_id, platform_product_id)'
|
||||||
|
);
|
||||||
|
|
||||||
|
Schema::table('skus_mapping', function (Blueprint $table) {
|
||||||
|
$table->text('platform_product_id')->nullable(false)->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace HyperfTest\Cases\Integration\Sku;
|
||||||
|
|
||||||
|
use App\Model\SkuMapping;
|
||||||
|
use App\Model\SkuOrigin;
|
||||||
|
use HyperfTest\TestCase;
|
||||||
|
use HyperfTest\Traits\AuthenticatedTestTrait;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SkuMappingController 集成测试
|
||||||
|
*
|
||||||
|
* 覆盖列表筛选、origin_sku_id 过滤、创建校验(platform_product_id 可选)、CRUD、认证拦截
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
* @coversNothing
|
||||||
|
*/
|
||||||
|
class SkuMappingControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
use AuthenticatedTestTrait;
|
||||||
|
|
||||||
|
protected function hasData(): bool
|
||||||
|
{
|
||||||
|
return $this->runInCoroutine(static function (): bool {
|
||||||
|
return SkuMapping::query()->exists();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getFirstRecord(): ?array
|
||||||
|
{
|
||||||
|
return $this->runInCoroutine(static function (): ?array {
|
||||||
|
$record = SkuMapping::query()->first();
|
||||||
|
return $record ? $record->toArray() : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getFirstOrigin(): ?array
|
||||||
|
{
|
||||||
|
return $this->runInCoroutine(static function (): ?array {
|
||||||
|
$record = SkuOrigin::query()->first();
|
||||||
|
return $record ? $record->toArray() : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 列表接口 ==========
|
||||||
|
|
||||||
|
public function test_list_returns_paginated_data(): void
|
||||||
|
{
|
||||||
|
$response = $this->get('/api/v1/sku-mappings', [], $this->authHeaders());
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertJsonPath('code', 0);
|
||||||
|
$response->assertJsonStructure([
|
||||||
|
'code',
|
||||||
|
'message',
|
||||||
|
'data' => [
|
||||||
|
'items',
|
||||||
|
'total',
|
||||||
|
'page',
|
||||||
|
'per_page',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_list_filter_by_origin_sku_id(): void
|
||||||
|
{
|
||||||
|
if (!$this->hasData()) {
|
||||||
|
$this->markTestSkipped('没有 SKU Mapping 数据');
|
||||||
|
}
|
||||||
|
|
||||||
|
$first = $this->getFirstRecord();
|
||||||
|
if (!$first['origin_sku_id']) {
|
||||||
|
$this->markTestSkipped('首条记录无 origin_sku_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->get('/api/v1/sku-mappings', [
|
||||||
|
'origin_sku_id' => $first['origin_sku_id'],
|
||||||
|
], $this->authHeaders());
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$body = json_decode($response->getContent(), true);
|
||||||
|
|
||||||
|
foreach ($body['data']['items'] as $item) {
|
||||||
|
$this->assertSame($first['origin_sku_id'], $item['origin_sku_id']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_list_filter_by_company_and_platform(): void
|
||||||
|
{
|
||||||
|
if (!$this->hasData()) {
|
||||||
|
$this->markTestSkipped('没有 SKU Mapping 数据');
|
||||||
|
}
|
||||||
|
|
||||||
|
$first = $this->getFirstRecord();
|
||||||
|
|
||||||
|
$response = $this->get('/api/v1/sku-mappings', [
|
||||||
|
'company_id' => $first['company_id'],
|
||||||
|
'platform_id' => $first['platform_id'],
|
||||||
|
], $this->authHeaders());
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$body = json_decode($response->getContent(), true);
|
||||||
|
|
||||||
|
foreach ($body['data']['items'] as $item) {
|
||||||
|
$this->assertSame($first['company_id'], $item['company_id']);
|
||||||
|
$this->assertSame($first['platform_id'], $item['platform_id']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_list_filter_by_enabled(): void
|
||||||
|
{
|
||||||
|
$response = $this->get('/api/v1/sku-mappings', ['enabled' => 'true'], $this->authHeaders());
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$body = json_decode($response->getContent(), true);
|
||||||
|
|
||||||
|
foreach ($body['data']['items'] as $item) {
|
||||||
|
$this->assertTrue($item['enabled']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 创建校验 ==========
|
||||||
|
|
||||||
|
public function test_create_without_platform_product_id_succeeds(): void
|
||||||
|
{
|
||||||
|
$origin = $this->getFirstOrigin();
|
||||||
|
if (!$origin) {
|
||||||
|
$this->markTestSkipped('没有 SKU Origin 数据');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->post('/api/v1/sku-mappings', [
|
||||||
|
'company_id' => $origin['company_id'],
|
||||||
|
'platform_id' => 1,
|
||||||
|
'origin_sku' => $origin['sku'],
|
||||||
|
'origin_sku_id' => $origin['id'],
|
||||||
|
'platform_outer_sku' => 'TEST-' . uniqid(),
|
||||||
|
], $this->authHeaders());
|
||||||
|
|
||||||
|
$response->assertStatus(201);
|
||||||
|
$response->assertJsonPath('code', 0);
|
||||||
|
|
||||||
|
// 清理测试数据
|
||||||
|
$body = json_decode($response->getContent(), true);
|
||||||
|
if (isset($body['data']['id'])) {
|
||||||
|
$this->runInCoroutine(static function () use ($body): void {
|
||||||
|
SkuMapping::query()->where('id', $body['data']['id'])->delete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_with_platform_product_id_succeeds(): void
|
||||||
|
{
|
||||||
|
$origin = $this->getFirstOrigin();
|
||||||
|
if (!$origin) {
|
||||||
|
$this->markTestSkipped('没有 SKU Origin 数据');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->post('/api/v1/sku-mappings', [
|
||||||
|
'company_id' => $origin['company_id'],
|
||||||
|
'platform_id' => 1,
|
||||||
|
'origin_sku' => $origin['sku'],
|
||||||
|
'origin_sku_id' => $origin['id'],
|
||||||
|
'platform_product_id' => 'PROD-TEST-' . uniqid(),
|
||||||
|
'platform_outer_sku' => 'TEST-' . uniqid(),
|
||||||
|
], $this->authHeaders());
|
||||||
|
|
||||||
|
$response->assertStatus(201);
|
||||||
|
$response->assertJsonPath('code', 0);
|
||||||
|
|
||||||
|
// 清理测试数据
|
||||||
|
$body = json_decode($response->getContent(), true);
|
||||||
|
if (isset($body['data']['id'])) {
|
||||||
|
$this->runInCoroutine(static function () use ($body): void {
|
||||||
|
SkuMapping::query()->where('id', $body['data']['id'])->delete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_requires_company_id(): void
|
||||||
|
{
|
||||||
|
$response = $this->post('/api/v1/sku-mappings', [
|
||||||
|
'platform_id' => 1,
|
||||||
|
'origin_sku' => 'TEST-SKU',
|
||||||
|
], $this->authHeaders());
|
||||||
|
|
||||||
|
$response->assertStatus(422);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_requires_platform_id(): void
|
||||||
|
{
|
||||||
|
$response = $this->post('/api/v1/sku-mappings', [
|
||||||
|
'company_id' => 1,
|
||||||
|
'origin_sku' => 'TEST-SKU',
|
||||||
|
], $this->authHeaders());
|
||||||
|
|
||||||
|
$response->assertStatus(422);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_requires_origin_sku(): void
|
||||||
|
{
|
||||||
|
$response = $this->post('/api/v1/sku-mappings', [
|
||||||
|
'company_id' => 1,
|
||||||
|
'platform_id' => 1,
|
||||||
|
], $this->authHeaders());
|
||||||
|
|
||||||
|
$response->assertStatus(422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 详情接口 ==========
|
||||||
|
|
||||||
|
public function test_detail_not_found_returns_404(): void
|
||||||
|
{
|
||||||
|
$response = $this->get('/api/v1/sku-mappings/999999999', [], $this->authHeaders());
|
||||||
|
|
||||||
|
$response->assertStatus(404);
|
||||||
|
$response->assertJsonPath('code', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 更新 & 删除 ==========
|
||||||
|
|
||||||
|
public function test_update_mapping(): void
|
||||||
|
{
|
||||||
|
if (!$this->hasData()) {
|
||||||
|
$this->markTestSkipped('没有 SKU Mapping 数据');
|
||||||
|
}
|
||||||
|
|
||||||
|
$first = $this->getFirstRecord();
|
||||||
|
|
||||||
|
$response = $this->put('/api/v1/sku-mappings/' . $first['id'], [
|
||||||
|
'note' => 'integration-test-update-' . uniqid(),
|
||||||
|
], $this->authHeaders());
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertJsonPath('code', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_delete_mapping(): void
|
||||||
|
{
|
||||||
|
// 创建一条临时记录然后删除
|
||||||
|
$origin = $this->getFirstOrigin();
|
||||||
|
if (!$origin) {
|
||||||
|
$this->markTestSkipped('没有 SKU Origin 数据');
|
||||||
|
}
|
||||||
|
|
||||||
|
$createResponse = $this->post('/api/v1/sku-mappings', [
|
||||||
|
'company_id' => $origin['company_id'],
|
||||||
|
'platform_id' => 1,
|
||||||
|
'origin_sku' => $origin['sku'],
|
||||||
|
'origin_sku_id' => $origin['id'],
|
||||||
|
'platform_outer_sku' => 'DELETE-TEST-' . uniqid(),
|
||||||
|
], $this->authHeaders());
|
||||||
|
|
||||||
|
$createResponse->assertStatus(201);
|
||||||
|
$body = json_decode($createResponse->getContent(), true);
|
||||||
|
$newId = $body['data']['id'];
|
||||||
|
|
||||||
|
$deleteResponse = $this->delete('/api/v1/sku-mappings/' . $newId, [], $this->authHeaders());
|
||||||
|
|
||||||
|
$deleteResponse->assertStatus(200);
|
||||||
|
$deleteResponse->assertJsonPath('code', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 认证拦截 ==========
|
||||||
|
|
||||||
|
public function test_list_without_token_returns_401(): void
|
||||||
|
{
|
||||||
|
$response = $this->get('/api/v1/sku-mappings');
|
||||||
|
|
||||||
|
$response->assertStatus(401);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace HyperfTest\Cases\Integration\Sku;
|
||||||
|
|
||||||
|
use App\Model\SkuOrigin;
|
||||||
|
use HyperfTest\TestCase;
|
||||||
|
use HyperfTest\Traits\AuthenticatedTestTrait;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SkuOriginController 集成测试
|
||||||
|
*
|
||||||
|
* 覆盖列表分页、筛选、详情、创建校验、认证拦截
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
* @coversNothing
|
||||||
|
*/
|
||||||
|
class SkuOriginControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
use AuthenticatedTestTrait;
|
||||||
|
|
||||||
|
protected function hasData(): bool
|
||||||
|
{
|
||||||
|
return $this->runInCoroutine(static function (): bool {
|
||||||
|
return SkuOrigin::query()->exists();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getFirstRecord(): ?array
|
||||||
|
{
|
||||||
|
return $this->runInCoroutine(static function (): ?array {
|
||||||
|
$record = SkuOrigin::query()->first();
|
||||||
|
return $record ? $record->toArray() : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 列表接口 ==========
|
||||||
|
|
||||||
|
public function test_list_returns_paginated_data(): void
|
||||||
|
{
|
||||||
|
$response = $this->get('/api/v1/sku-origins', [], $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/sku-origins', ['per_page' => 5], $this->authHeaders());
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertJsonPath('data.per_page', 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_list_filter_by_company_id(): void
|
||||||
|
{
|
||||||
|
if (!$this->hasData()) {
|
||||||
|
$this->markTestSkipped('没有 SKU Origin 数据');
|
||||||
|
}
|
||||||
|
|
||||||
|
$company_id = $this->runInCoroutine(static function (): mixed {
|
||||||
|
return SkuOrigin::query()->value('company_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
$response = $this->get('/api/v1/sku-origins', ['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_sku(): void
|
||||||
|
{
|
||||||
|
if (!$this->hasData()) {
|
||||||
|
$this->markTestSkipped('没有 SKU Origin 数据');
|
||||||
|
}
|
||||||
|
|
||||||
|
$first = $this->getFirstRecord();
|
||||||
|
$sku_fragment = substr($first['sku'], 0, 3);
|
||||||
|
|
||||||
|
$response = $this->get('/api/v1/sku-origins', ['sku' => $sku_fragment], $this->authHeaders());
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$body = json_decode($response->getContent(), true);
|
||||||
|
|
||||||
|
foreach ($body['data']['items'] as $item) {
|
||||||
|
$this->assertStringContainsStringIgnoringCase($sku_fragment, $item['sku']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_list_filter_by_barcode(): void
|
||||||
|
{
|
||||||
|
if (!$this->hasData()) {
|
||||||
|
$this->markTestSkipped('没有 SKU Origin 数据');
|
||||||
|
}
|
||||||
|
|
||||||
|
$first = $this->getFirstRecord();
|
||||||
|
if (empty($first['barcode'])) {
|
||||||
|
$this->markTestSkipped('首条记录无 barcode');
|
||||||
|
}
|
||||||
|
|
||||||
|
$barcode_fragment = substr($first['barcode'], 0, 4);
|
||||||
|
$response = $this->get('/api/v1/sku-origins', ['barcode' => $barcode_fragment], $this->authHeaders());
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$body = json_decode($response->getContent(), true);
|
||||||
|
|
||||||
|
foreach ($body['data']['items'] as $item) {
|
||||||
|
$this->assertStringContainsStringIgnoringCase($barcode_fragment, $item['barcode']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 详情接口 ==========
|
||||||
|
|
||||||
|
public function test_detail_returns_sku_origin(): void
|
||||||
|
{
|
||||||
|
$first = $this->getFirstRecord();
|
||||||
|
if (!$first) {
|
||||||
|
$this->markTestSkipped('没有 SKU Origin 数据');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->get('/api/v1/sku-origins/' . $first['id'], [], $this->authHeaders());
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertJsonPath('code', 0);
|
||||||
|
$response->assertJsonPath('data.id', $first['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_detail_not_found_returns_404(): void
|
||||||
|
{
|
||||||
|
$response = $this->get('/api/v1/sku-origins/999999999', [], $this->authHeaders());
|
||||||
|
|
||||||
|
$response->assertStatus(404);
|
||||||
|
$response->assertJsonPath('code', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 创建校验 ==========
|
||||||
|
|
||||||
|
public function test_create_requires_barcode(): void
|
||||||
|
{
|
||||||
|
$response = $this->post('/api/v1/sku-origins', [
|
||||||
|
'company_id' => 1,
|
||||||
|
'sku' => 'TEST-NO-BARCODE-' . uniqid(),
|
||||||
|
'name' => 'Test Product',
|
||||||
|
], $this->authHeaders());
|
||||||
|
|
||||||
|
$response->assertStatus(422);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_requires_sku(): void
|
||||||
|
{
|
||||||
|
$response = $this->post('/api/v1/sku-origins', [
|
||||||
|
'company_id' => 1,
|
||||||
|
'barcode' => '6901234567890',
|
||||||
|
'name' => 'Test Product',
|
||||||
|
], $this->authHeaders());
|
||||||
|
|
||||||
|
$response->assertStatus(422);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_duplicate_company_sku_returns_422(): void
|
||||||
|
{
|
||||||
|
$first = $this->getFirstRecord();
|
||||||
|
if (!$first) {
|
||||||
|
$this->markTestSkipped('没有 SKU Origin 数据');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->post('/api/v1/sku-origins', [
|
||||||
|
'company_id' => $first['company_id'],
|
||||||
|
'sku' => $first['sku'],
|
||||||
|
'barcode' => '0000000000000',
|
||||||
|
'name' => 'Duplicate Test',
|
||||||
|
], $this->authHeaders());
|
||||||
|
|
||||||
|
$response->assertStatus(422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 认证拦截 ==========
|
||||||
|
|
||||||
|
public function test_list_without_token_returns_401(): void
|
||||||
|
{
|
||||||
|
$response = $this->get('/api/v1/sku-origins');
|
||||||
|
|
||||||
|
$response->assertStatus(401);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: (query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/utils/request', () => ({
|
||||||
|
api: {
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
put: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { api } from '@/utils/request'
|
||||||
|
|
||||||
|
const mockMappings = {
|
||||||
|
items: [
|
||||||
|
{ id: 1, company_id: 3, platform_id: 1, store_id: null, origin_sku: '0032', origin_sku_id: 1, platform_outer_sku: 'AMZ-0032', platform_product_id: 'ITEM-001', enabled: true, note: null, created_at: '2026-04-01T00:00:00', updated_at: '2026-04-01T00:00:00' },
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
per_page: 15,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockCompanies = [{ id: 3, name: 'Acme Corp', label: 'Acme' }]
|
||||||
|
const mockPlatforms = [{ id: 1, name: 'Amazon', label: null }]
|
||||||
|
const mockStores = [{ id: 101, company_id: 3, platform_id: 1, name: 'Store A', label: null }]
|
||||||
|
const mockSkuOrigins = {
|
||||||
|
items: [{ id: 1, company_id: 3, sku: '0032', name: 'Test SKU' }],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
per_page: 100,
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupApiMocks() {
|
||||||
|
vi.mocked(api.get).mockImplementation((url: string) => {
|
||||||
|
if (url === '/api/v1/sku-mappings') return Promise.resolve(mockMappings) as never
|
||||||
|
if (url === '/api/v1/companies') return Promise.resolve(mockCompanies) as never
|
||||||
|
if (url === '/api/v1/platforms') return Promise.resolve(mockPlatforms) as never
|
||||||
|
if (url === '/api/v1/stores') return Promise.resolve(mockStores) as never
|
||||||
|
if (url === '/api/v1/sku-origins') return Promise.resolve(mockSkuOrigins) as never
|
||||||
|
return Promise.resolve({ items: [], total: 0, page: 1, per_page: 15 }) as never
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SkuMappingsPage', () => {
|
||||||
|
let wrapper: ReturnType<typeof mount>
|
||||||
|
|
||||||
|
const stubs = {
|
||||||
|
SearchOutlined: { template: '<span />' },
|
||||||
|
ReloadOutlined: { template: '<span />' },
|
||||||
|
PlusOutlined: { template: '<span />' },
|
||||||
|
EditOutlined: { template: '<span />' },
|
||||||
|
DeleteOutlined: { template: '<span />' },
|
||||||
|
ThunderboltOutlined: { template: '<span />' },
|
||||||
|
QuestionCircleOutlined: { template: '<span />' },
|
||||||
|
CascadeFilter: { template: '<div class="cascade-stub" />' },
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper?.unmount()
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
async function mountPage() {
|
||||||
|
setupApiMocks()
|
||||||
|
|
||||||
|
const { default: SkuMappingsPage } = await import('../index.vue')
|
||||||
|
|
||||||
|
wrapper = mount(SkuMappingsPage, {
|
||||||
|
attachTo: document.body,
|
||||||
|
global: { stubs },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
await nextTick()
|
||||||
|
}
|
||||||
|
|
||||||
|
it('P1: renders page title as SKU 连接管理', async () => {
|
||||||
|
await mountPage()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('SKU 连接管理')
|
||||||
|
expect(wrapper.text()).not.toContain('SKU 映射管理')
|
||||||
|
}, 15000)
|
||||||
|
|
||||||
|
it('P2: calls fetchItems on mount', async () => {
|
||||||
|
await mountPage()
|
||||||
|
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/api/v1/sku-mappings', expect.any(Object))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('P3: renders table with mapping data', async () => {
|
||||||
|
await mountPage()
|
||||||
|
|
||||||
|
const html = wrapper.html()
|
||||||
|
expect(html).toContain('AMZ-0032')
|
||||||
|
expect(html).toContain('ITEM-001')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('P4: create button uses 连接 terminology', async () => {
|
||||||
|
await mountPage()
|
||||||
|
|
||||||
|
const buttons = wrapper.findAll('.ant-btn')
|
||||||
|
const buttonTexts = buttons.map((b) => b.text())
|
||||||
|
expect(buttonTexts.some((t) => t.includes('新建连接'))).toBe(true)
|
||||||
|
expect(buttonTexts.some((t) => t.includes('新建映射'))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('P5: platform_product_id has optional placeholder', async () => {
|
||||||
|
await mountPage()
|
||||||
|
|
||||||
|
// Open create modal
|
||||||
|
const newBtn = wrapper.findAll('.ant-btn').find((b) => b.text().includes('新建连接'))
|
||||||
|
await newBtn?.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
// Modal teleports to document.body
|
||||||
|
const bodyHtml = document.body.innerHTML
|
||||||
|
expect(bodyHtml).toContain('可选')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('P6: search and reset buttons work', async () => {
|
||||||
|
await mountPage()
|
||||||
|
|
||||||
|
const buttons = wrapper.findAll('.ant-btn')
|
||||||
|
const buttonTexts = buttons.map((b) => b.text())
|
||||||
|
expect(buttonTexts.some((t) => t.includes('搜索'))).toBe(true)
|
||||||
|
expect(buttonTexts.some((t) => t.includes('重置'))).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -33,7 +33,7 @@ const columns = [
|
|||||||
|
|
||||||
// Modal state
|
// Modal state
|
||||||
const modalVisible = ref(false)
|
const modalVisible = ref(false)
|
||||||
const modalTitle = ref('新建映射')
|
const modalTitle = ref('新建连接')
|
||||||
const editingId = ref<number | null>(null)
|
const editingId = ref<number | null>(null)
|
||||||
const formRef = ref()
|
const formRef = ref()
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
@@ -87,15 +87,41 @@ const filteredStoreOptions = computed(() => {
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
// Filtered SKU origins by company
|
// Remote search for SKU origins
|
||||||
const filteredSkuOrigins = computed(() => {
|
const skuSearchOptions = ref<{ value: number; label: string }[]>([])
|
||||||
if (!form.company_id) return store.skuOrigins
|
const skuSearching = ref(false)
|
||||||
return store.skuOrigins.filter((s) => s.company_id === form.company_id)
|
let skuSearchTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
})
|
|
||||||
|
function handleSkuSearch(keyword: string) {
|
||||||
|
if (skuSearchTimer) clearTimeout(skuSearchTimer)
|
||||||
|
|
||||||
|
if (!form.company_id) {
|
||||||
|
skuSearchOptions.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (keyword.length < 3) {
|
||||||
|
skuSearchOptions.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
skuSearching.value = true
|
||||||
|
skuSearchTimer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const items = await store.searchSkuOrigins(form.company_id!, keyword)
|
||||||
|
skuSearchOptions.value = items.map((s) => ({
|
||||||
|
value: s.id,
|
||||||
|
label: `${s.sku} - ${s.name}`,
|
||||||
|
}))
|
||||||
|
} catch {
|
||||||
|
skuSearchOptions.value = []
|
||||||
|
} finally {
|
||||||
|
skuSearching.value = false
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
store.loadLookups()
|
store.loadLookups()
|
||||||
store.loadSkuOrigins()
|
|
||||||
store.fetchItems()
|
store.fetchItems()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -123,13 +149,14 @@ function formatTime(time: string | null) {
|
|||||||
function openCreate() {
|
function openCreate() {
|
||||||
Object.assign(form, defaultForm())
|
Object.assign(form, defaultForm())
|
||||||
editingId.value = null
|
editingId.value = null
|
||||||
modalTitle.value = '新建映射'
|
modalTitle.value = '新建连接'
|
||||||
|
skuSearchOptions.value = []
|
||||||
modalVisible.value = true
|
modalVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(record: SkuMappingForm & { id: number }) {
|
function openEdit(record: SkuMappingForm & { id: number }) {
|
||||||
editingId.value = record.id
|
editingId.value = record.id
|
||||||
modalTitle.value = '编辑映射'
|
modalTitle.value = '编辑连接'
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
company_id: record.company_id,
|
company_id: record.company_id,
|
||||||
platform_id: record.platform_id,
|
platform_id: record.platform_id,
|
||||||
@@ -143,9 +170,17 @@ function openEdit(record: SkuMappingForm & { id: number }) {
|
|||||||
enabled: record.enabled ?? true,
|
enabled: record.enabled ?? true,
|
||||||
note: record.note || '',
|
note: record.note || '',
|
||||||
})
|
})
|
||||||
|
// 从已有列表数据预填 origin SKU 选项,无需额外 API 调用
|
||||||
|
skuSearchOptions.value = record.origin_sku_id
|
||||||
|
? [{ value: record.origin_sku_id as number, label: record.origin_sku || '' }]
|
||||||
|
: []
|
||||||
modalVisible.value = true
|
modalVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleModalClose() {
|
||||||
|
skuSearchOptions.value = []
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
try {
|
try {
|
||||||
await formRef.value.validate()
|
await formRef.value.validate()
|
||||||
@@ -160,6 +195,7 @@ async function handleSubmit() {
|
|||||||
await store.createItem({ ...form })
|
await store.createItem({ ...form })
|
||||||
}
|
}
|
||||||
modalVisible.value = false
|
modalVisible.value = false
|
||||||
|
handleModalClose()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : '操作失败'
|
const msg = err instanceof Error ? err.message : '操作失败'
|
||||||
message.error(msg)
|
message.error(msg)
|
||||||
@@ -168,10 +204,14 @@ async function handleSubmit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDelete(record: { id: number; origin_sku: string; platform_outer_sku: string | null }) {
|
function handleDelete(record: {
|
||||||
|
id: number
|
||||||
|
origin_sku: string
|
||||||
|
platform_outer_sku: string | null
|
||||||
|
}) {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: '确认删除',
|
title: '确认删除',
|
||||||
content: `确定删除映射「${record.origin_sku} → ${record.platform_outer_sku || '-'}」?`,
|
content: `确定删除连接「${record.origin_sku} → ${record.platform_outer_sku || '-'}」?`,
|
||||||
okText: '删除',
|
okText: '删除',
|
||||||
okType: 'danger',
|
okType: 'danger',
|
||||||
cancelText: '取消',
|
cancelText: '取消',
|
||||||
@@ -196,7 +236,7 @@ function openGenerate() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
genForm.strategy = 'prefix'
|
genForm.strategy = 'prefix'
|
||||||
genForm.prefix = ''
|
genForm.prefix = form.company_id ? `C${String(form.company_id).padStart(4, '0')}` : ''
|
||||||
genForm.random_length = 4
|
genForm.random_length = 4
|
||||||
genForm.manual_value = ''
|
genForm.manual_value = ''
|
||||||
genResult.value = ''
|
genResult.value = ''
|
||||||
@@ -244,18 +284,20 @@ function applyGenerated() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch company change to reload SKU origins
|
// Watch company change to reset SKU search
|
||||||
watch(
|
watch(
|
||||||
() => form.company_id,
|
() => form.company_id,
|
||||||
(val) => {
|
() => {
|
||||||
store.loadSkuOrigins(val)
|
form.origin_sku_id = undefined
|
||||||
|
form.origin_sku = ''
|
||||||
|
skuSearchOptions.value = []
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-semibold mb-4">SKU 映射管理</h2>
|
<h2 class="text-xl font-semibold mb-4">SKU 连接管理</h2>
|
||||||
|
|
||||||
<!-- Filter area -->
|
<!-- Filter area -->
|
||||||
<a-card class="mb-6">
|
<a-card class="mb-6">
|
||||||
@@ -311,7 +353,7 @@ watch(
|
|||||||
<span class="text-gray-500">共 {{ store.pagination.total }} 条记录</span>
|
<span class="text-gray-500">共 {{ store.pagination.total }} 条记录</span>
|
||||||
<a-button type="primary" @click="openCreate">
|
<a-button type="primary" @click="openCreate">
|
||||||
<template #icon><PlusOutlined /></template>
|
<template #icon><PlusOutlined /></template>
|
||||||
新建映射
|
新建连接
|
||||||
</a-button>
|
</a-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -378,29 +420,27 @@ watch(
|
|||||||
:title="modalTitle"
|
:title="modalTitle"
|
||||||
:confirm-loading="saving"
|
:confirm-loading="saving"
|
||||||
@ok="handleSubmit"
|
@ok="handleSubmit"
|
||||||
@cancel="modalVisible = false"
|
@cancel="modalVisible = false; handleModalClose()"
|
||||||
:width="640"
|
:width="640"
|
||||||
>
|
>
|
||||||
<a-form
|
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical" class="mt-4">
|
||||||
ref="formRef"
|
|
||||||
:model="form"
|
|
||||||
:rules="rules"
|
|
||||||
layout="vertical"
|
|
||||||
class="mt-4"
|
|
||||||
>
|
|
||||||
<a-row :gutter="16">
|
<a-row :gutter="16">
|
||||||
<a-col :span="12">
|
<a-col :span="12">
|
||||||
<a-form-item label="公司" name="company_id">
|
<a-form-item label="公司" name="company_id">
|
||||||
<a-select
|
<a-select
|
||||||
v-model:value="form.company_id"
|
v-model:value="form.company_id"
|
||||||
placeholder="请选择公司"
|
placeholder="请选择公司"
|
||||||
:options="store.companies.map(c => ({
|
:options="
|
||||||
|
store.companies.map((c) => ({
|
||||||
value: c.id,
|
value: c.id,
|
||||||
label: (c.label && c.label !== 'null') ? c.label : c.name
|
label: c.label && c.label !== 'null' ? c.label : c.name,
|
||||||
}))"
|
}))
|
||||||
|
"
|
||||||
show-search
|
show-search
|
||||||
:filter-option="(input: string, option: { label: string }) =>
|
:filter-option="
|
||||||
option.label.toLowerCase().includes(input.toLowerCase())"
|
(input: string, option: { label: string }) =>
|
||||||
|
option.label.toLowerCase().includes(input.toLowerCase())
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
@@ -409,13 +449,17 @@ watch(
|
|||||||
<a-select
|
<a-select
|
||||||
v-model:value="form.platform_id"
|
v-model:value="form.platform_id"
|
||||||
placeholder="请选择平台"
|
placeholder="请选择平台"
|
||||||
:options="store.platforms.map(p => ({
|
:options="
|
||||||
|
store.platforms.map((p) => ({
|
||||||
value: p.id,
|
value: p.id,
|
||||||
label: (p.label && p.label !== 'null') ? p.label : (p.name || `平台 #${p.id}`)
|
label: p.label && p.label !== 'null' ? p.label : p.name || `平台 #${p.id}`,
|
||||||
}))"
|
}))
|
||||||
|
"
|
||||||
show-search
|
show-search
|
||||||
:filter-option="(input: string, option: { label: string }) =>
|
:filter-option="
|
||||||
option.label.toLowerCase().includes(input.toLowerCase())"
|
(input: string, option: { label: string }) =>
|
||||||
|
option.label.toLowerCase().includes(input.toLowerCase())
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
@@ -430,8 +474,10 @@ watch(
|
|||||||
:options="filteredStoreOptions"
|
:options="filteredStoreOptions"
|
||||||
allow-clear
|
allow-clear
|
||||||
show-search
|
show-search
|
||||||
:filter-option="(input: string, option: { label: string }) =>
|
:filter-option="
|
||||||
option.label.toLowerCase().includes(input.toLowerCase())"
|
(input: string, option: { label: string }) =>
|
||||||
|
option.label.toLowerCase().includes(input.toLowerCase())
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
@@ -439,37 +485,35 @@ watch(
|
|||||||
<a-form-item label="内部 SKU" name="origin_sku">
|
<a-form-item label="内部 SKU" name="origin_sku">
|
||||||
<a-select
|
<a-select
|
||||||
v-model:value="form.origin_sku_id"
|
v-model:value="form.origin_sku_id"
|
||||||
placeholder="选择已有 SKU"
|
:placeholder="!form.company_id ? '请先选择公司' : '搜索(最少3个字)或输入SKU'"
|
||||||
:options="filteredSkuOrigins.map(s => ({
|
:options="skuSearchOptions"
|
||||||
value: s.id,
|
:loading="skuSearching"
|
||||||
label: `${s.sku} - ${s.name}`
|
:disabled="!form.company_id"
|
||||||
}))"
|
|
||||||
allow-clear
|
allow-clear
|
||||||
show-search
|
show-search
|
||||||
:filter-option="(input: string, option: { label: string }) =>
|
:filter-option="false"
|
||||||
option.label.toLowerCase().includes(input.toLowerCase())"
|
:not-found-content="skuSearching ? '搜索中...' : '无匹配结果'"
|
||||||
@change="(val: number | undefined) => {
|
@search="handleSkuSearch"
|
||||||
const found = store.skuOrigins.find(s => s.id === val)
|
@change="
|
||||||
form.origin_sku = found?.sku || ''
|
(val: number | undefined) => {
|
||||||
}"
|
const found = skuSearchOptions.find((s) => s.value === val)
|
||||||
/>
|
form.origin_sku = found ? found.label.split(' - ')[0] : ''
|
||||||
<a-input
|
}
|
||||||
v-if="!form.origin_sku_id"
|
"
|
||||||
v-model:value="form.origin_sku"
|
|
||||||
placeholder="或手动输入内部 SKU"
|
|
||||||
class="mt-2"
|
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
|
|
||||||
<a-row :gutter="16">
|
<a-row :gutter="16">
|
||||||
<a-col :span="16">
|
<a-col :span="12">
|
||||||
<a-form-item name="platform_outer_sku">
|
<a-form-item name="platform_outer_sku">
|
||||||
<template #label>
|
<template #label>
|
||||||
<span>
|
<span>
|
||||||
平台 SKU
|
平台 SKU
|
||||||
<a-tooltip title="电商平台侧使用的 SKU 编码,需符合平台填写规则(如不能以 0 开头、不能含特殊字符等)">
|
<a-tooltip
|
||||||
|
title="电商平台侧使用的 SKU 编码,需符合平台填写规则(如不能以 0 开头、不能含特殊字符等)"
|
||||||
|
>
|
||||||
<QuestionCircleOutlined class="ml-1 text-gray-400" />
|
<QuestionCircleOutlined class="ml-1 text-gray-400" />
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</span>
|
</span>
|
||||||
@@ -483,20 +527,24 @@ watch(
|
|||||||
</a-input>
|
</a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="8">
|
<a-col :span="12">
|
||||||
<a-form-item label="状态">
|
<a-form-item label="平台商品 ID" name="platform_product_id">
|
||||||
<a-switch v-model:checked="form.enabled" checked-children="启用" un-checked-children="禁用" />
|
<a-input v-model:value="form.platform_product_id" placeholder="可选" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
|
|
||||||
<a-form-item label="平台商品 ID" name="platform_product_id">
|
|
||||||
<a-input v-model:value="form.platform_product_id" placeholder="可选" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item label="备注" name="note">
|
<a-form-item label="备注" name="note">
|
||||||
<a-textarea v-model:value="form.note" placeholder="可选备注" :rows="2" />
|
<a-textarea v-model:value="form.note" placeholder="可选备注" :rows="2" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="状态">
|
||||||
|
<a-switch
|
||||||
|
v-model:checked="form.enabled"
|
||||||
|
checked-children="启用"
|
||||||
|
un-checked-children="禁用"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: (query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/utils/request', () => ({
|
||||||
|
api: {
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
put: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { api } from '@/utils/request'
|
||||||
|
|
||||||
|
const mockPaginated = {
|
||||||
|
items: [
|
||||||
|
{ id: 1, company_id: 3, sku: '0032', barcode: '6901234567890', name: 'Test SKU', label: null, hs: null, created_at: '2026-04-01T00:00:00', updated_at: '2026-04-01T00:00:00' },
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
per_page: 15,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockCompanies = [{ id: 3, name: 'Acme Corp', label: 'Acme' }]
|
||||||
|
const mockPlatforms = [{ id: 1, name: 'Amazon', label: null }]
|
||||||
|
const mockStores = [{ id: 101, company_id: 3, platform_id: 1, name: 'Store A', label: null }]
|
||||||
|
|
||||||
|
const mockMappings = {
|
||||||
|
items: [
|
||||||
|
{ id: 10, company_id: 3, platform_id: 1, store_id: null, origin_sku: '0032', origin_sku_id: 1, platform_outer_sku: 'AMZ-0032', platform_product_id: 'ITEM-001', enabled: true, note: null, created_at: '2026-04-01T00:00:00', updated_at: '2026-04-01T00:00:00' },
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
per_page: 50,
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupApiMocks() {
|
||||||
|
vi.mocked(api.get).mockImplementation((url: string) => {
|
||||||
|
if (url === '/api/v1/sku-origins') return Promise.resolve(mockPaginated) as never
|
||||||
|
if (url === '/api/v1/companies') return Promise.resolve(mockCompanies) as never
|
||||||
|
if (url === '/api/v1/platforms') return Promise.resolve(mockPlatforms) as never
|
||||||
|
if (url === '/api/v1/stores') return Promise.resolve(mockStores) as never
|
||||||
|
if (url === '/api/v1/sku-mappings') return Promise.resolve(mockMappings) as never
|
||||||
|
return Promise.resolve({ items: [], total: 0, page: 1, per_page: 15 }) as never
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SkuOriginsPage', () => {
|
||||||
|
let wrapper: ReturnType<typeof mount>
|
||||||
|
|
||||||
|
const stubs = {
|
||||||
|
SearchOutlined: { template: '<span />' },
|
||||||
|
ReloadOutlined: { template: '<span />' },
|
||||||
|
PlusOutlined: { template: '<span />' },
|
||||||
|
EditOutlined: { template: '<span />' },
|
||||||
|
DeleteOutlined: { template: '<span />' },
|
||||||
|
LinkOutlined: { template: '<span />' },
|
||||||
|
CascadeFilter: { template: '<div class="cascade-stub" />' },
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper?.unmount()
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
async function mountPage() {
|
||||||
|
setupApiMocks()
|
||||||
|
|
||||||
|
const { default: SkuOriginsPage } = await import('../index.vue')
|
||||||
|
|
||||||
|
wrapper = mount(SkuOriginsPage, {
|
||||||
|
attachTo: document.body,
|
||||||
|
global: { stubs },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
await nextTick()
|
||||||
|
}
|
||||||
|
|
||||||
|
it('P1: renders page title and action buttons', async () => {
|
||||||
|
await mountPage()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('内部 SKU 管理')
|
||||||
|
expect(wrapper.text()).toContain('新建 SKU')
|
||||||
|
}, 15000)
|
||||||
|
|
||||||
|
it('P2: calls fetchItems on mount', async () => {
|
||||||
|
await mountPage()
|
||||||
|
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/api/v1/sku-origins', expect.any(Object))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('P3: renders table with sku data', async () => {
|
||||||
|
await mountPage()
|
||||||
|
|
||||||
|
const html = wrapper.html()
|
||||||
|
expect(html).toContain('0032')
|
||||||
|
expect(html).toContain('Test SKU')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('P4: search and reset buttons exist', async () => {
|
||||||
|
await mountPage()
|
||||||
|
|
||||||
|
const buttons = wrapper.findAll('.ant-btn')
|
||||||
|
const buttonTexts = buttons.map((b) => b.text())
|
||||||
|
expect(buttonTexts.some((t) => t.includes('搜索'))).toBe(true)
|
||||||
|
expect(buttonTexts.some((t) => t.includes('重置'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('P5: barcode field is required in create modal', async () => {
|
||||||
|
await mountPage()
|
||||||
|
|
||||||
|
// Open create modal
|
||||||
|
const newBtn = wrapper.findAll('.ant-btn').find((b) => b.text().includes('新建 SKU'))
|
||||||
|
await newBtn?.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
// Modal teleports to document.body
|
||||||
|
const bodyHtml = document.body.innerHTML
|
||||||
|
expect(bodyHtml).toContain('条形码 / GTIN 编码')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('P6: new link button exists in action column', async () => {
|
||||||
|
await mountPage()
|
||||||
|
|
||||||
|
const html = wrapper.html()
|
||||||
|
expect(html).toContain('新建链接')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('P7: terminology uses 链接 not 映射', async () => {
|
||||||
|
await mountPage()
|
||||||
|
|
||||||
|
const html = wrapper.html()
|
||||||
|
expect(html).toContain('链接')
|
||||||
|
expect(html).not.toContain('映射')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useSkuOriginStore, type SkuOriginForm } from '@/stores/sku-origin'
|
import { useSkuOriginStore, type SkuOriginForm } from '@/stores/sku-origin'
|
||||||
|
import { useSkuMappingStore, type SkuMappingForm } from '@/stores/sku-mapping'
|
||||||
import CascadeFilter from '@/components/CascadeFilter.vue'
|
import CascadeFilter from '@/components/CascadeFilter.vue'
|
||||||
import {
|
import {
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
@@ -7,23 +8,117 @@ import {
|
|||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
|
LinkOutlined,
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
import type { Rule } from 'ant-design-vue/es/form'
|
import type { Rule } from 'ant-design-vue/es/form'
|
||||||
|
|
||||||
const store = useSkuOriginStore()
|
const store = useSkuOriginStore()
|
||||||
|
const mappingStore = useSkuMappingStore()
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ title: 'ID', dataIndex: 'id', width: 80 },
|
{ title: 'ID', dataIndex: 'id', width: 80 },
|
||||||
{ title: '公司', key: 'company', width: 140 },
|
{ title: '公司', key: 'company', width: 140 },
|
||||||
{ title: 'SKU', dataIndex: 'sku', width: 160 },
|
{ title: 'SKU', dataIndex: 'sku', width: 160 },
|
||||||
{ title: '名称', dataIndex: 'name', ellipsis: true },
|
{ title: '名称', dataIndex: 'name', ellipsis: true, customCell: () => ({ class: 'min-w-40' }) },
|
||||||
{ title: '条形码', dataIndex: 'barcode', width: 160 },
|
{ title: '条形码', dataIndex: 'barcode', width: 160 },
|
||||||
{ title: '标签', dataIndex: 'label', width: 120 },
|
{ title: '标签', dataIndex: 'label', width: 120 },
|
||||||
{ title: 'HS 编码', dataIndex: 'hs', width: 120 },
|
{ title: 'HS 编码', dataIndex: 'hs', width: 120 },
|
||||||
{ title: '更新时间', key: 'updated_at', width: 170 },
|
{ title: '更新时间', key: 'updated_at', width: 170 },
|
||||||
{ title: '操作', key: 'action', width: 160, fixed: 'right' as const },
|
{ title: '操作', key: 'action', width: 320, fixed: 'right' as const },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Expand row
|
||||||
|
const expandedRowKeys = ref<number[]>([])
|
||||||
|
|
||||||
|
const mappingColumns = [
|
||||||
|
{ title: '平台', key: 'platform', width: 100 },
|
||||||
|
{ title: '店铺', key: 'store', width: 140 },
|
||||||
|
{ title: '平台 SKU', dataIndex: 'platform_outer_sku', width: 160 },
|
||||||
|
{ title: '平台商品 ID', dataIndex: 'platform_product_id', width: 160, ellipsis: true },
|
||||||
|
{ title: '状态', key: 'enabled', width: 80 },
|
||||||
|
]
|
||||||
|
|
||||||
|
function onExpand(expanded: boolean, record: { id: number }) {
|
||||||
|
if (expanded) {
|
||||||
|
store.fetchMappings(record.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create mapping modal
|
||||||
|
const mappingModalVisible = ref(false)
|
||||||
|
const mappingSaving = ref(false)
|
||||||
|
const mappingFormRef = ref()
|
||||||
|
const mappingOriginRecord = ref<{ id: number; company_id: number; sku: string } | null>(null)
|
||||||
|
|
||||||
|
const defaultMappingForm = () => ({
|
||||||
|
platform_id: undefined as number | undefined,
|
||||||
|
store_id: undefined as number | undefined,
|
||||||
|
platform_outer_sku: '',
|
||||||
|
platform_product_id: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const mappingForm = reactive(defaultMappingForm())
|
||||||
|
|
||||||
|
const mappingRules: Record<string, Rule[]> = {
|
||||||
|
platform_id: [{ required: true, message: '请选择平台', trigger: 'change' }],
|
||||||
|
platform_outer_sku: [{ required: true, message: '请输入平台 SKU', trigger: 'blur' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredMappingStores = computed(() => {
|
||||||
|
let filtered = store.stores
|
||||||
|
if (mappingOriginRecord.value?.company_id) {
|
||||||
|
filtered = filtered.filter((s) => s.company_id === mappingOriginRecord.value!.company_id)
|
||||||
|
}
|
||||||
|
if (mappingForm.platform_id) {
|
||||||
|
filtered = filtered.filter((s) => s.platform_id === mappingForm.platform_id)
|
||||||
|
}
|
||||||
|
return filtered.map((s) => ({
|
||||||
|
value: s.id,
|
||||||
|
label: s.label && s.label !== 'null' ? s.label : s.name,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
function openCreateMapping(record: { id: number; company_id: number; sku: string }) {
|
||||||
|
mappingOriginRecord.value = record
|
||||||
|
Object.assign(mappingForm, defaultMappingForm())
|
||||||
|
mappingModalVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMappingSubmit() {
|
||||||
|
try {
|
||||||
|
await mappingFormRef.value.validate()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!mappingOriginRecord.value) return
|
||||||
|
mappingSaving.value = true
|
||||||
|
try {
|
||||||
|
const payload: SkuMappingForm = {
|
||||||
|
company_id: mappingOriginRecord.value.company_id,
|
||||||
|
platform_id: mappingForm.platform_id,
|
||||||
|
store_id: mappingForm.store_id,
|
||||||
|
origin_sku: mappingOriginRecord.value.sku,
|
||||||
|
origin_sku_id: mappingOriginRecord.value.id,
|
||||||
|
platform_product_id: mappingForm.platform_product_id,
|
||||||
|
platform_outer_sku: mappingForm.platform_outer_sku,
|
||||||
|
generation_strategy: 'manual',
|
||||||
|
warehouse_id: undefined,
|
||||||
|
enabled: true,
|
||||||
|
note: '',
|
||||||
|
}
|
||||||
|
await mappingStore.createItem(payload)
|
||||||
|
mappingModalVisible.value = false
|
||||||
|
// 刷新展开行缓存
|
||||||
|
store.mappingsCache.delete(mappingOriginRecord.value.id)
|
||||||
|
store.fetchMappings(mappingOriginRecord.value.id)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : '创建失败'
|
||||||
|
message.error(msg)
|
||||||
|
} finally {
|
||||||
|
mappingSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Modal state
|
// Modal state
|
||||||
const modalVisible = ref(false)
|
const modalVisible = ref(false)
|
||||||
const modalTitle = ref('新建内部 SKU')
|
const modalTitle = ref('新建内部 SKU')
|
||||||
@@ -47,6 +142,7 @@ const rules: Record<string, Rule[]> = {
|
|||||||
company_id: [{ required: true, message: '请选择公司', trigger: 'change' }],
|
company_id: [{ required: true, message: '请选择公司', trigger: 'change' }],
|
||||||
sku: [{ required: true, message: '请输入 SKU 编码', trigger: 'blur' }],
|
sku: [{ required: true, message: '请输入 SKU 编码', trigger: 'blur' }],
|
||||||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||||
|
barcode: [{ required: true, message: '请输入条形码', trigger: 'blur' }],
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -122,7 +218,7 @@ async function handleSubmit() {
|
|||||||
function handleDelete(record: { id: number; sku: string }) {
|
function handleDelete(record: { id: number; sku: string }) {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: '确认删除',
|
title: '确认删除',
|
||||||
content: `确定删除 SKU「${record.sku}」?如果该 SKU 已被映射引用将无法删除。`,
|
content: `确定删除 SKU「${record.sku}」?如果该 SKU 已被链接引用将无法删除。`,
|
||||||
okText: '删除',
|
okText: '删除',
|
||||||
okType: 'danger',
|
okType: 'danger',
|
||||||
cancelText: '取消',
|
cancelText: '取消',
|
||||||
@@ -204,7 +300,39 @@ function handleDelete(record: { id: number; sku: string }) {
|
|||||||
:pagination="false"
|
:pagination="false"
|
||||||
row-key="id"
|
row-key="id"
|
||||||
:scroll="{ x: 1200 }"
|
:scroll="{ x: 1200 }"
|
||||||
|
:expand-column-width="48"
|
||||||
|
v-model:expandedRowKeys="expandedRowKeys"
|
||||||
|
@expand="onExpand"
|
||||||
>
|
>
|
||||||
|
<template #expandedRowRender="{ record }">
|
||||||
|
<a-spin v-if="store.mappingsLoading.has(record.id)" />
|
||||||
|
<a-empty v-else-if="!store.mappingsCache.get(record.id)?.length" description="暂无平台链接" />
|
||||||
|
<a-table
|
||||||
|
v-else
|
||||||
|
:columns="mappingColumns"
|
||||||
|
:data-source="store.mappingsCache.get(record.id) || []"
|
||||||
|
:pagination="false"
|
||||||
|
size="small"
|
||||||
|
row-key="id"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record: mapping }">
|
||||||
|
<template v-if="column.key === 'platform'">
|
||||||
|
{{ store.platformMap.get(mapping.platform_id) || mapping.platform_id }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'store'">
|
||||||
|
<template v-if="mapping.store_id">
|
||||||
|
{{ store.storeMap.get(mapping.store_id) || mapping.store_id }}
|
||||||
|
</template>
|
||||||
|
<span v-else class="text-gray-400">平台默认</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'enabled'">
|
||||||
|
<a-tag :color="mapping.enabled ? 'green' : 'default'">
|
||||||
|
{{ mapping.enabled ? '启用' : '禁用' }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</template>
|
||||||
<template #bodyCell="{ column, record }">
|
<template #bodyCell="{ column, record }">
|
||||||
<template v-if="column.key === 'company'">
|
<template v-if="column.key === 'company'">
|
||||||
{{ store.companyMap.get(record.company_id) || record.company_id }}
|
{{ store.companyMap.get(record.company_id) || record.company_id }}
|
||||||
@@ -214,6 +342,10 @@ function handleDelete(record: { id: number; sku: string }) {
|
|||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'action'">
|
<template v-else-if="column.key === 'action'">
|
||||||
<a-space>
|
<a-space>
|
||||||
|
<a-button type="link" size="small" @click="openCreateMapping(record)">
|
||||||
|
<template #icon><LinkOutlined /></template>
|
||||||
|
新建链接
|
||||||
|
</a-button>
|
||||||
<a-button type="link" size="small" @click="openEdit(record)">
|
<a-button type="link" size="small" @click="openEdit(record)">
|
||||||
<template #icon><EditOutlined /></template>
|
<template #icon><EditOutlined /></template>
|
||||||
编辑
|
编辑
|
||||||
@@ -276,7 +408,7 @@ function handleDelete(record: { id: number; sku: string }) {
|
|||||||
<a-input v-model:value="form.name" placeholder="SKU 名称/描述" />
|
<a-input v-model:value="form.name" placeholder="SKU 名称/描述" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="条形码" name="barcode">
|
<a-form-item label="条形码" name="barcode">
|
||||||
<a-input v-model:value="form.barcode" placeholder="可选" />
|
<a-input v-model:value="form.barcode" placeholder="条形码 / GTIN 编码" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="标签" name="label">
|
<a-form-item label="标签" name="label">
|
||||||
<a-input v-model:value="form.label" placeholder="可选别名" />
|
<a-input v-model:value="form.label" placeholder="可选别名" />
|
||||||
@@ -286,6 +418,58 @@ function handleDelete(record: { id: number; sku: string }) {
|
|||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
|
|
||||||
|
<!-- Create Mapping Modal -->
|
||||||
|
<a-modal
|
||||||
|
v-model:open="mappingModalVisible"
|
||||||
|
title="新建平台 SKU 链接"
|
||||||
|
:confirm-loading="mappingSaving"
|
||||||
|
@ok="handleMappingSubmit"
|
||||||
|
@cancel="mappingModalVisible = false"
|
||||||
|
:width="520"
|
||||||
|
>
|
||||||
|
<a-form
|
||||||
|
ref="mappingFormRef"
|
||||||
|
:model="mappingForm"
|
||||||
|
:rules="mappingRules"
|
||||||
|
layout="vertical"
|
||||||
|
class="mt-4"
|
||||||
|
>
|
||||||
|
<a-form-item label="内部 SKU">
|
||||||
|
<a-input :value="mappingOriginRecord?.sku" disabled />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="平台" name="platform_id">
|
||||||
|
<a-select
|
||||||
|
v-model:value="mappingForm.platform_id"
|
||||||
|
placeholder="请选择平台"
|
||||||
|
:options="store.platforms.map(p => ({
|
||||||
|
value: p.id,
|
||||||
|
label: (p.label && p.label !== 'null') ? p.label : (p.name || `平台 #${p.id}`)
|
||||||
|
}))"
|
||||||
|
show-search
|
||||||
|
:filter-option="(input: string, option: { label: string }) =>
|
||||||
|
option.label.toLowerCase().includes(input.toLowerCase())"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="店铺" name="store_id">
|
||||||
|
<a-select
|
||||||
|
v-model:value="mappingForm.store_id"
|
||||||
|
placeholder="留空表示平台默认"
|
||||||
|
:options="filteredMappingStores"
|
||||||
|
allow-clear
|
||||||
|
show-search
|
||||||
|
:filter-option="(input: string, option: { label: string }) =>
|
||||||
|
option.label.toLowerCase().includes(input.toLowerCase())"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="平台 SKU" name="platform_outer_sku">
|
||||||
|
<a-input v-model:value="mappingForm.platform_outer_sku" placeholder="平台侧 SKU 编码" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="平台商品 ID" name="platform_product_id">
|
||||||
|
<a-input v-model:value="mappingForm.platform_product_id" placeholder="可选" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
|
||||||
|
vi.mock('@/utils/request', () => ({
|
||||||
|
api: {
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
put: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { api } from '@/utils/request'
|
||||||
|
import { useSkuOriginStore } from '../sku-origin'
|
||||||
|
|
||||||
|
const mockItems = [
|
||||||
|
{ id: 1, company_id: 3, sku: '0032', barcode: '6901234567890', name: 'Test SKU', label: null, hs: null, created_at: '2026-04-01T00:00:00', updated_at: '2026-04-01T00:00:00' },
|
||||||
|
{ id: 2, company_id: 3, sku: '0033', barcode: '6901234567891', name: 'Test SKU 2', label: 'alias', hs: '6403', created_at: '2026-04-02T00:00:00', updated_at: '2026-04-02T00:00:00' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const mockPaginated = {
|
||||||
|
items: mockItems,
|
||||||
|
total: 2,
|
||||||
|
page: 1,
|
||||||
|
per_page: 15,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockCompanies = [
|
||||||
|
{ id: 3, name: 'Acme Corp', label: 'Acme' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const mockPlatforms = [
|
||||||
|
{ id: 1, name: 'Amazon', label: null },
|
||||||
|
]
|
||||||
|
|
||||||
|
const mockStores = [
|
||||||
|
{ id: 101, company_id: 3, platform_id: 1, name: 'Store A', label: null },
|
||||||
|
]
|
||||||
|
|
||||||
|
const mockMappings = {
|
||||||
|
items: [
|
||||||
|
{ id: 10, company_id: 3, platform_id: 1, store_id: null, origin_sku: '0032', origin_sku_id: 1, platform_outer_sku: 'AMZ-0032', platform_product_id: 'ITEM-001', enabled: true, note: null, created_at: '2026-04-01T00:00:00', updated_at: '2026-04-01T00:00:00' },
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
per_page: 50,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useSkuOriginStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('initial state', () => {
|
||||||
|
it('starts with empty items and default pagination', () => {
|
||||||
|
const store = useSkuOriginStore()
|
||||||
|
|
||||||
|
expect(store.items).toEqual([])
|
||||||
|
expect(store.pagination.page).toBe(1)
|
||||||
|
expect(store.pagination.per_page).toBe(15)
|
||||||
|
expect(store.pagination.total).toBe(0)
|
||||||
|
expect(store.loading).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fetchItems', () => {
|
||||||
|
it('loads data and updates state', async () => {
|
||||||
|
vi.mocked(api.get).mockResolvedValueOnce(mockPaginated)
|
||||||
|
|
||||||
|
const store = useSkuOriginStore()
|
||||||
|
await store.fetchItems()
|
||||||
|
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/api/v1/sku-origins', expect.objectContaining({
|
||||||
|
page: 1,
|
||||||
|
per_page: 15,
|
||||||
|
}))
|
||||||
|
expect(store.items).toEqual(mockItems)
|
||||||
|
expect(store.pagination.total).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes filter params', async () => {
|
||||||
|
vi.mocked(api.get).mockResolvedValueOnce(mockPaginated)
|
||||||
|
|
||||||
|
const store = useSkuOriginStore()
|
||||||
|
store.filters.sku = '0032'
|
||||||
|
store.filters.barcode = '690'
|
||||||
|
store.cascadeValue.company_id = 3
|
||||||
|
await store.fetchItems()
|
||||||
|
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/api/v1/sku-origins', expect.objectContaining({
|
||||||
|
sku: '0032',
|
||||||
|
barcode: '690',
|
||||||
|
company_id: 3,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears data on error', async () => {
|
||||||
|
vi.mocked(api.get).mockRejectedValueOnce(new Error('网络错误'))
|
||||||
|
|
||||||
|
const store = useSkuOriginStore()
|
||||||
|
await store.fetchItems()
|
||||||
|
|
||||||
|
expect(store.items).toEqual([])
|
||||||
|
expect(store.pagination.total).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resetFilters', () => {
|
||||||
|
it('clears all filters and pagination', () => {
|
||||||
|
const store = useSkuOriginStore()
|
||||||
|
store.filters.sku = 'test'
|
||||||
|
store.filters.barcode = '123'
|
||||||
|
store.cascadeValue.company_id = 5
|
||||||
|
store.pagination.page = 3
|
||||||
|
|
||||||
|
store.resetFilters()
|
||||||
|
|
||||||
|
expect(store.filters.sku).toBe('')
|
||||||
|
expect(store.filters.barcode).toBe('')
|
||||||
|
expect(store.cascadeValue.company_id).toBeUndefined()
|
||||||
|
expect(store.pagination.page).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('createItem', () => {
|
||||||
|
it('calls API and refreshes list', async () => {
|
||||||
|
vi.mocked(api.post).mockResolvedValueOnce({})
|
||||||
|
vi.mocked(api.get).mockResolvedValueOnce(mockPaginated)
|
||||||
|
|
||||||
|
const store = useSkuOriginStore()
|
||||||
|
await store.createItem({
|
||||||
|
company_id: 3,
|
||||||
|
sku: 'NEW-SKU',
|
||||||
|
barcode: '1234567890123',
|
||||||
|
name: 'New SKU',
|
||||||
|
label: '',
|
||||||
|
hs: '',
|
||||||
|
ledger: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/api/v1/sku-origins', expect.objectContaining({
|
||||||
|
sku: 'NEW-SKU',
|
||||||
|
}))
|
||||||
|
expect(api.get).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('updateItem', () => {
|
||||||
|
it('calls API and refreshes list', async () => {
|
||||||
|
vi.mocked(api.put).mockResolvedValueOnce({})
|
||||||
|
vi.mocked(api.get).mockResolvedValueOnce(mockPaginated)
|
||||||
|
|
||||||
|
const store = useSkuOriginStore()
|
||||||
|
await store.updateItem(1, { name: 'Updated' })
|
||||||
|
|
||||||
|
expect(api.put).toHaveBeenCalledWith('/api/v1/sku-origins/1', { name: 'Updated' })
|
||||||
|
expect(api.get).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('deleteItem', () => {
|
||||||
|
it('calls API and refreshes list', async () => {
|
||||||
|
vi.mocked(api.delete).mockResolvedValueOnce({})
|
||||||
|
vi.mocked(api.get).mockResolvedValueOnce(mockPaginated)
|
||||||
|
|
||||||
|
const store = useSkuOriginStore()
|
||||||
|
await store.deleteItem(1)
|
||||||
|
|
||||||
|
expect(api.delete).toHaveBeenCalledWith('/api/v1/sku-origins/1')
|
||||||
|
expect(api.get).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fetchMappings', () => {
|
||||||
|
it('loads and caches mapping data', async () => {
|
||||||
|
vi.mocked(api.get).mockResolvedValueOnce(mockMappings)
|
||||||
|
|
||||||
|
const store = useSkuOriginStore()
|
||||||
|
await store.fetchMappings(1)
|
||||||
|
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/api/v1/sku-mappings', {
|
||||||
|
origin_sku_id: 1,
|
||||||
|
per_page: 50,
|
||||||
|
})
|
||||||
|
expect(store.mappingsCache.get(1)).toEqual(mockMappings.items)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets loading state correctly', async () => {
|
||||||
|
let resolvePromise: (value: unknown) => void
|
||||||
|
const pending = new Promise((resolve) => { resolvePromise = resolve })
|
||||||
|
vi.mocked(api.get).mockReturnValueOnce(pending as never)
|
||||||
|
|
||||||
|
const store = useSkuOriginStore()
|
||||||
|
const fetchPromise = store.fetchMappings(1)
|
||||||
|
|
||||||
|
expect(store.mappingsLoading.has(1)).toBe(true)
|
||||||
|
|
||||||
|
resolvePromise!(mockMappings)
|
||||||
|
await fetchPromise
|
||||||
|
|
||||||
|
expect(store.mappingsLoading.has(1)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('caches empty array on error', async () => {
|
||||||
|
vi.mocked(api.get).mockRejectedValueOnce(new Error('失败'))
|
||||||
|
|
||||||
|
const store = useSkuOriginStore()
|
||||||
|
await store.fetchMappings(99)
|
||||||
|
|
||||||
|
expect(store.mappingsCache.get(99)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('lookup maps', () => {
|
||||||
|
it('builds companyMap correctly', async () => {
|
||||||
|
vi.mocked(api.get)
|
||||||
|
.mockResolvedValueOnce(mockCompanies)
|
||||||
|
.mockResolvedValueOnce(mockPlatforms)
|
||||||
|
.mockResolvedValueOnce(mockStores)
|
||||||
|
|
||||||
|
const store = useSkuOriginStore()
|
||||||
|
await store.loadLookups()
|
||||||
|
|
||||||
|
expect(store.companyMap.get(3)).toBe('Acme')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('builds platformMap correctly', async () => {
|
||||||
|
vi.mocked(api.get)
|
||||||
|
.mockResolvedValueOnce(mockCompanies)
|
||||||
|
.mockResolvedValueOnce(mockPlatforms)
|
||||||
|
.mockResolvedValueOnce(mockStores)
|
||||||
|
|
||||||
|
const store = useSkuOriginStore()
|
||||||
|
await store.loadLookups()
|
||||||
|
|
||||||
|
expect(store.platformMap.get(1)).toBe('Amazon')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('builds storeMap correctly', async () => {
|
||||||
|
vi.mocked(api.get)
|
||||||
|
.mockResolvedValueOnce(mockCompanies)
|
||||||
|
.mockResolvedValueOnce(mockPlatforms)
|
||||||
|
.mockResolvedValueOnce(mockStores)
|
||||||
|
|
||||||
|
const store = useSkuOriginStore()
|
||||||
|
await store.loadLookups()
|
||||||
|
|
||||||
|
expect(store.storeMap.get(101)).toBe('Store A')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -157,6 +157,15 @@ export const useSkuMappingStore = defineStore('skuMapping', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function searchSkuOrigins(company_id: number, sku: string) {
|
||||||
|
const data = await api.get<PaginatedData<{ id: number; company_id: number; sku: string; name: string }>>('/api/v1/sku-origins', {
|
||||||
|
company_id,
|
||||||
|
sku,
|
||||||
|
per_page: 20,
|
||||||
|
})
|
||||||
|
return data.items
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchItems() {
|
async function fetchItems() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -240,6 +249,7 @@ export const useSkuMappingStore = defineStore('skuMapping', () => {
|
|||||||
skuOrigins,
|
skuOrigins,
|
||||||
loadLookups,
|
loadLookups,
|
||||||
loadSkuOrigins,
|
loadSkuOrigins,
|
||||||
|
searchSkuOrigins,
|
||||||
fetchItems,
|
fetchItems,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
createItem,
|
createItem,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { api } from '@/utils/request'
|
import { api } from '@/utils/request'
|
||||||
import type { PaginatedData } from '@/types/api'
|
import type { PaginatedData } from '@/types/api'
|
||||||
|
import type { SkuMappingRecord } from '@/stores/sku-mapping'
|
||||||
|
|
||||||
// ─── Types ───
|
// ─── Types ───
|
||||||
|
|
||||||
@@ -59,6 +60,11 @@ export const useSkuOriginStore = defineStore('skuOrigin', () => {
|
|||||||
|
|
||||||
// Lookup maps
|
// Lookup maps
|
||||||
const companies = ref<{ id: number; name: string; label: string | null }[]>([])
|
const companies = ref<{ id: number; name: string; label: string | null }[]>([])
|
||||||
|
const platforms = ref<{ id: number; name: string; label: string | null }[]>([])
|
||||||
|
const stores = ref<
|
||||||
|
{ id: number; company_id: number; platform_id: number; name: string; label: string | null }[]
|
||||||
|
>([])
|
||||||
|
|
||||||
const companyMap = computed(
|
const companyMap = computed(
|
||||||
() =>
|
() =>
|
||||||
new Map(
|
new Map(
|
||||||
@@ -68,17 +74,61 @@ export const useSkuOriginStore = defineStore('skuOrigin', () => {
|
|||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
const platformMap = computed(
|
||||||
|
() =>
|
||||||
|
new Map(
|
||||||
|
platforms.value.map((p) => [
|
||||||
|
p.id,
|
||||||
|
p.label && p.label !== 'null' ? p.label : p.name || `平台 #${p.id}`,
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const storeMap = computed(
|
||||||
|
() =>
|
||||||
|
new Map(
|
||||||
|
stores.value.map((s) => [
|
||||||
|
s.id,
|
||||||
|
s.label && s.label !== 'null' ? s.label : s.name,
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mappings expand cache
|
||||||
|
const mappingsCache = ref<Map<number, SkuMappingRecord[]>>(new Map())
|
||||||
|
const mappingsLoading = ref<Set<number>>(new Set())
|
||||||
|
|
||||||
async function loadLookups() {
|
async function loadLookups() {
|
||||||
try {
|
try {
|
||||||
companies.value = await api.get<{ id: number; name: string; label: string | null }[]>(
|
const [c, p, s] = await Promise.all([
|
||||||
'/api/v1/companies',
|
api.get<typeof companies.value>('/api/v1/companies'),
|
||||||
)
|
api.get<typeof platforms.value>('/api/v1/platforms'),
|
||||||
|
api.get<typeof stores.value>('/api/v1/stores'),
|
||||||
|
])
|
||||||
|
companies.value = c
|
||||||
|
platforms.value = p
|
||||||
|
stores.value = s
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('加载查找表数据失败', err)
|
console.warn('加载查找表数据失败', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchMappings(originSkuId: number) {
|
||||||
|
if (mappingsLoading.value.has(originSkuId)) return
|
||||||
|
mappingsLoading.value.add(originSkuId)
|
||||||
|
try {
|
||||||
|
const data = await api.get<PaginatedData<SkuMappingRecord>>('/api/v1/sku-mappings', {
|
||||||
|
origin_sku_id: originSkuId,
|
||||||
|
per_page: 50,
|
||||||
|
})
|
||||||
|
mappingsCache.value.set(originSkuId, data.items)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('加载关联链接数据失败', err)
|
||||||
|
mappingsCache.value.set(originSkuId, [])
|
||||||
|
} finally {
|
||||||
|
mappingsLoading.value.delete(originSkuId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchItems() {
|
async function fetchItems() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -138,9 +188,16 @@ export const useSkuOriginStore = defineStore('skuOrigin', () => {
|
|||||||
cascadeValue,
|
cascadeValue,
|
||||||
filters,
|
filters,
|
||||||
companies,
|
companies,
|
||||||
|
platforms,
|
||||||
|
stores,
|
||||||
companyMap,
|
companyMap,
|
||||||
|
platformMap,
|
||||||
|
storeMap,
|
||||||
|
mappingsCache,
|
||||||
|
mappingsLoading,
|
||||||
loadLookups,
|
loadLookups,
|
||||||
fetchItems,
|
fetchItems,
|
||||||
|
fetchMappings,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
createItem,
|
createItem,
|
||||||
updateItem,
|
updateItem,
|
||||||
|
|||||||
Reference in New Issue
Block a user