update sku mapping

This commit is contained in:
2026-04-14 15:45:29 +08:00
parent b1cd4ea0eb
commit 417ac5e1f9
11 changed files with 1454 additions and 75 deletions
@@ -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)
})
})
+113 -65
View File
@@ -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="
value: c.id, store.companies.map((c) => ({
label: (c.label && c.label !== 'null') ? c.label : c.name value: c.id,
}))" 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="
value: p.id, store.platforms.map((p) => ({
label: (p.label && p.label !== 'null') ? p.label : (p.name || `平台 #${p.id}`) value: 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('映射')
})
})
+188 -4
View File
@@ -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')
})
})
})
+10
View File
@@ -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,
+60 -3
View File
@@ -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,