update sku mapping
This commit is contained in:
@@ -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 => ({
|
||||
value: c.id,
|
||||
label: (c.label && c.label !== 'null') ? c.label : c.name
|
||||
}))"
|
||||
:options="
|
||||
store.companies.map((c) => ({
|
||||
value: c.id,
|
||||
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 => ({
|
||||
value: p.id,
|
||||
label: (p.label && p.label !== 'null') ? p.label : (p.name || `平台 #${p.id}`)
|
||||
}))"
|
||||
: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())"
|
||||
: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