Merge branch 'dev' of http://8.163.30.128:10082/html/admin-web into dev
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { listAllPlatformWarehouses } from '@/modules/warehouses'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import { createPallet, getPallet, listCustomerWarehouses, listPallets, updatePallet } from '../api/pallets'
|
||||
import PalletsView from './PalletsView.vue'
|
||||
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('../api/pallets', () => ({ createPallet: vi.fn(), getPallet: vi.fn(), listCustomerWarehouses: vi.fn(), listPallets: vi.fn(), updatePallet: vi.fn() }))
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
|
||||
function memoryStorage(): Storage { const values = new Map<string, string>(); return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value) } }
|
||||
enableAutoUnmount(afterEach)
|
||||
afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('PalletsView', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('localStorage', memoryStorage())
|
||||
vi.mocked(listCustomerWarehouses).mockReset().mockResolvedValue({ records: [{ id: '200', customerId: '0', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', warehouseType: 'OVERSEAS', status: 'ACTIVE' }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(listAllPlatformWarehouses).mockReset().mockResolvedValue([{ id: '300', warehouseCode: 'ONT8', warehouseName: 'ONT8', warehouseType: 'FBA', status: 'ACTIVE' } as never])
|
||||
const pallet = { id: '9007199254740993', customerId: '0', warehouseId: '200', locationId: null, palletCode: 'PLT-1', palletType: 'WOOD', fbaWarehouseCode: 'ONT8', capacityCbm: 2, unitPrice: 10, status: 'IDLE', remark: null, version: 7 }
|
||||
vi.mocked(listPallets).mockReset().mockResolvedValue({ records: [pallet], page: 1, size: 10, total: 1, pages: 1 })
|
||||
vi.mocked(getPallet).mockReset().mockResolvedValue(pallet)
|
||||
vi.mocked(createPallet).mockReset()
|
||||
vi.mocked(updatePallet).mockReset().mockResolvedValue(null)
|
||||
})
|
||||
|
||||
it('loads the current detail version and keeps the edit open on a conflict', async () => {
|
||||
vi.mocked(updatePallet).mockRejectedValueOnce(Object.assign(new Error('version conflict'), { code: 'A0409' }))
|
||||
const wrapper = mount(PalletsView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
wrapper.findComponent(QueryToolbar).vm.$emit('search', { warehouseId: '200', code: '', type: '', status: '' })
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button.el-button--small').filter((button) => button.text().includes('编辑')).at(-1)!.trigger('click')
|
||||
await flushPromises()
|
||||
const dialog = wrapper.findComponent(AppDialog)
|
||||
dialog.vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
|
||||
expect(getPallet).toHaveBeenCalledWith('200', '9007199254740993')
|
||||
expect(updatePallet).toHaveBeenCalledWith('200', '9007199254740993', expect.objectContaining({
|
||||
palletCode: 'PLT-1', fbaWarehouseCode: 'ONT8', version: 7,
|
||||
}))
|
||||
expect(dialog.props('modelValue')).toBe(true)
|
||||
expect(createPallet).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Eye, Pencil, Plus, Trash2 } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Eye, Pencil, Plus } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
|
||||
@@ -14,7 +14,7 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { createPallet, deletePallet, getPallet, listCustomerWarehouses, listPallets, updatePallet, type CustomerWarehouseDto, type PalletDto } from '../api/pallets'
|
||||
import { createPallet, getPallet, listCustomerWarehouses, listPallets, updatePallet, type CustomerWarehouseDto, type PalletDto } from '../api/pallets'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import type { PalletListItem } from '../types'
|
||||
|
||||
@@ -26,7 +26,6 @@ const rows = ref<PalletListItem[]>([])
|
||||
const loading = ref(false)
|
||||
const contextLoading = ref(false)
|
||||
const saving = ref(false)
|
||||
const deletingId = ref('')
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const mutationError = ref<ApiErrorDetails | null>(null)
|
||||
const loadErrorKind = ref<'context' | 'list'>('context')
|
||||
@@ -49,7 +48,7 @@ const columns: DataTableColumn[] = [
|
||||
]
|
||||
const dialogOpen = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const form = reactive({ id: '', warehouseId: '', fbaWarehouseCode: '', code: '', type: 'WOOD', volume: 2, purchasePrice: 0, status: 'IDLE', remark: '' })
|
||||
const form = reactive({ id: '', warehouseId: '', fbaWarehouseCode: '', code: '', type: 'WOOD', volume: 2, purchasePrice: 0, status: 'IDLE', remark: '', version: 0 })
|
||||
|
||||
function text(value: unknown) { const normalized = String(value ?? '').trim(); return normalized || '—' }
|
||||
function number(value: unknown) { const normalized = Number(value); return Number.isFinite(normalized) ? normalized : 0 }
|
||||
@@ -93,14 +92,14 @@ function retryLoad() {
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
dialogMode.value = 'create'; Object.assign(form, { id: '', warehouseId: '', fbaWarehouseCode: '', code: '', type: 'WOOD', volume: 2, purchasePrice: 0, status: 'IDLE', remark: '' }); mutationError.value = null; dialogOpen.value = true
|
||||
dialogMode.value = 'create'; Object.assign(form, { id: '', warehouseId: '', fbaWarehouseCode: '', code: '', type: 'WOOD', volume: 2, purchasePrice: 0, status: 'IDLE', remark: '', version: 0 }); mutationError.value = null; dialogOpen.value = true
|
||||
}
|
||||
|
||||
async function openEdit(source: object) {
|
||||
const row = source as PalletListItem; saving.value = true; mutationError.value = null
|
||||
try {
|
||||
const detail = await getPallet(row.warehouseId, row.id)
|
||||
dialogMode.value = 'edit'; Object.assign(form, { id: String(detail.id), warehouseId: String(detail.warehouseId), fbaWarehouseCode: detail.fbaWarehouseCode, code: detail.palletCode, type: detail.palletType, volume: number(detail.capacityCbm), purchasePrice: number(detail.unitPrice), status: detail.status, remark: detail.remark || '' }); dialogOpen.value = true
|
||||
dialogMode.value = 'edit'; Object.assign(form, { id: String(detail.id), warehouseId: String(detail.warehouseId), fbaWarehouseCode: detail.fbaWarehouseCode, code: detail.palletCode, type: detail.palletType, volume: number(detail.capacityCbm), purchasePrice: number(detail.unitPrice), status: detail.status, remark: detail.remark || '', version: detail.version }); dialogOpen.value = true
|
||||
} catch (error) { mutationError.value = getApiErrorDetails(error, '托盘详情加载失败,请重试') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
@@ -110,23 +109,13 @@ async function save() {
|
||||
saving.value = true; mutationError.value = null
|
||||
const payload = { palletCode: form.code.trim(), palletType: form.type, fbaWarehouseCode: form.fbaWarehouseCode, capacityCbm: form.volume, unitPrice: form.purchasePrice, status: form.status, locationId: null, remark: form.remark.trim() || null }
|
||||
try {
|
||||
if (dialogMode.value === 'edit') await updatePallet(form.warehouseId, form.id, payload)
|
||||
if (dialogMode.value === 'edit') await updatePallet(form.warehouseId, form.id, { ...payload, version: form.version })
|
||||
else await createPallet(form.warehouseId, payload)
|
||||
dialogOpen.value = false; ElMessage.success(dialogMode.value === 'edit' ? '托盘已更新' : '托盘已新增'); await load()
|
||||
} catch (error) { mutationError.value = getApiErrorDetails(error, '托盘保存失败,请重试') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
async function remove(source: object) {
|
||||
const row = source as PalletListItem
|
||||
try { await ElMessageBox.confirm(`确认删除托盘 ${row.code}?`, '删除托盘', { type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }) }
|
||||
catch { return }
|
||||
deletingId.value = row.id; mutationError.value = null
|
||||
try { await deletePallet(row.warehouseId, row.id); ElMessage.success('托盘已删除'); await load() }
|
||||
catch (error) { mutationError.value = getApiErrorDetails(error, '托盘删除失败,请重试') }
|
||||
finally { deletingId.value = '' }
|
||||
}
|
||||
|
||||
function search(model: QueryModel) { page.value = 1; void load(model) }
|
||||
function openDetail(source: object) { const row = source as PalletListItem; void router.push({ name: 'overseas-pallet-detail', params: { palletId: row.id }, query: { warehouseId: row.warehouseId } }) }
|
||||
onMounted(loadWarehouses)
|
||||
@@ -137,8 +126,8 @@ onMounted(loadWarehouses)
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading || contextLoading" ariaLabel="托盘筛选" preference-key="overseas-pallets-query" @search="search" @reset="page = 1; rows = []; total = 0" /></template>
|
||||
<template v-if="loadError" #state><LoadErrorState title="托盘数据加载失败" :message="loadError.message" :loading="loading || contextLoading" @retry="retryLoad"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
|
||||
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-pallets" row-key="id" :empty-text="filters.warehouseId ? '暂无托盘' : '请先选择仓库后查询'" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-actions><el-button type="primary" @click="openCreate"><Plus :size="16" />新增托盘</el-button></template>
|
||||
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.palletEdit)" size="small" plain @click="openEdit(row)"><Pencil :size="15" />编辑</el-button><el-button v-if="access.can(permission.palletDelete)" size="small" plain type="danger" :loading="deletingId === (row as PalletListItem).id" @click="remove(row)"><Trash2 :size="15" />删除</el-button></template>
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.palletCreate)" type="primary" @click="openCreate"><Plus :size="16" />新增托盘</el-button></template>
|
||||
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.palletEdit)" size="small" plain @click="openEdit(row)"><Pencil :size="15" />编辑</el-button></template>
|
||||
<template #pagination-left>共 {{ total }} 条托盘</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { listPlatformTenants } from '@/modules/iam'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import {
|
||||
changeRelabelOrderStatus,
|
||||
createRelabelOrder,
|
||||
listRelabelOrders,
|
||||
listRelabelWarehouseOptions,
|
||||
} from '../api/relabelOrders'
|
||||
import RelabelOrdersView from './RelabelOrdersView.vue'
|
||||
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
||||
vi.mock('../api/relabelOrders', () => ({
|
||||
changeRelabelOrderStatus: vi.fn(),
|
||||
createRelabelOrder: vi.fn(),
|
||||
listRelabelOrders: vi.fn(),
|
||||
listRelabelWarehouseOptions: vi.fn(),
|
||||
}))
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
|
||||
function memoryStorage(): Storage { const values = new Map<string, string>(); return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value) } }
|
||||
function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>((done) => { resolve = done }); return { promise, resolve } }
|
||||
|
||||
function formItem(wrapper: VueWrapper<any>, label: string) {
|
||||
const item = wrapper.findAllComponents({ name: 'ElFormItem' }).find((candidate) => candidate.props('label') === label)
|
||||
if (!item) throw new Error(`Missing form item: ${label}`)
|
||||
return item
|
||||
}
|
||||
|
||||
function select(wrapper: VueWrapper<any>, placeholder: string) {
|
||||
const target = wrapper.findAllComponents({ name: 'ElSelect' }).find((candidate) => candidate.props('placeholder') === placeholder)
|
||||
if (!target) throw new Error(`Missing select: ${placeholder}`)
|
||||
return target
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('RelabelOrdersView', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('localStorage', memoryStorage())
|
||||
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [{ id: '10001', tenantName: '客户甲', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(listRelabelOrders).mockReset().mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 })
|
||||
vi.mocked(listRelabelWarehouseOptions).mockReset()
|
||||
vi.mocked(createRelabelOrder).mockReset()
|
||||
vi.mocked(changeRelabelOrderStatus).mockReset()
|
||||
})
|
||||
|
||||
it('keeps only the latest selected customer warehouse response', async () => {
|
||||
const first = deferred<Awaited<ReturnType<typeof listRelabelWarehouseOptions>>>()
|
||||
const second = deferred<Awaited<ReturnType<typeof listRelabelWarehouseOptions>>>()
|
||||
vi.mocked(listRelabelWarehouseOptions).mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
|
||||
const wrapper = mount(RelabelOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('新增换标单'))!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const customer = select(wrapper, '请选择 ACTIVE 客户')
|
||||
customer.vm.$emit('update:modelValue', '10001'); customer.vm.$emit('change', '10001')
|
||||
await flushPromises()
|
||||
customer.vm.$emit('update:modelValue', '10002'); customer.vm.$emit('change', '10002')
|
||||
await flushPromises()
|
||||
|
||||
second.resolve({ records: [{ id: 'WH-B', customerId: '10002', warehouseCode: 'B', warehouseName: 'B仓', warehouseType: 'OVERSEAS', status: 'ACTIVE' }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
await flushPromises()
|
||||
first.resolve({ records: [{ id: 'WH-A', customerId: '10001', warehouseCode: 'A', warehouseName: 'A仓', warehouseType: 'OVERSEAS', status: 'ACTIVE' }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
await flushPromises()
|
||||
|
||||
const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => option.props('label'))
|
||||
expect(labels).toContain('B仓 · B')
|
||||
expect(labels).not.toContain('A仓 · A')
|
||||
})
|
||||
|
||||
it('reuses one idempotency key after a failed create retry', async () => {
|
||||
vi.mocked(listRelabelWarehouseOptions).mockResolvedValue({ records: [{ id: 'WH-1', customerId: '10001', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', warehouseType: 'OVERSEAS', status: 'ACTIVE' }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(createRelabelOrder).mockRejectedValueOnce(new Error('temporary')).mockResolvedValueOnce({ application: { id: '1', relabelNo: 'RL-1' }, items: [], history: [] })
|
||||
const wrapper = mount(RelabelOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('新增换标单'))!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const customer = select(wrapper, '请选择 ACTIVE 客户')
|
||||
customer.vm.$emit('update:modelValue', '10001'); customer.vm.$emit('change', '10001')
|
||||
await flushPromises()
|
||||
const warehouse = select(wrapper, '请先选择客户,再选择其海外仓')
|
||||
warehouse.vm.$emit('update:modelValue', 'WH-1')
|
||||
formItem(wrapper, '关联订单').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', 'CO-1')
|
||||
formItem(wrapper, '商品SKU').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', 'SKU-1')
|
||||
formItem(wrapper, '中文品名').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', '商品一')
|
||||
formItem(wrapper, '箱数').findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 2)
|
||||
formItem(wrapper, '截止时间').findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-08-30T10:00:00')
|
||||
formItem(wrapper, '联系人').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', '张三')
|
||||
formItem(wrapper, '联系电话').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', '13800000000')
|
||||
formItem(wrapper, '贴标要求').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '换SKU标')
|
||||
formItem(wrapper, '货件号').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', 'SHIP-1')
|
||||
formItem(wrapper, '新SKU').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', 'SKU-2')
|
||||
await flushPromises()
|
||||
|
||||
const dialog = wrapper.findComponent(AppDialog)
|
||||
dialog.vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
expect(dialog.props('modelValue')).toBe(true)
|
||||
dialog.vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
|
||||
expect(createRelabelOrder).toHaveBeenCalledTimes(2)
|
||||
const firstKey = vi.mocked(createRelabelOrder).mock.calls[0]![1].idempotencyKey
|
||||
const retryKey = vi.mocked(createRelabelOrder).mock.calls[1]![1].idempotencyKey
|
||||
expect(firstKey).toBeTruthy()
|
||||
expect(retryKey).toBe(firstKey)
|
||||
expect(createRelabelOrder).toHaveBeenLastCalledWith('10001', expect.objectContaining({
|
||||
customerOrderNo: 'CO-1', overseasWarehouseId: 'WH-1', idempotencyKey: firstKey,
|
||||
items: [expect.objectContaining({ skuCode: 'SKU-1', newSkuCode: 'SKU-2', labelRequirement: '换SKU标' })],
|
||||
}))
|
||||
})
|
||||
|
||||
it('offers only backend-allowed audit transitions', async () => {
|
||||
vi.mocked(listRelabelOrders).mockResolvedValueOnce({
|
||||
records: [{ id: '1', relabelNo: 'RL-1', customerOrderNo: 'CO-1', skuCodes: 'SKU-1', totalPackageCount: 2, status: 'PENDING_PROCESSING' }],
|
||||
page: 1, size: 10, total: 1, pages: 1,
|
||||
})
|
||||
const wrapper = mount(RelabelOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('审核'))!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const labels = formItem(wrapper, '目标状态').findAllComponents({ name: 'ElOption' }).map((option) => option.props('label'))
|
||||
expect(labels).toEqual(expect.arrayContaining(['处理中', '已驳回', '已取消']))
|
||||
expect(labels).not.toContain('已完成')
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { Eye, Pencil, Plus } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
@@ -12,8 +13,7 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { changeRelabelOrderStatus, createRelabelOrder, listRelabelOrders } from '../api/relabelOrders'
|
||||
import { listCustomerWarehouses, type CustomerWarehouseDto } from '../api/pallets'
|
||||
import { changeRelabelOrderStatus, createRelabelOrder, listRelabelOrders, listRelabelWarehouseOptions, type RelabelWarehouseOptionDto } from '../api/relabelOrders'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
import type { RelabelOrderListItem } from '../types'
|
||||
@@ -23,7 +23,11 @@ const router = useRouter()
|
||||
const rows = ref<RelabelOrderListItem[]>([])
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const warehouses = ref<CustomerWarehouseDto[]>([])
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
const customersLoading = ref(false)
|
||||
const warehouses = ref<RelabelWarehouseOptionDto[]>([])
|
||||
const warehousesLoading = ref(false)
|
||||
const warehouseRequestSequence = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
@@ -46,35 +50,90 @@ const columns: DataTableColumn[] = [
|
||||
]
|
||||
const dialogOpen = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const form = reactive({ orderNo: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined as number | undefined, budgetTotal: undefined as number | undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined as number | undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' })
|
||||
const submitting = ref(false)
|
||||
const editSourceStatus = ref('')
|
||||
const form = reactive({ orderNo: '', customerId: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined as number | undefined, budgetTotal: undefined as number | undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined as number | undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' })
|
||||
const createIdempotencyKey = ref('')
|
||||
const allowedStatusOptions = computed(() => {
|
||||
if (editSourceStatus.value === 'PENDING_PROCESSING') return ['PROCESSING', 'REJECTED', 'CANCELLED']
|
||||
if (editSourceStatus.value === 'PROCESSING') return ['COMPLETED', 'REJECTED', 'CANCELLED']
|
||||
return []
|
||||
})
|
||||
|
||||
function optionalNumber(value: unknown) { const number = Number(value); return value === '' || value == null || !Number.isFinite(number) ? undefined : number }
|
||||
function openForm(mode: 'create' | 'edit', source?: object) {
|
||||
const row = source as RelabelOrderListItem | undefined
|
||||
dialogMode.value = mode
|
||||
Object.assign(form, { orderNo: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined, budgetTotal: undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' })
|
||||
if (mode === 'edit' && row) Object.assign(form, { orderNo: String(row.orderNo || ''), relatedOrderNo: String(row.relatedOrderNo || ''), sku: String(row.sku || ''), productName: String(row.productName || ''), cartons: optionalNumber(row.cartons), budgetTotal: optionalNumber(row.budgetTotal), status: relabelStatusCode(row.status), labelRequirement: String(row.labelRequirement || ''), relabelCost: optionalNumber(row.relabelCost), remark: String(row.remark || '') })
|
||||
if (mode === 'create') void loadWarehouses()
|
||||
Object.assign(form, { orderNo: '', customerId: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined, budgetTotal: undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' })
|
||||
editSourceStatus.value = ''
|
||||
if (mode === 'edit' && row) {
|
||||
editSourceStatus.value = relabelStatusCode(row.status)
|
||||
Object.assign(form, { orderNo: String(row.orderNo || ''), relatedOrderNo: String(row.relatedOrderNo || ''), sku: String(row.sku || ''), productName: String(row.productName || ''), cartons: optionalNumber(row.cartons), budgetTotal: optionalNumber(row.budgetTotal), status: '', labelRequirement: String(row.labelRequirement || ''), relabelCost: optionalNumber(row.relabelCost), remark: String(row.remark || '') })
|
||||
}
|
||||
if (mode === 'create') {
|
||||
createIdempotencyKey.value = globalThis.crypto?.randomUUID?.() || `admin-relabel-${Date.now()}`
|
||||
warehouses.value = []
|
||||
void loadCustomers()
|
||||
}
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
async function loadWarehouses() {
|
||||
try { warehouses.value = (await listCustomerWarehouses()).records }
|
||||
catch (error) { ElMessage.error(getApiErrorDetails(error, '海外仓列表加载失败,请重试').message) }
|
||||
async function loadCustomers() {
|
||||
customersLoading.value = true
|
||||
try {
|
||||
const records: PlatformTenantResource[] = []
|
||||
let currentPage = 1
|
||||
let pages = 1
|
||||
do {
|
||||
const result = await listPlatformTenants({ page: currentPage, size: 200, status: 'ACTIVE' })
|
||||
records.push(...result.records.filter((item) => item.status === 'ACTIVE'))
|
||||
pages = Math.max(1, result.pages)
|
||||
currentPage += 1
|
||||
} while (currentPage <= pages)
|
||||
customers.value = records
|
||||
} catch (error) { customers.value = []; ElMessage.error(getApiErrorDetails(error, '客户列表加载失败,请重试').message) }
|
||||
finally { customersLoading.value = false }
|
||||
}
|
||||
|
||||
async function handleCustomerChange(customerId: string) {
|
||||
const requestSequence = ++warehouseRequestSequence.value
|
||||
form.warehouseId = ''
|
||||
warehouses.value = []
|
||||
if (!customerId) { warehousesLoading.value = false; return }
|
||||
warehousesLoading.value = true
|
||||
try {
|
||||
const records = (await listRelabelWarehouseOptions(customerId)).records.filter((item) => item.status === 'ACTIVE')
|
||||
if (requestSequence === warehouseRequestSequence.value && customerId === form.customerId) warehouses.value = records
|
||||
} catch (error) {
|
||||
if (requestSequence === warehouseRequestSequence.value) ElMessage.error(getApiErrorDetails(error, '海外仓列表加载失败,请重试').message)
|
||||
} finally {
|
||||
if (requestSequence === warehouseRequestSequence.value) warehousesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
if (submitting.value) return
|
||||
try {
|
||||
if (dialogMode.value === 'create') {
|
||||
await createRelabelOrder({ customerOrderNo: form.relatedOrderNo, overseasWarehouseId: form.warehouseId, labelDeadline: form.labelDeadline, contactName: form.contactName, contactPhone: form.contactPhone, remark: form.remark || undefined, idempotencyKey: `admin-${Date.now()}`, items: [{ skuCode: form.sku, productNameZh: form.productName, totalPackageCount: Number(form.cartons || 0), notifiedPackageCount: Number(form.cartons || 0), shipmentNo: form.shipmentNo, newSkuCode: form.newSkuCode || form.sku, labelRequirement: form.labelRequirement }] })
|
||||
if (!form.customerId || !form.warehouseId) { ElMessage.warning('请先选择客户和该客户的海外仓'); return }
|
||||
if (!form.relatedOrderNo.trim() || !form.sku.trim() || !form.productName.trim() || !form.cartons || !form.labelDeadline || !form.contactName.trim() || !form.contactPhone.trim() || !form.shipmentNo.trim() || !form.newSkuCode.trim() || !form.labelRequirement) {
|
||||
ElMessage.warning('请完整填写换标单必填信息')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
await createRelabelOrder(form.customerId, { customerOrderNo: form.relatedOrderNo.trim(), overseasWarehouseId: form.warehouseId, labelDeadline: form.labelDeadline, contactName: form.contactName.trim(), contactPhone: form.contactPhone.trim(), remark: form.remark.trim() || undefined, idempotencyKey: createIdempotencyKey.value, items: [{ skuCode: form.sku.trim(), productNameZh: form.productName.trim(), totalPackageCount: Number(form.cartons), notifiedPackageCount: Number(form.cartons), shipmentNo: form.shipmentNo.trim(), newSkuCode: form.newSkuCode.trim(), labelRequirement: form.labelRequirement }] })
|
||||
ElMessage.success('换标单已创建')
|
||||
} else {
|
||||
if (!allowedStatusOptions.value.includes(form.status)) { ElMessage.warning('当前状态没有可提交的审核流转'); return }
|
||||
submitting.value = true
|
||||
await changeRelabelOrderStatus(form.orderNo, form.status, form.remark || undefined)
|
||||
ElMessage.success('状态已更新')
|
||||
}
|
||||
dialogOpen.value = false
|
||||
createIdempotencyKey.value = ''
|
||||
await load()
|
||||
} catch (error) { ElMessage.error(getApiErrorDetails(error, '操作失败,请重试').message) }
|
||||
finally { submitting.value = false }
|
||||
}
|
||||
|
||||
function openDetail(source: object) {
|
||||
@@ -90,6 +149,10 @@ function relabelStatusCode(value: unknown) {
|
||||
const codes: Record<string, string> = { 待处理: 'PENDING_PROCESSING', 处理中: 'PROCESSING', 已完成: 'COMPLETED', 已驳回: 'REJECTED', 已取消: 'CANCELLED' }
|
||||
return codes[String(value || '')] || String(value || 'PENDING_PROCESSING')
|
||||
}
|
||||
function canAudit(source: object) {
|
||||
const row = source as RelabelOrderListItem
|
||||
return ['PENDING_PROCESSING', 'PROCESSING'].includes(relabelStatusCode(row.status))
|
||||
}
|
||||
async function load(model: QueryModel = filters.value) {
|
||||
Object.assign(filters.value, model)
|
||||
loading.value = true; loadError.value = null
|
||||
@@ -107,6 +170,7 @@ async function load(model: QueryModel = filters.value) {
|
||||
function search(model: QueryModel) { page.value = 1; void load(model) }
|
||||
function reset(model: QueryModel) { page.value = 1; void load(model) }
|
||||
onMounted(load)
|
||||
watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -115,27 +179,28 @@ onMounted(load)
|
||||
<template v-if="loadError" #state><LoadErrorState title="换标单加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
|
||||
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-relabel-orders" row-key="orderNo" empty-text="暂无换标单" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.relabelOrderCreate)" type="primary" @click="openForm('create')"><Plus :size="16" />新增换标单</el-button></template>
|
||||
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.relabelOrderStatus)" size="small" plain @click="openForm('edit', row)"><Pencil :size="15" />审核</el-button></template>
|
||||
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.relabelOrderStatus) && canAudit(row)" size="small" plain @click="openForm('edit', row)"><Pencil :size="15" />审核</el-button></template>
|
||||
<template #pagination-left>共 {{ total }} 条换标单</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
<AppDialog v-model="dialogOpen" :title="dialogMode === 'create' ? '新增换标单' : '审核换标单'" width="min(760px, calc(100vw - 28px))" :confirm-text="dialogMode === 'create' ? '保存换标单' : '提交审核'" @confirm="submitForm">
|
||||
<AppDialog v-model="dialogOpen" :title="dialogMode === 'create' ? '新增换标单' : '审核换标单'" width="min(760px, calc(100vw - 28px))" :confirm-text="dialogMode === 'create' ? '保存换标单' : '提交审核'" :confirm-loading="submitting" @confirm="submitForm">
|
||||
<el-form label-position="top"><h3>订单信息</h3><el-row :gutter="16">
|
||||
<el-col :span="12"><el-form-item label="换标单号"><el-input :model-value="dialogMode === 'create' ? '系统自动生成' : form.orderNo || '—'" readonly /></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="关联订单" required><el-input v-model="form.relatedOrderNo" placeholder="请输入关联订单号" /></el-form-item></el-col>
|
||||
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="海外仓" required><el-select v-model="form.warehouseId" placeholder="请选择海外仓"><el-option v-for="item in warehouses" :key="item.id" :label="item.warehouseName" :value="String(item.id)" /></el-select></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="商品SKU" required><el-input v-model="form.sku" placeholder="请输入商品SKU" /></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="中文品名" required><el-input v-model="form.productName" placeholder="请输入中文品名" /></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="箱数" required><el-input-number v-model="form.cartons" :min="1" :precision="0" controls-position="right" /></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="关联订单" required><el-input v-model="form.relatedOrderNo" :disabled="dialogMode === 'edit'" placeholder="请输入关联订单号" /></el-form-item></el-col>
|
||||
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="客户" required><el-select v-model="form.customerId" filterable :loading="customersLoading" placeholder="请选择 ACTIVE 客户" @change="handleCustomerChange"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item></el-col>
|
||||
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="海外仓" required><el-select v-model="form.warehouseId" filterable :loading="warehousesLoading" :disabled="!form.customerId" placeholder="请先选择客户,再选择其海外仓"><el-option v-for="item in warehouses" :key="item.id" :label="`${item.warehouseName} · ${item.warehouseCode}`" :value="String(item.id)" /></el-select></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="商品SKU" required><el-input v-model="form.sku" :disabled="dialogMode === 'edit'" placeholder="请输入商品SKU" /></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="中文品名" required><el-input v-model="form.productName" :disabled="dialogMode === 'edit'" placeholder="请输入中文品名" /></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="箱数" required><el-input-number v-model="form.cartons" :disabled="dialogMode === 'edit'" :min="1" :precision="0" controls-position="right" /></el-form-item></el-col>
|
||||
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="截止时间" required><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></el-col>
|
||||
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="联系人" required><el-input v-model="form.contactName" /></el-form-item></el-col>
|
||||
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="联系电话" required><el-input v-model="form.contactPhone" /></el-form-item></el-col>
|
||||
<el-col v-if="dialogMode === 'edit'" :span="12"><el-form-item label="状态"><el-select v-model="form.status"><el-option v-for="value in ['PROCESSING', 'COMPLETED', 'REJECTED', 'CANCELLED']" :key="value" :label="relabelStatus(value)" :value="value" /></el-select></el-form-item></el-col>
|
||||
<el-col v-if="dialogMode === 'edit'" :span="12"><el-form-item label="目标状态"><el-select v-model="form.status" placeholder="请选择目标状态"><el-option v-for="value in allowedStatusOptions" :key="value" :label="relabelStatus(value)" :value="value" /></el-select></el-form-item></el-col>
|
||||
</el-row><h3>作业要求</h3><el-row :gutter="16">
|
||||
<el-col :span="12"><el-form-item label="贴标要求" required><el-select v-model="form.labelRequirement" placeholder="请选择贴标要求"><el-option v-for="value in ['全覆盖换标', '加贴新标', '移除原标+换标']" :key="value" :label="value" :value="value" /></el-select></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="贴标要求" required><el-select v-model="form.labelRequirement" :disabled="dialogMode === 'edit'" placeholder="请选择贴标要求"><el-option v-for="value in ['换箱唛', '换SKU标', '换箱唛和SKU标']" :key="value" :label="value" :value="value" /></el-select></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="货件号" required><el-input v-model="form.shipmentNo" :disabled="dialogMode === 'edit'" /></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="新SKU" required><el-input v-model="form.newSkuCode" :disabled="dialogMode === 'edit'" /></el-form-item></el-col>
|
||||
<el-col v-if="dialogMode === 'edit'" :span="12"><el-form-item label="预算报价(USD)"><el-input-number v-model="form.relabelCost" :min="0" :precision="2" controls-position="right" /></el-form-item></el-col>
|
||||
<el-col v-if="dialogMode === 'edit'" :span="12"><el-form-item label="预算报价(只读)"><el-input-number v-model="form.relabelCost" disabled :min="0" :precision="2" controls-position="right" /></el-form-item></el-col>
|
||||
<el-col :span="24"><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-col>
|
||||
</el-row></el-form>
|
||||
</AppDialog>
|
||||
|
||||
@@ -146,7 +146,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
expect(inventoryList).not.toContain('请先选择客户后查询')
|
||||
})
|
||||
|
||||
it('connects pallet CRUD after the overseas contract is available', () => {
|
||||
it('connects only the prototype-defined pallet create and edit actions', () => {
|
||||
const value = source('PalletsView.vue')
|
||||
expect(value).toContain('<AppDialog')
|
||||
expect(value).toContain('760px')
|
||||
@@ -156,9 +156,10 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
expect(value).toContain('placeholder="请选择海外仓"')
|
||||
expect(value).toContain('!form.warehouseId')
|
||||
expect(value).toContain('updatePallet')
|
||||
expect(value).toContain('deletePallet')
|
||||
expect(value).toContain('<el-button type="primary" @click="openCreate">')
|
||||
expect(value).not.toContain('v-if="access.can(permission.palletCreate)"')
|
||||
expect(value).not.toContain('deletePallet')
|
||||
expect(value).toContain('v-if="access.can(permission.palletCreate)"')
|
||||
expect(value).toContain('version: detail.version')
|
||||
expect(value).toContain('{ ...payload, version: form.version }')
|
||||
expect(value).toContain('v-model="form.fbaWarehouseCode"')
|
||||
expect(value).toContain("warehouseType: 'FBA'")
|
||||
expect(value).toContain('listAllPlatformWarehouses')
|
||||
|
||||
Reference in New Issue
Block a user