fix(admin): align supplier carrier master data

This commit is contained in:
2026-08-27 10:12:20 +08:00
parent 29bb641524
commit 3e3c7e237a
14 changed files with 168 additions and 44 deletions
@@ -361,7 +361,7 @@ describe('ContainersView', () => {
})
it('backfills a legacy trucking supplier name and submits the frozen id-name pair', async () => {
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', carrierCodes: [], region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '测试拖车供应商' } as never)
vi.mocked(updateContainer).mockResolvedValue({} as never)
const router = createTestRouter()
@@ -381,7 +381,7 @@ describe('ContainersView', () => {
})
it('clears the trucking supplier id and name together', async () => {
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', carrierCodes: [], region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: '9007199254740998', terminalTruckingCompany: '测试拖车供应商' } as never)
vi.mocked(updateContainer).mockResolvedValue({} as never)
const router = createTestRouter()
@@ -79,7 +79,7 @@ describe('Finance G3/G4 views', () => {
document.body.innerHTML = ''
const customer = { id: '10001', tenantName: '客户A', status: 'ACTIVE' } as PlatformTenantResource
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [customer], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(listSuppliers).mockReset().mockResolvedValue({ records: [{ id: '301', tenantId: '0', name: '拖车供应商A', category: 'TRUCKING', scope: 'DOMESTIC', region: '宁波', legalRepresentative: '', contactName: '', phone: '', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-01', updatedBy: null, updatedAt: '2026-08-01', deleted: false, version: 0 } as Supplier], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(listSuppliers).mockReset().mockResolvedValue({ records: [{ id: '301', tenantId: '0', name: '拖车供应商A', category: 'TRUCKING', carrierCodes: [], scope: 'DOMESTIC', region: '宁波', legalRepresentative: '', contactName: '', phone: '', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-01', updatedBy: null, updatedAt: '2026-08-01', deleted: false, version: 0 } as Supplier], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(listFinanceStatements).mockReset().mockResolvedValue([statement])
vi.mocked(getReconciliationStatement).mockReset().mockResolvedValue(statementDetail)
vi.mocked(actionReconciliationStatement).mockReset().mockResolvedValue(statementDetail)
@@ -21,6 +21,7 @@ const payload: SavePlatformPartnerRequest = {
partnerName: '测试往来单位',
partnerType: 'SHIPPER',
types: ['SHIPPER'],
carrierCode: null,
contactName: '测试联系人',
contactPhone: '13800000000',
contactEmail: null,
@@ -66,7 +67,7 @@ describe('platform partner API', () => {
vi.mocked(http.get)
.mockResolvedValueOnce({ code: '200', message: 'success', data: {
id: '19001', tenantId: 0, dataScopeId: 0, partnerCode: 'CUS-001', partnerName: '测试单位',
partnerType: 'CONSIGNEE', contactName: '张三', contactPhone: '13800000000', contactEmail: null,
partnerType: 'CONSIGNEE', carrierCode: null, contactName: '张三', contactPhone: '13800000000', contactEmail: null,
countryCode: 'CN', province: null, city: '深圳市', district: null, addressLine: '测试地址',
postalCode: null, status: 'ACTIVE',
} })
@@ -2,6 +2,7 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export type PlatformPartnerType = 'CARRIER' | 'SHIPPER' | 'CONSIGNEE'
export type PlatformPartnerStatus = 'ACTIVE' | 'INACTIVE'
export type PlatformPartnerCarrierCode = 'MATSON' | 'SML' | 'ZIM' | 'COSCO' | 'EMC' | 'HEDE' | 'OTHER'
export interface PlatformPartnerResource {
id: number | string
@@ -11,6 +12,7 @@ export interface PlatformPartnerResource {
partnerName: string
partnerType: PlatformPartnerType
types: PlatformPartnerType[]
carrierCode: PlatformPartnerCarrierCode | null
contactName: string
contactPhone: string
contactEmail: string | null
@@ -54,6 +56,7 @@ export interface SavePlatformPartnerRequest {
partnerName: string
partnerType: PlatformPartnerType
types: PlatformPartnerType[]
carrierCode: PlatformPartnerCarrierCode | null
contactName: string
contactPhone: string
contactEmail: string | null
@@ -89,6 +92,7 @@ function normalizePartner(resource: PlatformPartnerResource): PlatformPartnerRes
...resource,
dataScopeId: resource.dataScopeId ?? resource.tenantId,
types: resource.types?.length ? resource.types : [resource.partnerType],
carrierCode: resource.carrierCode ?? null,
remark: resource.remark ?? null,
}
}
@@ -52,6 +52,7 @@ const partner: PlatformPartnerResource = {
partnerName: '深圳示例贸易有限公司',
partnerType: 'SHIPPER',
types: ['SHIPPER'],
carrierCode: null,
contactName: '张三',
contactPhone: '13800138000',
contactEmail: 'contact@example.com',
@@ -159,6 +160,25 @@ describe('PartnersView real API integration', () => {
expect(document.body.textContent).not.toContain('供应商')
})
it('uses the fixed carrier-code selector when the unit is a shipping company', async () => {
const wrapper = await mountView()
await wrapper.findAll('button').find((button) => button.text().trim() === '新增往来单位')?.trigger('click')
await flushPromises()
const typeSelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((select) => select.props('multiple'))
typeSelect!.vm.$emit('update:modelValue', ['CARRIER'])
await flushPromises()
expect(document.body.textContent).toContain('船司标准编码')
const carrierValues = new Set(['MATSON', 'SML', 'ZIM', 'COSCO', 'EMC', 'HEDE', 'OTHER'])
const carrierOptions = wrapper.findAllComponents({ name: 'ElOption' }).filter((option) => carrierValues.has(String(option.props('value'))))
expect(carrierOptions.map((option) => option.props('value'))).toEqual(['MATSON', 'SML', 'ZIM', 'COSCO', 'EMC', 'HEDE', 'OTHER'])
expect(carrierOptions.map((option) => option.props('label'))).toContain('美森 (MATSON)')
expect(source).toContain('placeholder="请选择船司标准编码"')
expect(source).toContain("MATSON: '美森'")
expect(source).toContain("OTHER: '其他'")
expect(source).toContain("carrierCode: types.includes('CARRIER') ? form.carrierCode : null")
})
it('renders a padded detail layout without an empty data-scope cell', async () => {
const wrapper = await mountView()
const detailButton = wrapper.findAll('button').find((button) => button.text().trim() === '详情')
@@ -23,6 +23,7 @@ import {
listPlatformPartners,
updatePlatformPartner,
type PlatformPartnerResource,
type PlatformPartnerCarrierCode,
type PlatformPartnerStatus,
type PlatformPartnerType,
type SavePlatformPartnerRequest,
@@ -94,6 +95,7 @@ const emptyForm = (): PartnerForm => ({
partnerName: '',
partnerType: 'SHIPPER',
types: ['SHIPPER'],
carrierCode: null,
contactName: '',
contactPhone: '',
contactEmail: null,
@@ -121,6 +123,7 @@ const rules: FormRules<PartnerForm> = {
partnerCode: [{ required: true, whitespace: true, message: '请输入往来单位编码', trigger: 'blur' }],
partnerName: [{ required: true, whitespace: true, message: '请输入往来单位名称', trigger: 'blur' }],
types: [{ required: true, type: 'array', min: 1, message: '请至少选择一个单位类型', trigger: 'change' }],
carrierCode: [{ validator: (_rule, value, callback) => form.types.includes('CARRIER') && !value ? callback(new Error('请选择船司标准编码')) : callback(), trigger: 'change' }],
contactName: [{ required: true, whitespace: true, message: '请输入联系人', trigger: 'blur' }],
contactPhone: [{ required: true, whitespace: true, message: '请输入联系电话', trigger: 'blur' }],
contactEmail: [{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' }],
@@ -139,6 +142,11 @@ const typeLabels: Record<PlatformPartnerType, string> = {
CONSIGNEE: '收货单位',
}
const typeOptions = Object.entries(typeLabels).map(([value, label]) => ({ value: value as PlatformPartnerType, label }))
const carrierCodeLabels: Record<PlatformPartnerCarrierCode, string> = {
MATSON: '美森', SML: 'SML', ZIM: '以星', COSCO: '中远', EMC: '长荣', HEDE: '合德', OTHER: '其他',
}
const carrierCodeOptions = (Object.entries(carrierCodeLabels) as [PlatformPartnerCarrierCode, string][]).map(([value, label]) => ({ value, label: `${label} (${value})` }))
function formatCarrierCode(value?: PlatformPartnerCarrierCode | null) { return value ? `${carrierCodeLabels[value]} (${value})` : '—' }
function partnerTypeLabel(type: PlatformPartnerType) {
return typeLabels[type]
}
@@ -225,6 +233,7 @@ function openEdit(row: PlatformPartnerResource) {
partnerName: row.partnerName,
partnerType: row.types[0],
types: [...row.types],
carrierCode: row.carrierCode,
contactName: row.contactName,
contactPhone: row.contactPhone,
contactEmail: row.contactEmail,
@@ -249,6 +258,7 @@ function savePayload(): SavePlatformPartnerRequest {
partnerName: form.partnerName.trim(),
partnerType: types[0],
types,
carrierCode: types.includes('CARRIER') ? form.carrierCode : null,
contactName: form.contactName.trim(),
contactPhone: form.contactPhone.trim(),
contactEmail: nullable(form.contactEmail),
@@ -342,6 +352,11 @@ function formatUpdatedAt(value?: string | null) {
}
watch([page, pageSize], () => { void loadPartners() })
watch(() => form.types.includes('CARRIER'), (carrier) => {
if (carrier) return
form.carrierCode = null
formRef.value?.clearValidate('carrierCode')
})
watch(detailOpen, (open) => {
if (open) return
detailSequence += 1
@@ -415,6 +430,7 @@ onMounted(() => {
<el-form-item label="单位名称" prop="partnerName" required><el-input v-model="form.partnerName" placeholder="请输入工商登记或常用单位名称" maxlength="128" /></el-form-item>
<el-form-item label="单位类型" prop="types" required><el-select v-model="form.types" multiple collapse-tags collapse-tags-tooltip placeholder="请选择一个或多个单位类型"><el-option v-for="option in typeOptions" :key="option.value" :label="option.label" :value="option.value" /></el-select></el-form-item>
<el-form-item label="状态" prop="status" required><el-radio-group v-model="form.status"><el-radio-button value="ACTIVE">启用</el-radio-button><el-radio-button value="INACTIVE">停用</el-radio-button></el-radio-group></el-form-item>
<el-form-item v-if="form.types.includes('CARRIER')" label="船司标准编码" prop="carrierCode" required><el-select v-model="form.carrierCode" placeholder="请选择船司标准编码" style="width:100%"><el-option v-for="option in carrierCodeOptions" :key="option.value" :label="option.label" :value="option.value" /></el-select></el-form-item>
</div></FormSection>
<FormSection title="联系信息"><div class="form-grid two-cols">
<el-form-item label="联系人" prop="contactName" required><el-input v-model="form.contactName" placeholder="请输入主要联系人姓名" maxlength="64" /></el-form-item>
@@ -446,6 +462,7 @@ onMounted(() => {
<div><dt>单位名称</dt><dd>{{ detail.partnerName }}</dd></div>
<div><dt>状态</dt><dd><el-tag :type="detail.status === 'ACTIVE' ? 'success' : 'info'">{{ detail.status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></dd></div>
<div><dt>单位类型</dt><dd class="partner-types"><el-tag v-for="type in detail.types" :key="type" effect="plain">{{ partnerTypeLabel(type) }}</el-tag></dd></div>
<div v-if="detail.types.includes('CARRIER')"><dt>船司标准编码</dt><dd>{{ formatCarrierCode(detail.carrierCode) }}</dd></div>
</dl></FormSection>
<FormSection title="联系与地址"><dl class="detail-grid">
<div><dt>联系人</dt><dd>{{ detail.contactName }}</dd></div><div><dt>联系电话</dt><dd>{{ detail.contactPhone }}</dd></div>
@@ -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', 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', 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', () => {
@@ -18,10 +18,10 @@ describe('platform supplier API', () => {
vi.mocked(http.get)
.mockResolvedValueOnce({ code: '200', data: { records: [resource], page: 1, size: 20, total: 1, pages: 1 } })
.mockResolvedValueOnce({ code: '200', data: { booking: 1, customs: 2, trucking: 3, certificate: 4 } })
const page = await listSuppliers({ page: 1, size: 20, category: 'BOOKING', keyword: '测试' })
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/suppliers', { params: expect.objectContaining({ category: 'BOOKING', keyword: '测试' }) })
const page = await listSuppliers({ page: 1, size: 20, category: 'BOOKING', keyword: '测试', carrierCode: 'MATSON' })
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/suppliers', { params: expect.objectContaining({ category: 'BOOKING', keyword: '测试', carrierCode: 'MATSON' }) })
expect(vi.mocked(http.get).mock.calls[0]?.[1]?.params).not.toHaveProperty('scope')
expect(page.records[0]).toMatchObject({ id: expect.any(String), tenantId: '0', licenseFileId: expect.any(String), createdBy: expect.any(String) })
expect(page.records[0]).toMatchObject({ id: expect.any(String), tenantId: '0', carrierCodes: ['MATSON', 'SML'], licenseFileId: expect.any(String), createdBy: expect.any(String) })
expect(await getSupplierStats()).toEqual({ booking: 1, customs: 2, trucking: 3, certificate: 4 })
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/suppliers/stats')
})
@@ -9,7 +9,7 @@ function normalizeSupplier(raw: Supplier): Supplier {
return {
...raw,
id: String(raw.id), tenantId: String(raw.tenantId),
carrierCode: raw.carrierCode ?? null,
carrierCodes: [...raw.carrierCodes],
licenseFileId: id(raw.licenseFileId), createdBy: id(raw.createdBy), updatedBy: id(raw.updatedBy),
version: Number(raw.version), deleted: Boolean(raw.deleted),
}
@@ -21,6 +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,
},
})
const data = unwrapApiResponse(response)
@@ -11,7 +11,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, formatCarrierCode, settlementLabels, statusLabels, type Supplier } from '@/modules/suppliers/types'
import { 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'))
@@ -19,7 +19,7 @@ const supplierId = computed(() => typeof route.params.id === 'string' ? route.pa
const supplier = ref<Supplier | null>(null); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null); const licenseOpen = ref(false)
let loadSequence = 0
const items = computed(() => supplier.value ? [
['供应商名称', supplier.value.name], ['供应商类别', categoryLabels[supplier.value.category]], ['船司标准编码', formatCarrierCode(supplier.value.carrierCode)], ['地区', supplier.value.region],
['供应商名称', supplier.value.name], ['供应商类别', categoryLabels[supplier.value.category]], ['可代理船公司', supplier.value.category === 'BOOKING' ? formatCarrierCodes(supplier.value.carrierCodes) : '—'], ['地区', supplier.value.region],
['法人代表', 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 || '—'],
] : [])
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { computed, onActivated, onDeactivated, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { ArrowLeft, FileText, Save } from '@lucide/vue'
@@ -26,11 +26,11 @@ 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', carrierCode: null, 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', 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 rules: FormRules<SaveSupplierRequest> = {
name: [{ required: true, message: '请输入供应商名称', trigger: 'blur' }], category: [{ required: true, message: '请选择供应商类别', trigger: 'change' }],
carrierCode: [{ validator: (_rule, value, callback) => form.category === 'BOOKING' && !value ? callback(new Error('请选择船司标准编码')) : callback(), 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' }],
email: [{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }], status: [{ required: true, message: '请选择合作状态', trigger: 'change' }],
@@ -40,9 +40,9 @@ const { captureBaseline } = useUnsavedWorkRegistration({ key: 'supplier-editor',
function optional(value: string | null) { return value?.trim() || null }
function payload(): SaveSupplierRequest {
const { carrierCode, ...fields } = form
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) }
return form.category === 'BOOKING' ? { ...normalized, carrierCode } : normalized
return form.category === 'BOOKING' ? { ...normalized, carrierCodes: [...(carrierCodes || [])] } : normalized
}
let loadSequence = 0
function resetForm() { Object.assign(form, initial()) }
@@ -58,7 +58,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, carrierCode: data.category === 'BOOKING' ? data.carrierCode : null, 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, 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, '供应商详情加载失败')
@@ -76,8 +76,16 @@ async function save() {
} catch (error) { submitError.value = getApiErrorDetails(error, editing.value ? '供应商修改失败' : '供应商新增失败') } finally { saving.value = false }
}
watch(supplierId, () => { void load() }, { immediate: true })
watch(() => form.category, (category) => { if (category !== 'BOOKING') form.carrierCode = null })
let active = true
let activatedOnce = false
watch(supplierId, () => { if (active) void load() }, { immediate: true })
onActivated(() => {
active = true
if (activatedOnce) void load()
else activatedOnce = true
})
onDeactivated(() => { active = false })
watch(() => form.category, (category) => { if (category !== 'BOOKING') form.carrierCodes = [] })
</script>
<template>
@@ -93,7 +101,7 @@ watch(() => form.category, (category) => { if (category !== 'BOOKING') form.carr
<div class="form-grid">
<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="carrierCode"><el-select v-model="form.carrierCode" 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 === '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 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>
@@ -1,27 +1,32 @@
import { flushPromises, mount } from '@vue/test-utils'
import { reactive, nextTick } from 'vue'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { defineComponent, reactive, nextTick, ref } from 'vue'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import ElementPlus from 'element-plus'
import type { Component } from 'vue'
import type { Supplier } from '@/modules/suppliers/types'
const mocks = vi.hoisted(() => ({
route: null as null | { params: { id: string } },
push: vi.fn(), getSupplier: vi.fn(), updateSupplier: vi.fn(),
push: vi.fn(), getSupplier: vi.fn(), updateSupplier: vi.fn(), createSupplier: vi.fn(),
listSuppliers: vi.fn(), getSupplierStats: vi.fn(), changeSupplierStatus: vi.fn(),
can: vi.fn((permission: string) => permission === 'platform:supplier:list' || permission === 'platform:supplier:edit' || permission === 'platform:supplier:create'),
}))
const routeState = reactive({ params: { id: 'A' } })
mocks.route = routeState
let SupplierFormView: Component
let SupplierDetailView: Component
let SuppliersView: Component
vi.mock('vue-router', () => ({ useRoute: () => mocks.route, useRouter: () => ({ push: mocks.push }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: mocks.can }) }))
vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration', () => ({ useUnsavedWorkRegistration: () => ({ captureBaseline: vi.fn() }) }))
vi.mock('@/modules/suppliers/api/platformSuppliers', () => ({ getSupplier: mocks.getSupplier, updateSupplier: mocks.updateSupplier, createSupplier: vi.fn() }))
vi.mock('@/modules/suppliers/api/platformSuppliers', () => ({
getSupplier: mocks.getSupplier, updateSupplier: mocks.updateSupplier, createSupplier: mocks.createSupplier,
listSuppliers: mocks.listSuppliers, getSupplierStats: mocks.getSupplierStats, changeSupplierStatus: mocks.changeSupplierStatus,
}))
function supplier(id: string, name: string): Supplier {
return { id, tenantId: '0', name, category: 'BOOKING', carrierCode: 'MATSON', 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', 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>() {
@@ -37,16 +42,25 @@ const stubs = {
ApiErrorMeta: { template: '<span />' },
LoadErrorState: { template: '<div class="load-error"><slot name="details" /></div>' },
SupplierLicenseDialog: { template: '<div />' },
ListPageLayout: { template: '<section><slot name="toolbar"/><slot name="state"/><slot/></section>' },
QueryToolbar: { template: '<div />' },
DataTable: { template: '<div><slot name="toolbar-actions"/></div>' },
StatusBadge: { template: '<span />' },
}
enableAutoUnmount(afterEach)
describe('supplier route reuse', () => {
beforeAll(async () => {
SupplierFormView = (await import('@/modules/suppliers/pages/SupplierFormView.vue')).default
SupplierDetailView = (await import('@/modules/suppliers/pages/SupplierDetailView.vue')).default
SuppliersView = (await import('@/modules/suppliers/pages/SuppliersView.vue')).default
})
beforeEach(() => {
routeState.params.id = 'A'
mocks.push.mockReset(); mocks.getSupplier.mockReset(); mocks.updateSupplier.mockReset().mockResolvedValue(null)
mocks.push.mockReset(); mocks.getSupplier.mockReset(); mocks.updateSupplier.mockReset().mockResolvedValue(null); mocks.createSupplier.mockReset().mockResolvedValue('99')
mocks.listSuppliers.mockReset().mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 })
mocks.getSupplierStats.mockReset().mockResolvedValue({ booking: 0, customs: 0, trucking: 0, certificate: 0 })
mocks.changeSupplierStatus.mockReset().mockResolvedValue(null)
mocks.can.mockClear()
})
@@ -69,7 +83,7 @@ describe('supplier route reuse', () => {
expect(saveButton).toBeDefined()
await saveButton!.trigger('click')
await flushPromises()
expect(mocks.updateSupplier).toHaveBeenCalledWith('B', expect.objectContaining({ name: '供应商B' }))
expect(mocks.updateSupplier).toHaveBeenCalledWith('B', expect.objectContaining({ name: '供应商B', carrierCodes: ['MATSON', 'SML'] }))
expect(mocks.push).toHaveBeenCalledWith({ name: 'supplier-list' })
})
@@ -84,4 +98,44 @@ describe('supplier route reuse', () => {
expect(wrapper.text()).toContain('供应商B')
expect(wrapper.text()).not.toContain('供应商A')
})
it('resets a cached create form every time it is activated', async () => {
routeState.params.id = ''
const active = ref(true)
const Host = defineComponent({
components: { SupplierFormView },
setup: () => ({ active }),
template: '<KeepAlive><SupplierFormView v-if="active" /></KeepAlive>',
})
const wrapper = mount(Host, { global: { plugins: [ElementPlus], stubs } })
await nextTick()
const name = wrapper.find('input[placeholder="请输入供应商名称"]')
await name.setValue('上一次新增的数据')
expect((name.element as HTMLInputElement).value).toBe('上一次新增的数据')
active.value = false; await nextTick()
active.value = true; await nextTick()
expect((wrapper.find('input[placeholder="请输入供应商名称"]').element as HTMLInputElement).value).toBe('')
expect(mocks.getSupplier).not.toHaveBeenCalled()
})
it('reloads the cached supplier list every time it is activated', async () => {
routeState.params.id = ''
const active = ref(true)
const Host = defineComponent({
components: { SuppliersView },
setup: () => ({ active }),
template: '<KeepAlive><SuppliersView v-if="active" /></KeepAlive>',
})
mount(Host, { global: { plugins: [ElementPlus], stubs } })
await flushPromises()
expect(mocks.listSuppliers).toHaveBeenCalledTimes(1)
active.value = false; await nextTick()
active.value = true; await flushPromises()
expect(mocks.listSuppliers).toHaveBeenCalledTimes(2)
expect(mocks.getSupplierStats).toHaveBeenCalledTimes(2)
})
})
@@ -48,14 +48,16 @@ describe('supplier formal pages', () => {
expect(detailSource).toContain('<ApiErrorMeta')
})
it('uses a fixed carrier-code selector for booking suppliers and shows the code in list and detail', () => {
it('uses a fixed multi-select for booking supplier carrier codes and sends no legacy scalar', () => {
expect(formSource).toContain('v-if="form.category === \'BOOKING\'"')
expect(formSource).toContain('v-for="option in carrierCodeOptions"')
expect(formSource).not.toContain('v-model="form.carrierCode" maxlength')
expect(formSource).toContain("if (category !== 'BOOKING') form.carrierCode = null")
expect(formSource).toContain("return form.category === 'BOOKING' ? { ...normalized, carrierCode } : normalized")
expect(source).toContain("label: '船司标准编码'")
expect(detailSource).toContain("['船司标准编码', formatCarrierCode(supplier.value.carrierCode)]")
expect(formSource).toContain('v-model="form.carrierCodes" multiple')
expect(formSource).toContain("if (category !== 'BOOKING') form.carrierCodes = []")
expect(formSource).toContain("carrierCodes: [...(carrierCodes || [])]")
expect(formSource).not.toMatch(/form\.carrierCode(?!s)/)
expect(source).toContain("label: '可代理船公司'")
expect(source).toContain("carrierCode: activeCategory.value === 'BOOKING' ? applied.value.carrierCode || undefined : undefined")
expect(detailSource).toContain("['可代理船公司', supplier.value.category === 'BOOKING' ? formatCarrierCodes(supplier.value.carrierCodes) : '—']")
})
it('reloads detail data when the reused route id changes and protects the newer request', () => {
@@ -64,4 +66,12 @@ describe('supplier formal pages', () => {
expect(detailSource).toContain('if (sequence !== loadSequence || requestedId !== supplierId.value) return')
expect(detailSource).toContain("params: { id: supplier.id }")
})
it('refreshes cached lists and resets cached create forms on activation', () => {
expect(source).toContain('onMounted(load)')
expect(source).toContain('if (activatedOnce) void load()')
expect(formSource).toContain('if (activatedOnce) void load()')
expect(formSource).toContain('onDeactivated(() => { active = false })')
expect(formSource).toContain("watch(supplierId, () => { if (active) void load() }, { immediate: true })")
})
})
+13 -7
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onActivated, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Eye, FileText, Pencil, Plus, Power } from '@lucide/vue'
@@ -14,7 +14,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 { categoryLabels, formatCarrierCode, settlementLabels, statusLabels, type Supplier, type SupplierCategory, type SupplierStats, type SupplierStatus } from '@/modules/suppliers/types'
import { 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()
@@ -30,13 +30,14 @@ 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 }
const filters = ref<Filters>({ status: '', region: '', keyword: '' })
type Filters = QueryModel & { status: SupplierStatus | ''; region: string; keyword: string; carrierCode: CarrierCode | '' }
const filters = ref<Filters>({ status: '', region: '', keyword: '', carrierCode: '' })
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 }] : []),
{ key: 'region', type: 'input', label: '地区', placeholder: '请输入地区', width: 170, props: { maxlength: 80 } },
{ key: 'keyword', type: 'input', label: '名称或联系人', placeholder: '供应商名称/联系人', width: 220, props: { maxlength: 160 } },
])
@@ -45,7 +46,7 @@ const columns = computed<DataTableColumn[]>(() => [
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' },
{ key: 'name', label: '供应商名称', minWidth: 230, slotName: 'name' },
{ key: 'category', label: '类别', width: 90, slotName: 'category' },
{ key: 'carrierCode', label: '船司标准编码', minWidth: 160, slotName: 'carrierCode' },
{ key: 'carrierCodes', label: '可代理船公司', minWidth: 240, slotName: 'carrierCodes' },
{ key: 'region', label: '地区', minWidth: 110 },
{ key: 'legalRepresentative', label: '法人', minWidth: 120 },
{ key: 'contactName', label: '联系人', minWidth: 110 },
@@ -69,7 +70,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 }),
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 }),
getSupplierStats(),
])
if (current !== sequence) return
@@ -108,7 +109,12 @@ async function toggleStatus(row: Supplier) {
}
watch([page, pageSize], ([newPage, newSize], [oldPage, oldSize]) => { if (newPage !== oldPage || newSize !== oldSize) void load() })
let activatedOnce = false
onMounted(load)
onActivated(() => {
if (activatedOnce) void load()
else activatedOnce = true
})
</script>
<template>
@@ -149,7 +155,7 @@ onMounted(load)
</template>
<template #name="{ row }"><strong class="supplier-name">{{ row.name }}</strong></template>
<template #category="{ row }">{{ categoryLabels[row.category as SupplierCategory] }}</template>
<template #carrierCode="{ row }">{{ formatCarrierCode(supplierRow(row).carrierCode) }}</template>
<template #carrierCodes="{ row }">{{ supplierRow(row).category === 'BOOKING' ? formatCarrierCodes(supplierRow(row).carrierCodes) : '—' }}</template>
<template #license="{ row }"><el-button size="small" plain type="primary" @click="showLicense(supplierRow(row))"><FileText :size="15" />查看</el-button></template>
<template #status="{ row }"><StatusBadge :status="statusLabels[row.status as SupplierStatus]" /></template>
<template #settlement="{ row }">{{ settlementLabels[supplierRow(row).settlementPeriod] }}</template>
+6 -3
View File
@@ -8,7 +8,7 @@ export interface Supplier {
tenantId: string
name: string
category: SupplierCategory
carrierCode?: CarrierCode | null
carrierCodes: CarrierCode[]
region: string
legalRepresentative: string
contactName: string
@@ -45,12 +45,13 @@ export interface SupplierQuery {
region?: string
status?: SupplierStatus | ''
keyword?: string
carrierCode?: CarrierCode
}
export interface SaveSupplierRequest {
name: string
category: SupplierCategory
carrierCode?: CarrierCode | null
carrierCodes?: CarrierCode[]
region: string
legalRepresentative: string
contactName: string
@@ -69,7 +70,9 @@ export const carrierCodeLabels: Record<CarrierCode, string> = {
MATSON: '美森', SML: 'SML', ZIM: '以星', COSCO: '中远', EMC: '长荣', HEDE: '合德', OTHER: '其他',
}
export const carrierCodeOptions = (Object.entries(carrierCodeLabels) as [CarrierCode, string][]).map(([value, name]) => ({ value, label: `${name} (${value})` }))
export function formatCarrierCode(code: CarrierCode | null | undefined) { return code ? `${carrierCodeLabels[code]} (${code})` : '—' }
export function formatCarrierCodes(codes: CarrierCode[] | null | undefined) {
return codes?.length ? codes.map((code) => `${carrierCodeLabels[code]} (${code})`).join('、') : '—'
}
export const statusLabels: Record<SupplierStatus, string> = { ACTIVE: '合作中', INACTIVE: '停用' }
export const settlementLabels: Record<SettlementPeriod, string> = {
MONTHLY_30: '月结30天', MONTHLY_45: '月结45天', MONTHLY_60: '月结60天', MONTHLY_90: '月结90天', PER_TRANSACTION: '票结', IMMEDIATE: '现结',