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: '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: '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')),
|
||||
],
|
||||
responses: [
|
||||
@@ -85,6 +86,7 @@ class SkuMappingController extends AbstractController
|
||||
'company_id' => 'exact',
|
||||
'platform_id' => 'exact',
|
||||
'store_id' => 'exact',
|
||||
'origin_sku_id' => 'exact',
|
||||
];
|
||||
|
||||
foreach ($filters as $field => $type) {
|
||||
@@ -335,7 +337,7 @@ class SkuMappingController extends AbstractController
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['company_id', 'platform_id', 'origin_sku', 'platform_product_id'],
|
||||
required: ['company_id', 'platform_id', 'origin_sku'],
|
||||
properties: [
|
||||
new OA\Property(property: 'company_id', type: 'integer', example: 3),
|
||||
new OA\Property(property: 'platform_id', type: 'integer', example: 1),
|
||||
@@ -369,7 +371,7 @@ class SkuMappingController extends AbstractController
|
||||
{
|
||||
$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) {
|
||||
if (!isset($data[$field]) || $data[$field] === '') {
|
||||
return $this->response->json([
|
||||
@@ -385,7 +387,7 @@ class SkuMappingController extends AbstractController
|
||||
'store_id' => isset($data['store_id']) ? (int) $data['store_id'] : null,
|
||||
'origin_sku' => $data['origin_sku'],
|
||||
'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,
|
||||
'generation_strategy' => $data['generation_strategy'] ?? 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
|
||||
const modalVisible = ref(false)
|
||||
const modalTitle = ref('新建映射')
|
||||
const modalTitle = ref('新建连接')
|
||||
const editingId = ref<number | null>(null)
|
||||
const formRef = ref()
|
||||
const saving = ref(false)
|
||||
@@ -87,15 +87,41 @@ const filteredStoreOptions = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
// Filtered SKU origins by company
|
||||
const filteredSkuOrigins = computed(() => {
|
||||
if (!form.company_id) return store.skuOrigins
|
||||
return store.skuOrigins.filter((s) => s.company_id === form.company_id)
|
||||
})
|
||||
// Remote search for SKU origins
|
||||
const skuSearchOptions = ref<{ value: number; label: string }[]>([])
|
||||
const skuSearching = ref(false)
|
||||
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(() => {
|
||||
store.loadLookups()
|
||||
store.loadSkuOrigins()
|
||||
store.fetchItems()
|
||||
})
|
||||
|
||||
@@ -123,13 +149,14 @@ function formatTime(time: string | null) {
|
||||
function openCreate() {
|
||||
Object.assign(form, defaultForm())
|
||||
editingId.value = null
|
||||
modalTitle.value = '新建映射'
|
||||
modalTitle.value = '新建连接'
|
||||
skuSearchOptions.value = []
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(record: SkuMappingForm & { id: number }) {
|
||||
editingId.value = record.id
|
||||
modalTitle.value = '编辑映射'
|
||||
modalTitle.value = '编辑连接'
|
||||
Object.assign(form, {
|
||||
company_id: record.company_id,
|
||||
platform_id: record.platform_id,
|
||||
@@ -143,9 +170,17 @@ function openEdit(record: SkuMappingForm & { id: number }) {
|
||||
enabled: record.enabled ?? true,
|
||||
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
|
||||
}
|
||||
|
||||
function handleModalClose() {
|
||||
skuSearchOptions.value = []
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
@@ -160,6 +195,7 @@ async function handleSubmit() {
|
||||
await store.createItem({ ...form })
|
||||
}
|
||||
modalVisible.value = false
|
||||
handleModalClose()
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : '操作失败'
|
||||
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({
|
||||
title: '确认删除',
|
||||
content: `确定删除映射「${record.origin_sku} → ${record.platform_outer_sku || '-'}」?`,
|
||||
content: `确定删除连接「${record.origin_sku} → ${record.platform_outer_sku || '-'}」?`,
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
@@ -196,7 +236,7 @@ function openGenerate() {
|
||||
return
|
||||
}
|
||||
genForm.strategy = 'prefix'
|
||||
genForm.prefix = ''
|
||||
genForm.prefix = form.company_id ? `C${String(form.company_id).padStart(4, '0')}` : ''
|
||||
genForm.random_length = 4
|
||||
genForm.manual_value = ''
|
||||
genResult.value = ''
|
||||
@@ -244,18 +284,20 @@ function applyGenerated() {
|
||||
}
|
||||
}
|
||||
|
||||
// Watch company change to reload SKU origins
|
||||
// Watch company change to reset SKU search
|
||||
watch(
|
||||
() => form.company_id,
|
||||
(val) => {
|
||||
store.loadSkuOrigins(val)
|
||||
() => {
|
||||
form.origin_sku_id = undefined
|
||||
form.origin_sku = ''
|
||||
skuSearchOptions.value = []
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold mb-4">SKU 映射管理</h2>
|
||||
<h2 class="text-xl font-semibold mb-4">SKU 连接管理</h2>
|
||||
|
||||
<!-- Filter area -->
|
||||
<a-card class="mb-6">
|
||||
@@ -311,7 +353,7 @@ watch(
|
||||
<span class="text-gray-500">共 {{ store.pagination.total }} 条记录</span>
|
||||
<a-button type="primary" @click="openCreate">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新建映射
|
||||
新建连接
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
@@ -378,29 +420,27 @@ watch(
|
||||
:title="modalTitle"
|
||||
:confirm-loading="saving"
|
||||
@ok="handleSubmit"
|
||||
@cancel="modalVisible = false"
|
||||
@cancel="modalVisible = false; handleModalClose()"
|
||||
:width="640"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
class="mt-4"
|
||||
>
|
||||
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical" class="mt-4">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="公司" name="company_id">
|
||||
<a-select
|
||||
v-model:value="form.company_id"
|
||||
placeholder="请选择公司"
|
||||
:options="store.companies.map(c => ({
|
||||
:options="
|
||||
store.companies.map((c) => ({
|
||||
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
|
||||
:filter-option="(input: string, option: { label: string }) =>
|
||||
option.label.toLowerCase().includes(input.toLowerCase())"
|
||||
:filter-option="
|
||||
(input: string, option: { label: string }) =>
|
||||
option.label.toLowerCase().includes(input.toLowerCase())
|
||||
"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
@@ -409,13 +449,17 @@ watch(
|
||||
<a-select
|
||||
v-model:value="form.platform_id"
|
||||
placeholder="请选择平台"
|
||||
:options="store.platforms.map(p => ({
|
||||
:options="
|
||||
store.platforms.map((p) => ({
|
||||
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
|
||||
:filter-option="(input: string, option: { label: string }) =>
|
||||
option.label.toLowerCase().includes(input.toLowerCase())"
|
||||
:filter-option="
|
||||
(input: string, option: { label: string }) =>
|
||||
option.label.toLowerCase().includes(input.toLowerCase())
|
||||
"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
@@ -430,8 +474,10 @@ watch(
|
||||
:options="filteredStoreOptions"
|
||||
allow-clear
|
||||
show-search
|
||||
:filter-option="(input: string, option: { label: string }) =>
|
||||
option.label.toLowerCase().includes(input.toLowerCase())"
|
||||
:filter-option="
|
||||
(input: string, option: { label: string }) =>
|
||||
option.label.toLowerCase().includes(input.toLowerCase())
|
||||
"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
@@ -439,37 +485,35 @@ watch(
|
||||
<a-form-item label="内部 SKU" name="origin_sku">
|
||||
<a-select
|
||||
v-model:value="form.origin_sku_id"
|
||||
placeholder="选择已有 SKU"
|
||||
:options="filteredSkuOrigins.map(s => ({
|
||||
value: s.id,
|
||||
label: `${s.sku} - ${s.name}`
|
||||
}))"
|
||||
:placeholder="!form.company_id ? '请先选择公司' : '搜索(最少3个字)或输入SKU'"
|
||||
:options="skuSearchOptions"
|
||||
:loading="skuSearching"
|
||||
:disabled="!form.company_id"
|
||||
allow-clear
|
||||
show-search
|
||||
:filter-option="(input: string, option: { label: string }) =>
|
||||
option.label.toLowerCase().includes(input.toLowerCase())"
|
||||
@change="(val: number | undefined) => {
|
||||
const found = store.skuOrigins.find(s => s.id === val)
|
||||
form.origin_sku = found?.sku || ''
|
||||
}"
|
||||
/>
|
||||
<a-input
|
||||
v-if="!form.origin_sku_id"
|
||||
v-model:value="form.origin_sku"
|
||||
placeholder="或手动输入内部 SKU"
|
||||
class="mt-2"
|
||||
:filter-option="false"
|
||||
:not-found-content="skuSearching ? '搜索中...' : '无匹配结果'"
|
||||
@search="handleSkuSearch"
|
||||
@change="
|
||||
(val: number | undefined) => {
|
||||
const found = skuSearchOptions.find((s) => s.value === val)
|
||||
form.origin_sku = found ? found.label.split(' - ')[0] : ''
|
||||
}
|
||||
"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item name="platform_outer_sku">
|
||||
<template #label>
|
||||
<span>
|
||||
平台 SKU
|
||||
<a-tooltip title="电商平台侧使用的 SKU 编码,需符合平台填写规则(如不能以 0 开头、不能含特殊字符等)">
|
||||
<a-tooltip
|
||||
title="电商平台侧使用的 SKU 编码,需符合平台填写规则(如不能以 0 开头、不能含特殊字符等)"
|
||||
>
|
||||
<QuestionCircleOutlined class="ml-1 text-gray-400" />
|
||||
</a-tooltip>
|
||||
</span>
|
||||
@@ -483,20 +527,24 @@ watch(
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="状态">
|
||||
<a-switch v-model:checked="form.enabled" checked-children="启用" un-checked-children="禁用" />
|
||||
<a-col :span="12">
|
||||
<a-form-item label="平台商品 ID" name="platform_product_id">
|
||||
<a-input v-model:value="form.platform_product_id" placeholder="可选" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</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-textarea v-model:value="form.note" placeholder="可选备注" :rows="2" />
|
||||
</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-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">
|
||||
import { useSkuOriginStore, type SkuOriginForm } from '@/stores/sku-origin'
|
||||
import { useSkuMappingStore, type SkuMappingForm } from '@/stores/sku-mapping'
|
||||
import CascadeFilter from '@/components/CascadeFilter.vue'
|
||||
import {
|
||||
SearchOutlined,
|
||||
@@ -7,23 +8,117 @@ import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
LinkOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import type { Rule } from 'ant-design-vue/es/form'
|
||||
|
||||
const store = useSkuOriginStore()
|
||||
const mappingStore = useSkuMappingStore()
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 80 },
|
||||
{ title: '公司', key: 'company', width: 140 },
|
||||
{ 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: 'label', width: 120 },
|
||||
{ title: 'HS 编码', dataIndex: 'hs', width: 120 },
|
||||
{ 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
|
||||
const modalVisible = ref(false)
|
||||
const modalTitle = ref('新建内部 SKU')
|
||||
@@ -47,6 +142,7 @@ const rules: Record<string, Rule[]> = {
|
||||
company_id: [{ required: true, message: '请选择公司', trigger: 'change' }],
|
||||
sku: [{ required: true, message: '请输入 SKU 编码', trigger: 'blur' }],
|
||||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||
barcode: [{ required: true, message: '请输入条形码', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -122,7 +218,7 @@ async function handleSubmit() {
|
||||
function handleDelete(record: { id: number; sku: string }) {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定删除 SKU「${record.sku}」?如果该 SKU 已被映射引用将无法删除。`,
|
||||
content: `确定删除 SKU「${record.sku}」?如果该 SKU 已被链接引用将无法删除。`,
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
@@ -204,7 +300,39 @@ function handleDelete(record: { id: number; sku: string }) {
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
: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 v-if="column.key === 'company'">
|
||||
{{ store.companyMap.get(record.company_id) || record.company_id }}
|
||||
@@ -214,6 +342,10 @@ function handleDelete(record: { id: number; sku: string }) {
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<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)">
|
||||
<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-form-item>
|
||||
<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 label="标签" name="label">
|
||||
<a-input v-model:value="form.label" placeholder="可选别名" />
|
||||
@@ -286,6 +418,58 @@ function handleDelete(record: { id: number; sku: string }) {
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</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>
|
||||
</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() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -240,6 +249,7 @@ export const useSkuMappingStore = defineStore('skuMapping', () => {
|
||||
skuOrigins,
|
||||
loadLookups,
|
||||
loadSkuOrigins,
|
||||
searchSkuOrigins,
|
||||
fetchItems,
|
||||
resetFilters,
|
||||
createItem,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { api } from '@/utils/request'
|
||||
import type { PaginatedData } from '@/types/api'
|
||||
import type { SkuMappingRecord } from '@/stores/sku-mapping'
|
||||
|
||||
// ─── Types ───
|
||||
|
||||
@@ -59,6 +60,11 @@ export const useSkuOriginStore = defineStore('skuOrigin', () => {
|
||||
|
||||
// Lookup maps
|
||||
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(
|
||||
() =>
|
||||
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() {
|
||||
try {
|
||||
companies.value = await api.get<{ id: number; name: string; label: string | null }[]>(
|
||||
'/api/v1/companies',
|
||||
)
|
||||
const [c, p, s] = await Promise.all([
|
||||
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) {
|
||||
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() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -138,9 +188,16 @@ export const useSkuOriginStore = defineStore('skuOrigin', () => {
|
||||
cascadeValue,
|
||||
filters,
|
||||
companies,
|
||||
platforms,
|
||||
stores,
|
||||
companyMap,
|
||||
platformMap,
|
||||
storeMap,
|
||||
mappingsCache,
|
||||
mappingsLoading,
|
||||
loadLookups,
|
||||
fetchItems,
|
||||
fetchMappings,
|
||||
resetFilters,
|
||||
createItem,
|
||||
updateItem,
|
||||
|
||||
Reference in New Issue
Block a user