Merge branch 'release/test' of http://git.buggoglobal.com/html/admin-web into dev
This commit is contained in:
@@ -66,6 +66,13 @@ describe('API error diagnostics', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('translates notification recipient error codes into actionable Chinese messages', () => {
|
||||
expect(getApiErrorDetails(new ApiResponseError('NOTIFICATION_RECIPIENT_FORBIDDEN', 'A0400')).message)
|
||||
.toBe('角色编码不存在或当前账号无权使用该角色,请检查后重试')
|
||||
expect(getApiErrorDetails(new ApiResponseError('NOTIFICATION_RECIPIENT_EMPTY', 'A0400')).message)
|
||||
.toBe('该角色下暂无可接收消息的有效用户,请先为角色分配启用中的成员')
|
||||
})
|
||||
|
||||
it('preserves a backend credential error for an explicit login request', () => {
|
||||
const error = new ApiResponseError(
|
||||
'用户名或密码错误',
|
||||
|
||||
+11
-2
@@ -53,6 +53,15 @@ let authRefreshHandler: AuthRefreshHandler | null = null
|
||||
let authSessionExpiredHandler: AuthSessionExpiredHandler | null = null
|
||||
let activeRefresh: Promise<string> | null = null
|
||||
|
||||
const businessErrorMessages: Record<string, string> = {
|
||||
NOTIFICATION_RECIPIENT_FORBIDDEN: '角色编码不存在或当前账号无权使用该角色,请检查后重试',
|
||||
NOTIFICATION_RECIPIENT_EMPTY: '该角色下暂无可接收消息的有效用户,请先为角色分配启用中的成员',
|
||||
}
|
||||
|
||||
function friendlyBusinessMessage(message?: string | null) {
|
||||
return (message && businessErrorMessages[message]) || message || undefined
|
||||
}
|
||||
|
||||
export function registerAuthRefreshHandler(handler: AuthRefreshHandler) {
|
||||
authRefreshHandler = handler
|
||||
}
|
||||
@@ -134,7 +143,7 @@ export function getApiErrorDetails(
|
||||
? '当前账号无权访问该数据,请联系管理员确认权限'
|
||||
: error.code.startsWith('S05')
|
||||
? '服务暂时不可用,请稍后重试'
|
||||
: error.message
|
||||
: friendlyBusinessMessage(error.message) || '接口请求失败'
|
||||
return { message, code: error.code, requestId: error.requestId, traceId: error.traceId }
|
||||
}
|
||||
if (axios.isAxiosError(error)) {
|
||||
@@ -156,7 +165,7 @@ export function getApiErrorDetails(
|
||||
? '服务暂时不可用,请稍后重试'
|
||||
: undefined
|
||||
return {
|
||||
message: statusMessage || response?.message || response?.error || fallback,
|
||||
message: statusMessage || friendlyBusinessMessage(response?.message || response?.error) || fallback,
|
||||
code: response?.code || (error.response?.status ? `HTTP_${error.response.status}` : undefined),
|
||||
requestId: response?.requestId || undefined,
|
||||
traceId: response?.traceId || undefined,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from './http'
|
||||
import { overseasHttp } from './overseasHttp'
|
||||
import { getLoadingAssociationSummary, getOverseasInboundCodeRelationship, getPlatformInboundCodeAssociation } from './inboundCodeRelations'
|
||||
|
||||
vi.mock('./http', async (original) => ({ ...(await original<typeof import('./http')>()), http: { get: vi.fn() } }))
|
||||
vi.mock('./overseasHttp', () => ({ overseasHttp: { get: vi.fn() } }))
|
||||
|
||||
describe('inbound-code relationship APIs', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('uses the frozen domestic and overseas read models and encodes the business code', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { inboundCode: 'IN/01', orderNo: 'ORD-1' } })
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { inboundCode: 'IN/01', found: false, taillegOrders: [], inboundPlans: [], palletCodes: [], inventory: [] } })
|
||||
await getPlatformInboundCodeAssociation('IN/01')
|
||||
await getLoadingAssociationSummary('IN/01')
|
||||
await getOverseasInboundCodeRelationship('IN/01')
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/orders/first-leg/inbound-code-associations/IN%2F01')
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/first-leg/loading-associations/summary', { params: { inboundCode: 'IN/01' } })
|
||||
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/inventory/inbound-code-relationships/IN%2F01')
|
||||
})
|
||||
|
||||
it('does not accept customer or internal ids as lookup parameters', () => {
|
||||
const source = `${getPlatformInboundCodeAssociation}${getLoadingAssociationSummary}${getOverseasInboundCodeRelationship}`
|
||||
expect(source).not.toMatch(/customerId|inboundId|orderId|warehouseId/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from './http'
|
||||
import { overseasHttp } from './overseasHttp'
|
||||
|
||||
export interface PlatformInboundCodeAssociation {
|
||||
inboundCode: string
|
||||
orderNo: string
|
||||
orderStatus?: string | null
|
||||
orderId: string
|
||||
customerId: string
|
||||
customerName: string
|
||||
destinationType?: string | null
|
||||
destinationCountryCode?: string | null
|
||||
destinationWarehouseId?: string | null
|
||||
destinationWarehouseCode?: string | null
|
||||
destinationWarehouseName?: string | null
|
||||
}
|
||||
|
||||
export interface OverseasInboundCodeRelationship {
|
||||
inboundCode: string
|
||||
found: boolean
|
||||
customer: { customerId: string; customerName: string } | null
|
||||
overseasWarehouse: { warehouseId: string; warehouseCode: string; warehouseName: string } | null
|
||||
overseasReceivingStatus?: string | null
|
||||
taillegOrders: Array<{ orderNo: string; orderStatus?: string | null }>
|
||||
inboundPlans: Array<{ planNo: string; planStatus?: string | null; receivingStatus?: string | null }>
|
||||
palletCodes: string[]
|
||||
inventory: Array<{ skuCode: string; productNameZh?: string | null; productNameEn?: string | null; packageCount: number }>
|
||||
}
|
||||
|
||||
export interface LoadingAssociationSummary {
|
||||
inboundId: string
|
||||
inboundCode: string
|
||||
orderNo?: string | null
|
||||
loadingPlanId?: string | null
|
||||
loadingPlanNo?: string | null
|
||||
containerBookingId?: string | null
|
||||
cabinetNo?: string | null
|
||||
containerNo?: string | null
|
||||
loadingStatus?: string | null
|
||||
taskId?: string | null
|
||||
pdaTaskStatus?: string | null
|
||||
coLoadGroupId?: string | null
|
||||
coLoadGroupNo?: string | null
|
||||
items: Array<{ itemId: string }>
|
||||
}
|
||||
|
||||
export async function getPlatformInboundCodeAssociation(inboundCode: string) {
|
||||
const response = await http.get<ApiEnvelope<PlatformInboundCodeAssociation>, ApiEnvelope<PlatformInboundCodeAssociation>>(
|
||||
`/platform/v1/orders/first-leg/inbound-code-associations/${encodeURIComponent(inboundCode)}`,
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function getLoadingAssociationSummary(inboundCode: string) {
|
||||
const response = await http.get<ApiEnvelope<LoadingAssociationSummary>, ApiEnvelope<LoadingAssociationSummary>>(
|
||||
'/platform/v1/first-leg/loading-associations/summary',
|
||||
{ params: { inboundCode } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function getLoadingAssociationSummaryByInboundId(inboundId: string) {
|
||||
const response = await http.get<ApiEnvelope<LoadingAssociationSummary>, ApiEnvelope<LoadingAssociationSummary>>(
|
||||
'/platform/v1/first-leg/loading-associations/summary',
|
||||
{ params: { inboundId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function getOverseasInboundCodeRelationship(inboundCode: string) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<OverseasInboundCodeRelationship>, ApiEnvelope<OverseasInboundCodeRelationship>>(
|
||||
`/platform/v1/last-leg/inventory/inbound-code-relationships/${encodeURIComponent(inboundCode)}`,
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
@@ -6,14 +6,14 @@ import LayoutPreferenceList from './LayoutPreferenceList.vue'
|
||||
import { UI_PREFERENCE_SCOPE, preferenceStorageKey } from '@/shared/utils/uiLayoutPreference'
|
||||
|
||||
const TableStub = defineComponent({
|
||||
props: { border: Boolean, stripe: Boolean, maxHeight: [String, Number] },
|
||||
template: '<div class="table-stub" :data-border="String(border)" :data-stripe="String(stripe)" :data-max-height="String(maxHeight)"><slot /></div>',
|
||||
props: { border: Boolean, stripe: Boolean, maxHeight: [String, Number], emptyText: String },
|
||||
template: '<div class="table-stub" :data-border="String(border)" :data-stripe="String(stripe)" :data-max-height="String(maxHeight)" :data-empty-text="emptyText"><slot /></div>',
|
||||
methods: { doLayout() {} },
|
||||
})
|
||||
const ColumnStub = defineComponent({
|
||||
inheritAttrs: false,
|
||||
props: { label: String, prop: String },
|
||||
template: '<div class="column-stub" :data-label="label" :data-prop="prop"><slot /></div>',
|
||||
props: { label: String, prop: String, fixed: [String, Boolean], showOverflowTooltip: Boolean },
|
||||
template: '<div class="column-stub" :data-label="label" :data-prop="prop" :data-fixed="String(fixed || \'\')" :data-overflow-tooltip="String(showOverflowTooltip)"><slot /></div>',
|
||||
})
|
||||
const ButtonStub = defineComponent({ template: '<button type="button"><slot /></button>' })
|
||||
const PopoverStub = defineComponent({ template: '<div><slot name="reference" /><slot /></div>' })
|
||||
@@ -128,6 +128,30 @@ describe('DataTable column preferences', () => {
|
||||
expect(customTable.get('.table-stub').attributes('data-max-height')).toBe('420')
|
||||
})
|
||||
|
||||
it('keeps fixed actions, long text, empty state, and pagination in the shared list contract', () => {
|
||||
const wrapper = shallowMount(DataTable, {
|
||||
props: {
|
||||
data: [],
|
||||
columns: [
|
||||
{ key: 'actions', label: '操作', fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'description', label: '长文本', showOverflowTooltip: true },
|
||||
],
|
||||
emptyText: '暂无符合条件的数据',
|
||||
total: 0,
|
||||
},
|
||||
global: {
|
||||
stubs: { ElTable: TableStub, ElTableColumn: ColumnStub, ElButton: ButtonStub, ElPopover: PopoverStub },
|
||||
directives: { loading: {} },
|
||||
},
|
||||
})
|
||||
|
||||
const columns = wrapper.findAll('.column-stub')
|
||||
expect(columns[0].attributes('data-fixed')).toBe('left')
|
||||
expect(columns[1].attributes('data-overflow-tooltip')).toBe('true')
|
||||
expect(wrapper.get('.table-stub').attributes('data-empty-text')).toBe('暂无符合条件的数据')
|
||||
expect(wrapper.get('.data-table__pagination').text()).toContain('共 0 条')
|
||||
})
|
||||
|
||||
it('uses key as the data property for ordinary columns only', () => {
|
||||
const wrapper = shallowMount(DataTable, {
|
||||
props: {
|
||||
|
||||
@@ -358,6 +358,10 @@ function setPage(value: number) {
|
||||
|
||||
<style scoped>
|
||||
.data-table { min-width: 0; }
|
||||
.data-table :deep(.el-table) { font-size: var(--cc-list-table-font-size); }
|
||||
.data-table :deep(.el-table th.el-table__cell) { font-size: var(--cc-list-table-font-size); font-weight: var(--cc-list-table-header-font-weight); }
|
||||
.data-table :deep(.el-table .el-button) { font-size: var(--cc-list-action-font-size); }
|
||||
.data-table :deep(.el-table__empty-text) { font-size: var(--cc-list-table-font-size); }
|
||||
.data-table__toolbar { display: flex; min-height: 72px; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 18px; border-bottom: 1px solid var(--cc-border); }
|
||||
.data-table__toolbar.is-compact { min-height: 56px; justify-content: flex-end; padding: 8px 14px; }
|
||||
.data-table__toolbar-start { min-width: 0; flex: 1; }
|
||||
@@ -367,13 +371,13 @@ function setPage(value: number) {
|
||||
.data-table__business-actions, .data-table__utility-actions, .data-table__toolbar-end { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||
.data-table__utility-actions { flex: 0 0 auto; }
|
||||
.data-table__toolbar-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.data-table__pagination { display: flex; min-height: 58px; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 18px; border-top: 1px solid var(--cc-border); color: var(--cc-muted); font-size: 12px; }
|
||||
.data-table__pagination { display: flex; min-height: 58px; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 18px; border-top: 1px solid var(--cc-border); color: var(--cc-muted); font-size: var(--cc-list-pagination-font-size); }
|
||||
.data-table__pagination-left { display: flex; min-width: 0; flex: 1; align-items: center; gap: 8px; }
|
||||
.data-table__pagination-controls, .data-table__page-size, .data-table__page-buttons { display: flex; align-items: center; gap: 8px; }
|
||||
.data-table__pagination-controls { min-width: 0; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.data-table__page-size :deep(.el-select) { width: 82px; }
|
||||
.data-table__page-buttons { gap: 4px; }
|
||||
.data-table__page-button { width: 32px; min-width: 32px; height: 32px; padding: 0; margin-left: 0; }
|
||||
.data-table__page-button { width: 32px; min-width: 32px; height: 32px; padding: 0; margin-left: 0; font-size: var(--cc-list-page-button-font-size); }
|
||||
.data-table__page-ellipsis { display: inline-flex; width: 24px; height: 32px; align-items: center; justify-content: center; }
|
||||
|
||||
@media (min-width: 1920px) {
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { defineComponent } from 'vue'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ElementPlus from 'element-plus'
|
||||
import {
|
||||
getLoadingAssociationSummary,
|
||||
getOverseasInboundCodeRelationship,
|
||||
getPlatformInboundCodeAssociation,
|
||||
} from '@/shared/api/inboundCodeRelations'
|
||||
import InboundCodeRelationshipDialog from './InboundCodeRelationshipDialog.vue'
|
||||
|
||||
vi.mock('@/shared/api/inboundCodeRelations', () => ({
|
||||
getLoadingAssociationSummary: vi.fn(),
|
||||
getOverseasInboundCodeRelationship: vi.fn(),
|
||||
getPlatformInboundCodeAssociation: vi.fn(),
|
||||
}))
|
||||
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/shared/components/InboundCodeRelationshipDialog.vue'), 'utf8')
|
||||
const AppDialogStub = defineComponent({
|
||||
props: { modelValue: Boolean, title: String, loading: Boolean },
|
||||
emits: ['update:modelValue'],
|
||||
template: '<aside><h2>{{ title }}</h2><slot /></aside>',
|
||||
})
|
||||
|
||||
function mountDialog() {
|
||||
return mount(InboundCodeRelationshipDialog, {
|
||||
props: {
|
||||
modelValue: true,
|
||||
inboundCode: 'NYEXX2608300027',
|
||||
canReadBase: true,
|
||||
canReadLoading: true,
|
||||
canReadOverseas: true,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
AppDialog: AppDialogStub,
|
||||
ApiErrorMeta: true,
|
||||
LoadErrorState: { props: ['title', 'message'], template: '<div>{{ title }} · {{ message }}</div>' },
|
||||
StatusBadge: true,
|
||||
'el-alert': true,
|
||||
'el-empty': { props: ['description'], template: '<p>{{ description }}</p>' },
|
||||
'el-table': { props: ['data'], template: '<div>{{ data[0]?.skuCode }}<slot /></div>' },
|
||||
'el-table-column': true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('InboundCodeRelationshipDialog contract', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(getPlatformInboundCodeAssociation).mockResolvedValue({
|
||||
inboundCode: 'NYEXX2608300027', orderNo: 'FL2093975748931063810', orderStatus: 'APPROVED',
|
||||
orderId: '90071992547409931', customerId: '90071992547409932', customerName: '纽约客户',
|
||||
destinationType: 'FBA', destinationWarehouseId: '90071992547409933', destinationWarehouseCode: 'ABE3', destinationWarehouseName: '亚马逊 ABE3 仓',
|
||||
})
|
||||
vi.mocked(getLoadingAssociationSummary).mockResolvedValue({
|
||||
inboundId: '90071992547409934', inboundCode: 'NYEXX2608300027', orderNo: 'FL2093975748931063810',
|
||||
loadingPlanId: '90071992547409935', loadingPlanNo: 'LP-260830-0016', containerBookingId: '90071992547409936',
|
||||
cabinetNo: 'CAB-260830-0016', containerNo: null, loadingStatus: 'LOADING', taskId: '90071992547409937',
|
||||
pdaTaskStatus: 'IN_PROGRESS', coLoadGroupId: null, coLoadGroupNo: null, items: [{ itemId: '90071992547409938' }],
|
||||
})
|
||||
vi.mocked(getOverseasInboundCodeRelationship).mockResolvedValue({
|
||||
inboundCode: 'NYEXX2608300027', found: true,
|
||||
customer: { customerId: '90071992547409932', customerName: '纽约客户' },
|
||||
overseasWarehouse: { warehouseId: '90071992547409939', warehouseCode: 'LAX', warehouseName: '洛杉矶海外仓' },
|
||||
overseasReceivingStatus: 'RECEIVING', taillegOrders: [{ orderNo: 'LL-260830-0001', orderStatus: 'PENDING' }],
|
||||
inboundPlans: [{ planNo: 'IP-260830-0001', planStatus: 'READY', receivingStatus: 'WAITING' }],
|
||||
palletCodes: ['PLT-LAX-001'], inventory: [{ skuCode: 'SKU-001', productNameZh: '测试商品', packageCount: 12 }],
|
||||
})
|
||||
})
|
||||
|
||||
it('loads all three sections on an initially open mount and reloads after closing and reopening', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
|
||||
expect(getPlatformInboundCodeAssociation).toHaveBeenCalledWith('NYEXX2608300027')
|
||||
expect(getLoadingAssociationSummary).toHaveBeenCalledWith('NYEXX2608300027')
|
||||
expect(getOverseasInboundCodeRelationship).toHaveBeenCalledWith('NYEXX2608300027')
|
||||
expect(wrapper.text()).toContain('FL2093975748931063810')
|
||||
expect(wrapper.text()).toContain('LP-260830-0016')
|
||||
expect(wrapper.text()).toContain('CAB-260830-0016')
|
||||
expect(wrapper.text()).toContain('纽约客户')
|
||||
expect(wrapper.text()).toContain('洛杉矶海外仓')
|
||||
expect(wrapper.text()).toContain('LL-260830-0001')
|
||||
expect(wrapper.text()).toContain('IP-260830-0001')
|
||||
expect(wrapper.text()).toContain('PLT-LAX-001')
|
||||
expect(wrapper.text()).toContain('SKU-001')
|
||||
expect(wrapper.text()).not.toMatch(/9007199254740993[1-9]/)
|
||||
expect(wrapper.find('.relationship-sections').exists()).toBe(true)
|
||||
expect(wrapper.findAll('.relationship-section')).toHaveLength(3)
|
||||
expect(wrapper.findAll('.relationship-status')).toHaveLength(3)
|
||||
|
||||
await wrapper.setProps({ modelValue: false })
|
||||
await wrapper.setProps({ modelValue: true })
|
||||
await flushPromises()
|
||||
expect(getPlatformInboundCodeAssociation).toHaveBeenCalledTimes(2)
|
||||
expect(getLoadingAssociationSummary).toHaveBeenCalledTimes(2)
|
||||
expect(getOverseasInboundCodeRelationship).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps a server-declared overseas miss as a local empty state', async () => {
|
||||
vi.mocked(getOverseasInboundCodeRelationship).mockResolvedValueOnce({
|
||||
inboundCode: 'NYEXX2608300027', found: false, customer: null, overseasWarehouse: null,
|
||||
overseasReceivingStatus: null, taillegOrders: [], inboundPlans: [], palletCodes: [], inventory: [],
|
||||
})
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('服务端未找到该入仓码的海外关联数据')
|
||||
expect(wrapper.text()).toContain('FL2093975748931063810')
|
||||
expect(wrapper.text()).toContain('LP-260830-0016')
|
||||
})
|
||||
|
||||
it('keeps a failed section local while the other successful sections stay visible', async () => {
|
||||
vi.mocked(getOverseasInboundCodeRelationship).mockRejectedValueOnce(new Error('overseas unavailable'))
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('海外关联加载失败')
|
||||
expect(wrapper.text()).toContain('FL2093975748931063810')
|
||||
expect(wrapper.text()).toContain('LP-260830-0016')
|
||||
expect(wrapper.text()).not.toContain('洛杉矶海外仓')
|
||||
})
|
||||
|
||||
it('distinguishes an unassigned overseas warehouse from a missing warehouse name', async () => {
|
||||
vi.mocked(getOverseasInboundCodeRelationship).mockResolvedValueOnce({
|
||||
inboundCode: 'NYEXX2608300027', found: true,
|
||||
customer: { customerId: '90071992547409932', customerName: '纽约客户' }, overseasWarehouse: null,
|
||||
overseasReceivingStatus: 'PENDING_ASSIGNMENT', taillegOrders: [], inboundPlans: [], palletCodes: [], inventory: [],
|
||||
})
|
||||
const unassigned = mountDialog()
|
||||
await flushPromises()
|
||||
expect(unassigned.text()).toContain('尚未分配海外仓')
|
||||
expect(unassigned.text()).not.toContain('仓库名称待补充')
|
||||
unassigned.unmount()
|
||||
|
||||
vi.mocked(getOverseasInboundCodeRelationship).mockResolvedValueOnce({
|
||||
inboundCode: 'NYEXX2608300027', found: true,
|
||||
customer: { customerId: '90071992547409932', customerName: '纽约客户' },
|
||||
overseasWarehouse: { warehouseId: '90071992547409939', warehouseCode: 'LAX', warehouseName: '' },
|
||||
overseasReceivingStatus: 'PENDING_ASSIGNMENT', taillegOrders: [], inboundPlans: [], palletCodes: [], inventory: [],
|
||||
})
|
||||
const missingName = mountDialog()
|
||||
await flushPromises()
|
||||
expect(missingName.text()).toContain('仓库名称待补充')
|
||||
expect(missingName.text()).not.toContain('尚未分配海外仓')
|
||||
missingName.unmount()
|
||||
})
|
||||
|
||||
it('loads only permission-authorized server read models and keeps failures isolated', () => {
|
||||
expect(source).toContain('props.canReadBase')
|
||||
expect(source).toContain('props.canReadLoading')
|
||||
expect(source).toContain('props.canReadOverseas')
|
||||
expect(source).toContain('getPlatformInboundCodeAssociation(code)')
|
||||
expect(source).toContain('getLoadingAssociationSummary(code)')
|
||||
expect(source).toContain('getOverseasInboundCodeRelationship(code)')
|
||||
expect(source).toContain('await Promise.all(tasks)')
|
||||
expect(source).toContain('{ immediate: true }')
|
||||
expect(source).toContain('<ApiErrorMeta :details="baseError"')
|
||||
expect(source).toContain('<ApiErrorMeta :details="overseasError"')
|
||||
expect(source).toContain('服务端未找到该入仓码的海外关联数据')
|
||||
expect(source).not.toMatch(/listPlatform|customerId\)|warehouseId\)/)
|
||||
})
|
||||
|
||||
it('shows business numbers and names without rendering internal ids', () => {
|
||||
expect(source).toContain('baseAssociation.inboundCode')
|
||||
expect(source).toContain('baseAssociation.orderNo')
|
||||
expect(source).toContain('baseAssociation.customerName')
|
||||
expect(source).toContain('baseAssociation.destinationWarehouseName')
|
||||
expect(source).not.toContain('{{ baseAssociation.orderId')
|
||||
expect(source).not.toContain('{{ baseAssociation.customerId')
|
||||
expect(source).not.toContain('{{ baseAssociation.destinationWarehouseId')
|
||||
expect(source).toContain('text-overflow:ellipsis')
|
||||
expect(source).toContain(':title="display(baseAssociation.inboundCode)"')
|
||||
})
|
||||
|
||||
it('keeps sections and status badges within the dialog at desktop and narrow widths', () => {
|
||||
expect(source).toContain('class="inbound-relationship-dialog"')
|
||||
expect(source).toContain('width="min(1060px, 94vw)"')
|
||||
expect(source).toContain(':global(.inbound-relationship-dialog .el-dialog__body){box-sizing:border-box;min-width:0;max-height:calc(90vh - 112px);overflow-x:hidden;overflow-y:auto;padding-right:28px}')
|
||||
expect(source).toContain(':global(.inbound-relationship-dialog .app-dialog__body){box-sizing:border-box;width:auto;max-width:100%;min-width:0;overflow-x:hidden}')
|
||||
expect(source).toContain('.relationship-sections{display:grid;width:auto;max-width:100%;min-width:0;grid-template-columns:minmax(0,1fr)')
|
||||
expect(source).toContain('.relationship-section{box-sizing:border-box;width:100%;min-width:0')
|
||||
expect(source).toContain('flex-wrap:wrap')
|
||||
expect(source).toContain('.relationship-section h3{flex:1 1 auto;min-width:0')
|
||||
expect(source).toContain('.relationship-status{flex:0 0 auto;max-width:100%}')
|
||||
expect(source).toContain('.inventory-table{width:100%;max-width:100%;min-width:0')
|
||||
})
|
||||
|
||||
it('mounts the containment class through the real AppDialog and Element Plus dialog DOM', async () => {
|
||||
const wrapper = mount(InboundCodeRelationshipDialog, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
modelValue: true,
|
||||
inboundCode: 'NYEXX2608300027',
|
||||
canReadBase: true,
|
||||
canReadLoading: true,
|
||||
canReadOverseas: true,
|
||||
},
|
||||
global: { plugins: [ElementPlus] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const dialog = document.querySelector('.inbound-relationship-dialog')
|
||||
const elementBody = dialog?.querySelector('.el-dialog__body')
|
||||
const appBody = dialog?.querySelector('.app-dialog__body')
|
||||
expect(dialog).not.toBeNull()
|
||||
expect(elementBody).not.toBeNull()
|
||||
expect(appBody).not.toBeNull()
|
||||
expect(appBody?.querySelectorAll('.relationship-section')).toHaveLength(3)
|
||||
expect(appBody?.querySelectorAll('.relationship-status')).toHaveLength(3)
|
||||
expect(dialog?.querySelector('.el-drawer__body')).toBeNull()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('removes the retired Drawer component and all Admin consumer references', () => {
|
||||
const root = process.cwd()
|
||||
expect(existsSync(resolve(root, 'src/shared/components/InboundCodeRelationshipDrawer.vue'))).toBe(false)
|
||||
expect(existsSync(resolve(root, 'src/shared/components/InboundCodeRelationshipDrawer.test.ts'))).toBe(false)
|
||||
const dialogConsumerFiles = [
|
||||
'src/modules/inbounds/pages/InboundOrdersView.vue',
|
||||
'src/modules/inbounds/components/InboundTaskList.vue',
|
||||
'src/modules/overseas-warehouse/pages/InventoryTransactionsView.vue',
|
||||
]
|
||||
const consumerSources = dialogConsumerFiles.map((file) => readFileSync(resolve(root, file), 'utf8'))
|
||||
const consumers = consumerSources.join('\n')
|
||||
expect(consumers).not.toContain('InboundCodeRelationshipDrawer')
|
||||
expect(consumers).not.toMatch(/relationshipOpen|relationshipInboundCode|inboundRelationshipOpen/)
|
||||
consumerSources.forEach((consumer) => {
|
||||
expect(consumer).toContain("import InboundCodeRelationshipDialog from '@/shared/components/InboundCodeRelationshipDialog.vue'")
|
||||
expect(consumer).toContain('<InboundCodeRelationshipDialog')
|
||||
})
|
||||
|
||||
const unifiedOrders = readFileSync(resolve(root, 'src/modules/unified-orders/pages/UnifiedOrdersView.vue'), 'utf8')
|
||||
expect(unifiedOrders).not.toContain('InboundCodeRelationshipDrawer')
|
||||
expect(unifiedOrders).not.toContain('InboundCodeRelationshipDialog')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getLoadingAssociationSummary, getOverseasInboundCodeRelationship, getPlatformInboundCodeAssociation, type LoadingAssociationSummary, type OverseasInboundCodeRelationship, type PlatformInboundCodeAssociation } from '@/shared/api/inboundCodeRelations'
|
||||
import ApiErrorMeta from './ApiErrorMeta.vue'
|
||||
import AppDialog from './AppDialog.vue'
|
||||
import LoadErrorState from './LoadErrorState.vue'
|
||||
import StatusBadge from './StatusBadge.vue'
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; inboundCode: string; canReadBase: boolean; canReadLoading: boolean; canReadOverseas: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
const baseAssociation = ref<PlatformInboundCodeAssociation | null>(null)
|
||||
const overseas = ref<OverseasInboundCodeRelationship | null>(null)
|
||||
const loadingAssociation = ref<LoadingAssociationSummary | null>(null)
|
||||
const baseError = ref<ApiErrorDetails | null>(null)
|
||||
const overseasError = ref<ApiErrorDetails | null>(null)
|
||||
const loadingAssociationError = ref<ApiErrorDetails | null>(null)
|
||||
const loading = ref(false)
|
||||
let serial = 0
|
||||
|
||||
const canReadBase = computed(() => props.canReadBase)
|
||||
const canReadLoading = computed(() => props.canReadLoading)
|
||||
const canReadOverseas = computed(() => props.canReadOverseas)
|
||||
const normalizedCode = computed(() => props.inboundCode.trim())
|
||||
|
||||
function display(value: unknown, fallback = '—') { return String(value ?? '').trim() || fallback }
|
||||
function overseasWarehouseName(value: OverseasInboundCodeRelationship['overseasWarehouse']) {
|
||||
return value ? display(value.warehouseName, '仓库名称待补充') : '尚未分配海外仓'
|
||||
}
|
||||
function close(value: boolean) { emit('update:modelValue', value) }
|
||||
|
||||
async function load() {
|
||||
const code = normalizedCode.value
|
||||
const request = ++serial
|
||||
baseAssociation.value = null; overseas.value = null; loadingAssociation.value = null; baseError.value = null; overseasError.value = null; loadingAssociationError.value = null
|
||||
if (!code || (!canReadBase.value && !canReadLoading.value && !canReadOverseas.value)) return
|
||||
loading.value = true
|
||||
const tasks: Promise<void>[] = []
|
||||
if (canReadBase.value) tasks.push(getPlatformInboundCodeAssociation(code).then((value) => { if (request === serial) baseAssociation.value = value }).catch((cause) => { if (request === serial) baseError.value = getApiErrorDetails(cause, '订单基础关联数据加载失败') }))
|
||||
if (canReadLoading.value) tasks.push(getLoadingAssociationSummary(code).then((value) => { if (request === serial) loadingAssociation.value = value }).catch((cause) => { if (request === serial) loadingAssociationError.value = getApiErrorDetails(cause, '装柜与PDA关联数据加载失败') }))
|
||||
if (canReadOverseas.value) tasks.push(getOverseasInboundCodeRelationship(code).then((value) => { if (request === serial) overseas.value = value }).catch((cause) => { if (request === serial) overseasError.value = getApiErrorDetails(cause, '海外入仓关联数据加载失败') }))
|
||||
await Promise.all(tasks)
|
||||
if (request === serial) loading.value = false
|
||||
}
|
||||
|
||||
watch(() => [props.modelValue, props.inboundCode, props.canReadBase, props.canReadLoading, props.canReadOverseas] as const, ([open]) => {
|
||||
if (open) void load()
|
||||
else serial += 1
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog class="inbound-relationship-dialog" :model-value="modelValue" :title="`入仓码关联数据 · ${normalizedCode || '未提供'}`" width="min(1060px, 94vw)" :loading="loading" :show-footer="false" destroy-on-close @update:model-value="close">
|
||||
<el-alert v-if="!normalizedCode" type="warning" :closable="false" show-icon title="缺少入仓码,已停止查询关联数据。" />
|
||||
<el-alert v-else-if="!canReadBase && !canReadLoading && !canReadOverseas" type="warning" :closable="false" show-icon title="当前账号无入仓关联数据查看权限。" />
|
||||
|
||||
<div v-else class="relationship-sections">
|
||||
<section v-if="canReadBase" class="relationship-section" aria-label="订单基础关联">
|
||||
<header><h3>订单基础信息</h3><StatusBadge v-if="baseAssociation?.orderStatus" class="relationship-status" :status="baseAssociation.orderStatus" /></header>
|
||||
<LoadErrorState v-if="baseError" title="订单基础关联加载失败" :message="baseError.message" @retry="load"><template #details><ApiErrorMeta :details="baseError" /></template></LoadErrorState>
|
||||
<dl v-else-if="baseAssociation" class="compact-facts">
|
||||
<div><dt>入仓码</dt><dd :title="display(baseAssociation.inboundCode)">{{ display(baseAssociation.inboundCode) }}</dd></div><div><dt>订单编码</dt><dd :title="display(baseAssociation.orderNo, '订单编码待补充')">{{ display(baseAssociation.orderNo, '订单编码待补充') }}</dd></div>
|
||||
<div><dt>客户</dt><dd :title="display(baseAssociation.customerName, '客户名称待补充')">{{ display(baseAssociation.customerName, '客户名称待补充') }}</dd></div><div><dt>目的类型</dt><dd>{{ display(baseAssociation.destinationType) }}</dd></div>
|
||||
<div><dt>目的国家</dt><dd>{{ display(baseAssociation.destinationCountryCode) }}</dd></div><div><dt>目的仓</dt><dd>{{ display(baseAssociation.destinationWarehouseName, '目的仓名称待补充') }}<small v-if="baseAssociation.destinationWarehouseCode">{{ baseAssociation.destinationWarehouseCode }}</small></dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section v-if="canReadLoading" class="relationship-section" aria-label="装柜与PDA关联">
|
||||
<header><h3>装柜与PDA</h3><StatusBadge v-if="loadingAssociation?.loadingStatus" class="relationship-status" :status="loadingAssociation.loadingStatus" /></header>
|
||||
<LoadErrorState v-if="loadingAssociationError" title="装柜关联加载失败" :message="loadingAssociationError.message" @retry="load"><template #details><ApiErrorMeta :details="loadingAssociationError" /></template></LoadErrorState>
|
||||
<dl v-else-if="loadingAssociation" class="compact-facts">
|
||||
<div><dt>订单编码</dt><dd :title="display(loadingAssociation.orderNo, '订单编码待补充')">{{ display(loadingAssociation.orderNo, '订单编码待补充') }}</dd></div><div><dt>装柜计划</dt><dd :title="display(loadingAssociation.loadingPlanNo, '尚未进入装柜')">{{ display(loadingAssociation.loadingPlanNo, '尚未进入装柜') }}</dd></div>
|
||||
<div><dt>柜号</dt><dd :title="display(loadingAssociation.containerNo || loadingAssociation.cabinetNo, '尚未关联柜号')">{{ display(loadingAssociation.containerNo || loadingAssociation.cabinetNo, '尚未关联柜号') }}</dd></div><div><dt>同柜组</dt><dd :title="display(loadingAssociation.coLoadGroupNo)">{{ display(loadingAssociation.coLoadGroupNo) }}</dd></div>
|
||||
<div><dt>装柜状态</dt><dd>{{ display(loadingAssociation.loadingStatus) }}</dd></div><div><dt>PDA任务状态</dt><dd>{{ display(loadingAssociation.pdaTaskStatus, '尚未生成PDA任务') }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section v-if="canReadOverseas" class="relationship-section" aria-label="海外入仓关联">
|
||||
<header><h3>海外入仓与库存</h3><StatusBadge v-if="overseas?.overseasReceivingStatus" class="relationship-status" :status="overseas.overseasReceivingStatus" /></header>
|
||||
<LoadErrorState v-if="overseasError" title="海外关联加载失败" :message="overseasError.message" @retry="load"><template #details><ApiErrorMeta :details="overseasError" /></template></LoadErrorState>
|
||||
<el-empty v-else-if="overseas && !overseas.found" description="服务端未找到该入仓码的海外关联数据" :image-size="56" />
|
||||
<template v-else-if="overseas">
|
||||
<dl class="compact-facts"><div><dt>客户</dt><dd>{{ display(overseas.customer?.customerName, '客户名称待补充') }}</dd></div><div><dt>海外仓</dt><dd>{{ overseasWarehouseName(overseas.overseasWarehouse) }}<small v-if="overseas.overseasWarehouse?.warehouseCode">{{ overseas.overseasWarehouse.warehouseCode }}</small></dd></div></dl>
|
||||
<div class="compact-list"><strong>尾程订单</strong><span :title="overseas.taillegOrders.map((item) => item.orderNo).join('、')">{{ overseas.taillegOrders.map((item) => `${item.orderNo}${item.orderStatus ? `(${item.orderStatus})` : ''}`).filter(Boolean).join('、') || '—' }}</span></div>
|
||||
<div class="compact-list"><strong>入库计划</strong><span :title="overseas.inboundPlans.map((item) => item.planNo).join('、')">{{ overseas.inboundPlans.map((item) => `${item.planNo}${item.planStatus ? `(${item.planStatus})` : ''}${item.receivingStatus ? ` · ${item.receivingStatus}` : ''}`).join('、') || '—' }}</span></div>
|
||||
<div class="compact-list"><strong>托盘号</strong><span :title="overseas.palletCodes.join('、')">{{ overseas.palletCodes.join('、') || '—' }}</span></div>
|
||||
<el-table v-if="overseas.inventory.length" :data="overseas.inventory" size="small" border class="inventory-table">
|
||||
<el-table-column prop="skuCode" label="SKU编码" min-width="150" /><el-table-column label="产品名称" min-width="190"><template #default="{ row }">{{ display(row.productNameZh || row.productNameEn) }}</template></el-table-column><el-table-column prop="packageCount" label="库存箱数" width="110" align="right" />
|
||||
</el-table>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:global(.inbound-relationship-dialog.el-dialog){width:min(1060px,94vw);max-width:94vw}:global(.inbound-relationship-dialog .el-dialog__body){box-sizing:border-box;min-width:0;max-height:calc(90vh - 112px);overflow-x:hidden;overflow-y:auto;padding-right:28px}:global(.inbound-relationship-dialog .app-dialog__body){box-sizing:border-box;width:auto;max-width:100%;min-width:0;overflow-x:hidden}.relationship-sections{display:grid;width:auto;max-width:100%;min-width:0;grid-template-columns:minmax(0,1fr);gap:12px}.relationship-section{box-sizing:border-box;width:100%;min-width:0;border:1px solid var(--cc-border);border-radius:8px;padding:12px;background:var(--cc-surface)}.relationship-section>header{display:flex;min-width:0;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px 12px;margin-bottom:10px}.relationship-section h3{flex:1 1 auto;min-width:0;margin:0;font-size:15px}.relationship-status{flex:0 0 auto;max-width:100%}.compact-facts{display:grid;min-width:0;grid-template-columns:repeat(2,minmax(0,1fr));gap:1px;margin:0;background:var(--cc-border)}.compact-facts>div{display:grid;min-width:0;grid-template-columns:92px minmax(0,1fr);gap:8px;padding:8px 10px;background:var(--cc-surface)}.compact-facts dt{color:var(--cc-muted)}.compact-facts dd{min-width:0;margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.compact-facts small{display:block;overflow:hidden;color:var(--cc-muted);margin-top:2px;text-overflow:ellipsis;white-space:nowrap}.compact-list{display:grid;min-width:0;grid-template-columns:92px minmax(0,1fr);gap:8px;padding:8px 10px;border-top:1px solid var(--cc-border)}.compact-list span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.inventory-table{width:100%;max-width:100%;min-width:0;margin-top:8px}@media(max-width:720px){:global(.inbound-relationship-dialog .el-dialog__body){padding-right:20px}.compact-facts{grid-template-columns:1fr}}
|
||||
</style>
|
||||
@@ -40,11 +40,13 @@ const props = withDefaults(defineProps<{
|
||||
searchText?: string
|
||||
resetText?: string
|
||||
searchOnReset?: boolean
|
||||
searchOnClear?: boolean
|
||||
loading?: boolean
|
||||
}>(), {
|
||||
searchText: '查询',
|
||||
resetText: '重置',
|
||||
searchOnReset: true,
|
||||
searchOnClear: true,
|
||||
loading: false,
|
||||
})
|
||||
|
||||
@@ -146,6 +148,10 @@ function search() {
|
||||
emit('search', model.value)
|
||||
}
|
||||
|
||||
function clearInput() {
|
||||
if (props.searchOnClear) search()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (props.loading) return
|
||||
for (const field of props.fields) model.value[field.key] = resetValue(field)
|
||||
@@ -182,7 +188,7 @@ defineExpose({ search, reset })
|
||||
clearable
|
||||
v-bind="field.props"
|
||||
@update:model-value="updateField(field.key, $event)"
|
||||
@clear="search"
|
||||
@clear="clearInput"
|
||||
@keyup.enter.exact="searchFromTextInput"
|
||||
>
|
||||
<template #prefix><Search :size="16" /></template>
|
||||
@@ -243,6 +249,7 @@ defineExpose({ search, reset })
|
||||
.query-toolbar__filters { display: flex; flex: 0 1 auto; flex-wrap: wrap; gap: 12px 16px; }
|
||||
.query-toolbar__field, .query-toolbar__control { display: grid; width: var(--query-field-width, 260px); max-width: 100%; min-width: 0; gap: 6px; }
|
||||
.query-toolbar__field :deep(.el-input), .query-toolbar__field :deep(.el-select), .query-toolbar__field :deep(.el-date-editor) { width: 100%; max-width: 100%; min-width: 0; }
|
||||
.query-toolbar__field :deep(.el-input__inner), .query-toolbar__field :deep(.el-select__selected-item), .query-toolbar__field :deep(.el-date-editor), .query-toolbar__actions :deep(.el-button) { font-size: var(--cc-list-query-font-size); }
|
||||
.query-toolbar__actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-start; gap: 8px; }
|
||||
.query-toolbar__actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
:global(.cc-layout-preference-popper) { max-width: calc(100vw - 24px); }
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export function saveCsv(blob: Blob, fileName: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
try { anchor.href = url; anchor.download = fileName; anchor.click() }
|
||||
finally { URL.revokeObjectURL(url) }
|
||||
}
|
||||
Reference in New Issue
Block a user