This commit is contained in:
2026-09-16 11:06:36 +08:00
parent d75e8381b6
commit 78a0d7916c
22 changed files with 197 additions and 51 deletions
@@ -8,7 +8,7 @@ vi.mock('@/shared/api/http', async (original) => ({
http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn() },
}))
const payload: SaveSupplierRequest = { name: '测试供应商', category: 'BOOKING', carrierCodes: ['MATSON', 'SML'], region: '宁波', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: '9007199254740995', status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null }
const payload: SaveSupplierRequest = { name: '测试供应商', category: 'BOOKING', truckingScope: null, carrierCodes: ['MATSON', 'SML'], region: '宁波', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: '9007199254740995', status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null }
const resource = { id: 9007199254740993, tenantId: 0, ...payload, createdBy: 9007199254740994, createdAt: '2026-08-13T10:00:00', updatedBy: null, updatedAt: '2026-08-13T10:00:00', deleted: false, version: 1 }
describe('platform supplier API', () => {
@@ -37,4 +37,11 @@ describe('platform supplier API', () => {
expect(http.put).toHaveBeenCalledWith('/platform/v1/suppliers/77', payload, { params: { tenantId: 0 } })
expect(http.patch).toHaveBeenCalledWith('/platform/v1/suppliers/77/status', { status: 'INACTIVE' }, { params: { tenantId: 0 } })
})
it('passes the trucking scope filter and keeps the authoritative type in resources', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [{ ...resource, category: 'TRUCKING', carrierCodes: [], truckingScope: 'OVERSEAS' }], page: 1, size: 20, total: 1, pages: 1 } })
const page = await listSuppliers({ category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' })
expect(http.get).toHaveBeenCalledWith('/platform/v1/suppliers', { params: expect.objectContaining({ category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' }) })
expect(page.records[0]?.truckingScope).toBe('OVERSEAS')
})
})
@@ -21,7 +21,7 @@ export async function listSuppliers(query: SupplierQuery): Promise<SupplierPage>
page: query.page ?? 1, size: query.size ?? 20, category: query.category,
region: query.region?.trim() || undefined,
status: query.status || undefined, keyword: query.keyword?.trim() || undefined,
carrierCode: query.carrierCode || undefined,
carrierCode: query.carrierCode || undefined, truckingScope: query.truckingScope,
},
})
const data = unwrapApiResponse(response)
@@ -12,7 +12,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import SupplierLicenseDialog from '@/modules/suppliers/components/SupplierLicenseDialog.vue'
import { getSupplier } from '@/modules/suppliers/api/platformSuppliers'
import { categoryLabels, formatCarrierCodes, settlementLabels, statusLabels, type Supplier } from '@/modules/suppliers/types'
import { formatTruckingScope, categoryLabels, formatCarrierCodes, settlementLabels, statusLabels, type Supplier } from '@/modules/suppliers/types'
const route = useRoute(); const router = useRouter(); const access = useAccessPreviewStore()
const canView = computed(() => access.can('platform:supplier:list'))
@@ -21,6 +21,7 @@ const supplier = ref<Supplier | null>(null); const loading = ref(false); const e
let loadSequence = 0
const items = computed(() => supplier.value ? [
['供应商名称', supplier.value.name], ['供应商类别', categoryLabels[supplier.value.category]], ['可代理船公司', supplier.value.category === 'BOOKING' ? formatCarrierCodes(supplier.value.carrierCodes) : '—'], ['地区', supplier.value.region],
...(supplier.value.category === 'TRUCKING' ? [['拖车类型', formatTruckingScope(supplier.value.truckingScope)]] : []),
['法人代表', supplier.value.legalRepresentative], ['联系人', supplier.value.contactName], ['联系电话', supplier.value.phone], ['电子邮箱', supplier.value.email || '—'],
['营业执照号', supplier.value.businessLicenseNo || '—'], ['结算周期', settlementLabels[supplier.value.settlementPeriod]], ['公司地址', supplier.value.address || '—'], ['备注', supplier.value.remark || '—'],
] : [])
@@ -14,7 +14,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { createSupplier, getSupplier, updateSupplier } from '@/modules/suppliers/api/platformSuppliers'
import { carrierCodeOptions, categoryLabels, settlementLabels, statusLabels, type SaveSupplierRequest } from '@/modules/suppliers/types'
import { truckingScopeLabels, carrierCodeOptions, categoryLabels, settlementLabels, statusLabels, type TruckingScope, type SaveSupplierRequest } from '@/modules/suppliers/types'
const route = useRoute()
const router = useRouter()
@@ -27,10 +27,13 @@ const loading = ref(false)
const saving = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const submitError = ref<ApiErrorDetails | null>(null)
const initial = (): SaveSupplierRequest => ({ name: '', category: 'BOOKING', carrierCodes: [], region: '', legalRepresentative: '', contactName: '', phone: '', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null })
const initial = (): SaveSupplierRequest => ({ name: '', category: 'BOOKING', truckingScope: null, carrierCodes: [], region: '', legalRepresentative: '', contactName: '', phone: '', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null })
const form = reactive(initial())
const saveAttempted = ref(false)
const truckingScopeError = computed(() => saveAttempted.value && form.category === 'TRUCKING' && !form.truckingScope ? '请选择国内拖车或海外车队' : '')
const rules: FormRules<SaveSupplierRequest> = {
name: [{ required: true, message: '请输入供应商名称', trigger: 'blur' }], category: [{ required: true, message: '请选择供应商类别', trigger: 'change' }],
truckingScope: [{ required: true, type: 'enum', enum: ['DOMESTIC', 'OVERSEAS'], message: '请选择国内拖车或海外车队', trigger: 'change' }],
carrierCodes: [{ validator: (_rule, value, callback) => form.category === 'BOOKING' && (!Array.isArray(value) || value.length === 0) ? callback(new Error('请至少选择一家可代理船公司')) : callback(), trigger: 'change' }],
region: [{ required: true, message: '请输入地区', trigger: 'blur' }], legalRepresentative: [{ required: true, message: '请输入法人代表', trigger: 'blur' }],
contactName: [{ required: true, message: '请输入联系人', trigger: 'blur' }], phone: [{ required: true, message: '请输入联系电话', trigger: 'blur' }],
@@ -42,11 +45,11 @@ const { captureBaseline } = useUnsavedWorkRegistration({ key: 'supplier-editor',
function optional(value: string | null) { return value?.trim() || null }
function payload(): SaveSupplierRequest {
const { carrierCodes, ...fields } = form
const normalized = { ...fields, name: form.name.trim(), region: form.region.trim(), legalRepresentative: form.legalRepresentative.trim(), contactName: form.contactName.trim(), phone: form.phone.trim(), email: optional(form.email), address: optional(form.address), businessLicenseNo: optional(form.businessLicenseNo), licenseFileId: form.licenseFileId, remark: optional(form.remark) }
const normalized = { ...fields, truckingScope: form.category === 'TRUCKING' ? form.truckingScope : null, name: form.name.trim(), region: form.region.trim(), legalRepresentative: form.legalRepresentative.trim(), contactName: form.contactName.trim(), phone: form.phone.trim(), email: optional(form.email), address: optional(form.address), businessLicenseNo: optional(form.businessLicenseNo), licenseFileId: form.licenseFileId, remark: optional(form.remark) }
return form.category === 'BOOKING' ? { ...normalized, carrierCodes: [...(carrierCodes || [])] } : normalized
}
let loadSequence = 0
function resetForm() { Object.assign(form, initial()) }
function resetForm() { Object.assign(form, initial()); saveAttempted.value = false }
async function load() {
const requestedId = supplierId.value
const sequence = ++loadSequence
@@ -59,7 +62,7 @@ async function load() {
try {
const data = await getSupplier(requestedId)
if (sequence !== loadSequence || requestedId !== supplierId.value) return
Object.assign(form, { name: data.name, category: data.category, carrierCodes: data.category === 'BOOKING' ? [...data.carrierCodes] : [], region: data.region, legalRepresentative: data.legalRepresentative, contactName: data.contactName, phone: data.phone, email: data.email, address: data.address, businessLicenseNo: data.businessLicenseNo, licenseFileId: data.licenseFileId, status: data.status, settlementPeriod: data.settlementPeriod, remark: data.remark })
Object.assign(form, { name: data.name, category: data.category, truckingScope: data.truckingScope, carrierCodes: data.category === 'BOOKING' ? [...data.carrierCodes] : [], region: data.region, legalRepresentative: data.legalRepresentative, contactName: data.contactName, phone: data.phone, email: data.email, address: data.address, businessLicenseNo: data.businessLicenseNo, licenseFileId: data.licenseFileId, status: data.status, settlementPeriod: data.settlementPeriod, remark: data.remark })
captureBaseline()
} catch (error) {
if (sequence === loadSequence && requestedId === supplierId.value) loadError.value = getApiErrorDetails(error, '供应商详情加载失败')
@@ -67,6 +70,8 @@ async function load() {
}
async function save() {
if (!access.can(requiredPermission.value)) return
saveAttempted.value = true
if (truckingScopeError.value) return
if (!await formRef.value?.validate().catch(() => false)) return
saving.value = true; submitError.value = null
try {
@@ -86,7 +91,7 @@ onActivated(() => {
else activatedOnce = true
})
onDeactivated(() => { active = false })
watch(() => form.category, (category) => { if (category !== 'BOOKING') form.carrierCodes = [] })
watch(() => form.category, (category) => { if (category !== 'BOOKING') form.carrierCodes = []; if (category !== 'TRUCKING') form.truckingScope = null })
</script>
<template>
@@ -103,6 +108,7 @@ watch(() => form.category, (category) => { if (category !== 'BOOKING') form.carr
<el-form-item label="供应商名称" prop="name"><el-input v-model="form.name" maxlength="160" placeholder="请输入供应商名称" /></el-form-item>
<el-form-item label="供应商类别" prop="category"><el-select v-model="form.category"><el-option v-for="(label, value) in categoryLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
<el-form-item v-if="form.category === 'BOOKING'" label="可代理船公司" prop="carrierCodes"><el-select v-model="form.carrierCodes" multiple collapse-tags collapse-tags-tooltip placeholder="请选择可代理船公司"><el-option v-for="option in carrierCodeOptions" :key="option.value" :label="option.label" :value="option.value" /></el-select></el-form-item>
<el-form-item v-if="form.category === 'TRUCKING'" label="拖车类型" prop="truckingScope" :error="truckingScopeError" required><el-radio-group :model-value="form.truckingScope ?? undefined" @update:model-value="form.truckingScope = $event as TruckingScope"><el-radio v-for="(label, value) in truckingScopeLabels" :key="value" :value="value">{{ label }}</el-radio></el-radio-group></el-form-item>
<el-form-item label="地区" prop="region"><el-input v-model="form.region" maxlength="80" placeholder="请输入地区" /></el-form-item>
<el-form-item label="法人代表" prop="legalRepresentative"><el-input v-model="form.legalRepresentative" maxlength="80" placeholder="请输入法人代表" /></el-form-item>
<el-form-item label="联系人" prop="contactName"><el-input v-model="form.contactName" maxlength="80" placeholder="请输入联系人" /></el-form-item>
@@ -26,7 +26,7 @@ vi.mock('@/modules/suppliers/api/platformSuppliers', () => ({
}))
function supplier(id: string, name: string): Supplier {
return { id, tenantId: '0', name, category: 'BOOKING', carrierCodes: ['MATSON', 'SML'], region: '宁波', legalRepresentative: '法人', contactName: '联系人', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '', updatedBy: null, updatedAt: '', deleted: false, version: 1 }
return { id, tenantId: '0', name, category: 'BOOKING', truckingScope: null, carrierCodes: ['MATSON', 'SML'], region: '宁波', legalRepresentative: '法人', contactName: '联系人', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '', updatedBy: null, updatedAt: '', deleted: false, version: 1 }
}
function deferred<T>() {
@@ -152,4 +152,24 @@ describe('supplier route reuse', () => {
expect(mocks.listSuppliers).toHaveBeenCalledTimes(2)
expect(mocks.getSupplierStats).toHaveBeenCalledTimes(2)
})
it('requires one trucking type, saves overseas and clears the type after changing category', async () => {
mocks.getSupplier.mockResolvedValue({ ...supplier('A', '车队'), category: 'TRUCKING', carrierCodes: [], truckingScope: null })
const wrapper = mount(SupplierFormView, { global: { plugins: [ElementPlus], stubs } })
await flushPromises()
const save = wrapper.findAll('button').find((button) => button.text().includes('保存'))!
await save.trigger('click'); await flushPromises()
expect(mocks.updateSupplier).not.toHaveBeenCalled()
expect(wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('prop') === 'truckingScope')?.props('error')).toBe('请选择国内拖车或海外车队')
const radios = wrapper.findComponent({ name: 'ElRadioGroup' })
expect(radios.text()).toContain('国内拖车')
expect(radios.text()).toContain('海外车队')
radios.vm.$emit('update:modelValue', 'OVERSEAS')
await flushPromises(); await save.trigger('click'); await flushPromises()
expect(mocks.updateSupplier).toHaveBeenLastCalledWith('A', expect.objectContaining({ category: 'TRUCKING', truckingScope: 'OVERSEAS' }))
wrapper.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', 'CUSTOMS')
await flushPromises(); await save.trigger('click'); await flushPromises()
expect(wrapper.findComponent({ name: 'ElRadioGroup' }).exists()).toBe(false)
expect(mocks.updateSupplier).toHaveBeenLastCalledWith('A', expect.objectContaining({ category: 'CUSTOMS', truckingScope: null }))
})
})
@@ -16,7 +16,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import SupplierLicenseDialog from '@/modules/suppliers/components/SupplierLicenseDialog.vue'
import { changeSupplierStatus, getSupplierStats, listSuppliers } from '@/modules/suppliers/api/platformSuppliers'
import { carrierCodeOptions, categoryLabels, formatCarrierCodes, settlementLabels, statusLabels, type CarrierCode, type Supplier, type SupplierCategory, type SupplierStats, type SupplierStatus } from '@/modules/suppliers/types'
import { truckingScopeOptions, formatTruckingScope, type TruckingScope, carrierCodeOptions, categoryLabels, formatCarrierCodes, settlementLabels, statusLabels, type CarrierCode, type Supplier, type SupplierCategory, type SupplierStats, type SupplierStatus } from '@/modules/suppliers/types'
const router = useRouter()
const access = useAccessPreviewStore()
@@ -32,14 +32,15 @@ const loadError = ref<ApiErrorDetails | null>(null)
const actionId = ref('')
const licenseOpen = ref(false)
const licenseSupplier = ref<Supplier | null>(null)
type Filters = QueryModel & { status: SupplierStatus | ''; region: string; keyword: string; carrierCode: CarrierCode | '' }
const filters = ref<Filters>({ status: '', region: '', keyword: '', carrierCode: '' })
type Filters = QueryModel & { status: SupplierStatus | ''; region: string; keyword: string; carrierCode: CarrierCode | ''; truckingScope: TruckingScope | '' }
const filters = ref<Filters>({ status: '', region: '', keyword: '', carrierCode: '', truckingScope: '' })
const applied = ref<Filters>({ ...filters.value })
let sequence = 0
const queryFields = computed<QueryField[]>(() => [
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 150, options: [{ label: '合作中', value: 'ACTIVE' }, { label: '停用', value: 'INACTIVE' }] },
...(activeCategory.value === 'BOOKING' ? [{ key: 'carrierCode', type: 'select' as const, label: '可代理船公司', placeholder: '全部船公司', width: 190, options: carrierCodeOptions }] : []),
...(activeCategory.value === 'TRUCKING' ? [{ key: 'truckingScope', type: 'select' as const, label: '拖车类型', placeholder: '全部拖车类型', width: 170, options: truckingScopeOptions }] : []),
{ key: 'region', type: 'input', label: '地区', placeholder: '请输入地区', width: 170, props: { maxlength: 80 } },
{ key: 'keyword', type: 'input', label: '名称或联系人', placeholder: '供应商名称/联系人', width: 220, props: { maxlength: 160 } },
])
@@ -48,7 +49,8 @@ const columns = computed<DataTableColumn[]>(() => [
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' },
{ key: 'name', label: '供应商名称', minWidth: 200, slotName: 'name' },
{ key: 'category', label: '类别', width: 70, slotName: 'category' },
{ key: 'carrierCodes', label: '可代理船公司', minWidth: 200, slotName: 'carrierCodes' },
...(activeCategory.value === 'BOOKING' ? [{ key: 'carrierCodes', label: '可代理船公司', minWidth: 200, slotName: 'carrierCodes' }] : []),
...(activeCategory.value === 'TRUCKING' ? [{ key: 'truckingScope', label: '拖车类型', width: 110, slotName: 'truckingScope' }] : []),
{ key: 'region', label: '地区', minWidth: 100 },
{ key: 'legalRepresentative', label: '法人', minWidth: 100 },
{ key: 'contactName', label: '联系人', minWidth: 120 },
@@ -72,7 +74,7 @@ async function load() {
loadError.value = null
try {
const [result, counts] = await Promise.all([
listSuppliers({ page: page.value, size: pageSize.value, category: activeCategory.value, status: applied.value.status, region: applied.value.region, keyword: applied.value.keyword, carrierCode: activeCategory.value === 'BOOKING' ? applied.value.carrierCode || undefined : undefined }),
listSuppliers({ page: page.value, size: pageSize.value, category: activeCategory.value, status: applied.value.status, region: applied.value.region, keyword: applied.value.keyword, truckingScope: activeCategory.value === 'TRUCKING' ? applied.value.truckingScope || undefined : undefined, carrierCode: activeCategory.value === 'BOOKING' ? applied.value.carrierCode || undefined : undefined }),
getSupplierStats(),
])
if (current !== sequence) return
@@ -152,6 +154,7 @@ onActivated(() => {
</template>
<template #name="{ row }"><LineClampText :content="row.name" /></template>
<template #category="{ row }">{{ categoryLabels[row.category as SupplierCategory] }}</template>
<template #truckingScope="{ row }">{{ formatTruckingScope(supplierRow(row).truckingScope) }}</template>
<template #carrierCodes="{ row }"><LineClampText :content="supplierRow(row).category === 'BOOKING' ? formatCarrierCodes(supplierRow(row).carrierCodes) : null" /></template>
<template #license="{ row }"><el-button data-tone="neutral" class="density-action" size="small" @click="showLicense(supplierRow(row))">查看</el-button></template>
<template #status="{ row }"><StatusBadge :status="statusLabels[supplierRow(row).status] ?? '—'" /></template>
+7
View File
@@ -1,4 +1,8 @@
export type SupplierCategory = 'BOOKING' | 'CUSTOMS' | 'TRUCKING' | 'CERTIFICATE'
export type TruckingScope = 'DOMESTIC' | 'OVERSEAS'
export const truckingScopeLabels: Record<TruckingScope, string> = { DOMESTIC: '国内拖车', OVERSEAS: '海外车队' }
export const truckingScopeOptions = Object.entries(truckingScopeLabels).map(([value, label]) => ({ value, label }))
export function formatTruckingScope(scope: TruckingScope | null) { return scope ? truckingScopeLabels[scope] : '—' }
export type SupplierStatus = 'ACTIVE' | 'INACTIVE'
export type SettlementPeriod = 'MONTHLY_30' | 'MONTHLY_45' | 'MONTHLY_60' | 'MONTHLY_90' | 'PER_TRANSACTION' | 'IMMEDIATE'
export type CarrierCode = 'MATSON' | 'SML' | 'ZIM' | 'COSCO' | 'EMC' | 'HEDE' | 'OTHER'
@@ -8,6 +12,7 @@ export interface Supplier {
tenantId: string
name: string
category: SupplierCategory
truckingScope: TruckingScope | null
carrierCodes: CarrierCode[]
region: string
legalRepresentative: string
@@ -42,6 +47,7 @@ export interface SupplierQuery {
page?: number
size?: number
category?: SupplierCategory
truckingScope?: TruckingScope
region?: string
status?: SupplierStatus | ''
keyword?: string
@@ -51,6 +57,7 @@ export interface SupplierQuery {
export interface SaveSupplierRequest {
name: string
category: SupplierCategory
truckingScope: TruckingScope | null
carrierCodes?: CarrierCode[]
region: string
legalRepresentative: string