diff --git a/src/modules/container-loading/components/ContainerOverviewPanel.test.ts b/src/modules/container-loading/components/ContainerOverviewPanel.test.ts index 97f3438f..4f06b632 100644 --- a/src/modules/container-loading/components/ContainerOverviewPanel.test.ts +++ b/src/modules/container-loading/components/ContainerOverviewPanel.test.ts @@ -47,6 +47,14 @@ describe('ContainerOverviewPanel', () => { expect(wrapper.text()).not.toContain('9007199254740999') }) + it('shows the channel restriction and suppresses DO uploads when the document is not applicable', () => { + const withoutDo = { ...overview, documents: overview.documents.filter((item) => item.documentType !== 'DELIVERY_ORDER') } + const wrapper = mount(ContainerOverviewPanel, { props: { overview: withoutDo, canManageDocuments: true, canManageCustoms: true, canManageException: true, deliveryOrderBlockedReason: '海铁渠道不适用 DO 资料' }, global: { plugins: [ElementPlus] } }) + const deliveryRow = wrapper.findAll('.document-table tbody tr').find((row) => row.text().includes('DO 资料'))! + expect(deliveryRow.text()).toContain('海铁渠道不适用 DO 资料') + expect(deliveryRow.find('button').exists()).toBe(false) + }) + it('renders two file rows and one inline PU code row without a nested heading', async () => { const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } }) const headers = wrapper.findAll('.document-table thead th').map((cell) => cell.text()) diff --git a/src/modules/container-loading/components/ContainerOverviewPanel.vue b/src/modules/container-loading/components/ContainerOverviewPanel.vue index ad57a4a9..e1aa3bed 100644 --- a/src/modules/container-loading/components/ContainerOverviewPanel.vue +++ b/src/modules/container-loading/components/ContainerOverviewPanel.vue @@ -13,6 +13,7 @@ const props = defineProps<{ canManageException: boolean documentBusy?: boolean pickupCodeBusy?: boolean + deliveryOrderBlockedReason?: string | null sections?: Array<'exception' | 'documents' | 'pda'> }>() @@ -38,6 +39,7 @@ function canDownloadDocument(documentType: string) { || (documentType === 'DESTINATION_CLEARANCE' && props.canManageCustoms) } function canUploadDocument(documentType: string) { + if (documentType === 'DELIVERY_ORDER' && props.deliveryOrderBlockedReason) return false return containerDownloadableTypes.has(documentType) ? props.canManageDocuments : documentType === 'DESTINATION_CLEARANCE' && props.canManageCustoms } function hasDocumentAction(documentType: string) { @@ -130,7 +132,7 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
下载{{ documentsByType.get(item.type) ? '更新附件' : '上传附件' }}—
{{ item.label }} - {{ documentsByType.get(item.type)?.fileName || '—' }} + {{ documentsByType.get(item.type)?.fileName || '—' }}{{ deliveryOrderBlockedReason }} {{ documentsByType.get(item.type)?.uploadedByName || '—' }} {{ documentsByType.get(item.type) ? formatTime(documentsByType.get(item.type)?.uploadedAt) : '—' }} {{ documentStatusLabel(item.type) }} @@ -165,7 +167,7 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() }) .overview-section header > span, .overview-section small { color: var(--cc-muted); font-size: 12px; } .overview-section p { margin: 5px 0; color: var(--cc-muted); font-size: 13px; } .exception-management{padding-top:10px;padding-bottom:10px}.exception-management__header{margin-bottom:7px!important}.exception-management__header>div:first-child{display:flex;min-width:0;align-items:center;gap:14px}.exception-current{display:flex;min-width:0;align-items:center;gap:9px;margin:0!important}.exception-current strong,.exception-current span{overflow:hidden;max-width:360px;text-overflow:ellipsis;white-space:nowrap}.exception-current strong{color:var(--el-color-danger)}.exception-current span{font-size:12px}.exception-management__actions{display:flex;align-items:center;gap:8px}.exception-history{overflow-x:auto;border-top:1px solid var(--cc-border);font-size:12px}.exception-history__head,.exception-history li{display:grid;grid-template-columns:145px 120px 130px minmax(220px,1fr);gap:8px;align-items:center;min-width:680px;padding:6px 4px}.exception-history__head{color:var(--cc-muted);font-weight:650}.exception-history ol{margin:0;padding:0;list-style:none}.exception-history li{border-top:1px solid var(--cc-border)}.exception-history li time,.exception-history li span{overflow:hidden;color:var(--cc-muted);text-overflow:ellipsis;white-space:nowrap}.exception-history__empty{margin:0!important;padding:7px 4px;border-top:1px solid var(--cc-border);font-size:12px!important} -.document-table-wrap{width:100%;overflow-x:auto;border:1px solid var(--cc-border)}.document-table{width:100%;min-width:940px;border-collapse:collapse;table-layout:fixed;font-size:12px}.document-table th,.document-table td{height:40px;padding:5px 10px;border-bottom:1px solid var(--cc-border);background:#fff;text-align:left;vertical-align:middle}.document-table tbody tr:last-child td{border-bottom:0}.document-table th{background:var(--cc-bg-subtle,#f7f8fa);color:var(--cc-muted);font-weight:650}.document-table th:nth-child(1){width:260px}.document-table th:nth-child(2){width:180px}.document-table th:nth-child(3){width:210px}.document-table th:nth-child(4){width:120px}.document-table th:nth-child(5){width:150px}.document-table th:nth-child(6){width:90px}.document-table__actions{position:sticky;left:0;z-index:1;box-shadow:1px 0 0 var(--cc-border)}.document-table th.document-table__actions{z-index:2}.document-table td strong{font-size:13px}.document-table td small{display:block;overflow:hidden;max-width:170px;margin-top:2px;text-overflow:ellipsis;white-space:nowrap}.document-file-name{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.document-actions{display:flex;align-items:center;gap:5px;white-space:nowrap}.document-actions :deep(.el-button){margin-left:0}.no-action{color:var(--cc-muted)}.pickup-code-row :deep(.el-input){max-width:260px}.pickup-code-row :deep(.el-input__inner){font-variant-numeric:tabular-nums} +.document-table-wrap{width:100%;overflow-x:auto;border:1px solid var(--cc-border)}.document-table{width:100%;min-width:940px;border-collapse:collapse;table-layout:fixed;font-size:12px}.document-table th,.document-table td{height:40px;padding:5px 10px;border-bottom:1px solid var(--cc-border);background:#fff;text-align:left;vertical-align:middle}.document-table tbody tr:last-child td{border-bottom:0}.document-table th{background:var(--cc-bg-subtle,#f7f8fa);color:var(--cc-muted);font-weight:650}.document-table th:nth-child(1){width:260px}.document-table th:nth-child(2){width:180px}.document-table th:nth-child(3){width:210px}.document-table th:nth-child(4){width:120px}.document-table th:nth-child(5){width:150px}.document-table th:nth-child(6){width:90px}.document-table__actions{position:sticky;left:0;z-index:1;box-shadow:1px 0 0 var(--cc-border)}.document-table th.document-table__actions{z-index:2}.document-table td strong{font-size:13px}.document-table td small{display:block;overflow:hidden;max-width:170px;margin-top:2px;text-overflow:ellipsis;white-space:nowrap}.document-file-name{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.document-blocked-reason{color:var(--cc-muted)}.document-actions{display:flex;align-items:center;gap:5px;white-space:nowrap}.document-actions :deep(.el-button){margin-left:0}.no-action{color:var(--cc-muted)}.pickup-code-row :deep(.el-input){max-width:260px}.pickup-code-row :deep(.el-input__inner){font-variant-numeric:tabular-nums} .pda-summary{display:grid;grid-template-columns:minmax(220px,1fr) minmax(0,2fr);align-items:center;gap:16px;margin-bottom:10px}.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; margin:0; } .metric-grid > div { display: grid; gap: 2px; padding: 6px 9px; background: var(--cc-bg-subtle, #f7f8fa); } .metric-grid span { color: var(--cc-muted); font-size: 12px; } diff --git a/src/modules/container-loading/pages/ContainerDetailView.vue b/src/modules/container-loading/pages/ContainerDetailView.vue index d2593373..6a7973fe 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.vue +++ b/src/modules/container-loading/pages/ContainerDetailView.vue @@ -22,7 +22,7 @@ import ContainerCustomsPanel from '../components/ContainerCustomsPanel.vue' import ContainerLastLegFeesPanel from '../components/ContainerLastLegFeesPanel.vue' import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue' import OceanBookingLinkDialog from '../components/OceanBookingLinkDialog.vue' -import { getSmartLoadingChannelGroup } from '../smartLoadingChannelGroups' +import { getSmartLoadingChannelGroup, isSeaRailChannel } from '../smartLoadingChannelGroups' import { cancelContainerException, completeContainerClearance, @@ -85,6 +85,12 @@ const hasAction = (action: ContainerWorkbenchAction) => Boolean(workbenchDetail. const canEdit = computed(() => isDomestic.value && access.can('platform:firstleg:container:edit') && hasAction('EDIT_CONTAINER')) const canManageCustoms = computed(() => isDomestic.value && access.can('platform:firstleg:customs:manage') && hasAction('MANAGE_CUSTOMS')) const canManageDestinationDocuments = computed(() => isOverseas.value && access.can('platform:firstleg:terminal-drayage:document:edit') && hasAction('MANAGE_DESTINATION_DOCUMENTS')) +const deliveryOrderBlockedReason = computed(() => { + if (isSeaRailChannel(detail.value?.channelCode)) return '海铁渠道不适用 DO 资料' + if (detail.value?.planStatus !== 'COMPLETED' || !detail.value?.outboundConfirmedAt) return '货柜进入海外履约后才可上传' + if (detail.value?.actualEmptyReportedAt) return '货柜已还柜,不可再上传' + return null +}) const canRecordEmptyReturn = computed(() => isOverseas.value && access.can('platform:firstleg:terminal-drayage:empty-return:confirm') && hasAction('RECORD_EMPTY_RETURN') @@ -373,6 +379,7 @@ function truckReferenceText(status?: string | null) { function openDocument(type: string) { if (type === 'DESTINATION_CLEARANCE') { if (canManageDestinationDocuments.value) void openClearance(); return } if (type !== 'DELIVERY_ORDER' || !canManageDestinationDocuments.value) return + if (deliveryOrderBlockedReason.value) { ElMessage.warning(deliveryOrderBlockedReason.value); return } documentType.value = type as UploadDocumentType documentFile.value = null; documentError.value = null; documentRefreshWarning.value = null; documentUploadKey.value += 1; documentOpen.value = true } @@ -537,7 +544,7 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action

{{ isDomestic ? '出口报关信息' : '目的港清关与提柜资料' }}

- +
diff --git a/src/modules/container-loading/smartLoadingChannelGroups.test.ts b/src/modules/container-loading/smartLoadingChannelGroups.test.ts index 56497582..7d9dff19 100644 --- a/src/modules/container-loading/smartLoadingChannelGroups.test.ts +++ b/src/modules/container-loading/smartLoadingChannelGroups.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { SMART_LOADING_CHANNEL_GROUPS, SMART_LOADING_WAREHOUSE_GROUPS, + isSeaRailChannel, resolveSmartLoadingChannelGroup, resolveSmartLoadingWarehouseGroup, } from './smartLoadingChannelGroups' @@ -33,6 +34,11 @@ describe('smartLoadingChannelGroups', () => { expect(resolveSmartLoadingChannelGroup('UNKNOWN', '未确认新渠道')).toBeNull() }) + it('identifies canonical and legacy sea-rail channel codes', () => { + expect(['EXX_RAIL_NY', 'NYEXX', 'OA_CHICAGO_RAIL', 'CPHT'].every(isSeaRailChannel)).toBe(true) + expect(isSeaRailChannel('OA_LA_TRUCK')).toBe(false) + }) + it('exposes Yiwu and Dongguan as the fixed top-level warehouses', () => { expect(SMART_LOADING_WAREHOUSE_GROUPS.map((group) => group.name)).toEqual(['义乌仓', '东莞仓']) expect(resolveSmartLoadingWarehouseGroup('义乌国内仓')?.code).toBe('YIWU') diff --git a/src/modules/container-loading/smartLoadingChannelGroups.ts b/src/modules/container-loading/smartLoadingChannelGroups.ts index 7a95bcc7..05d157f3 100644 --- a/src/modules/container-loading/smartLoadingChannelGroups.ts +++ b/src/modules/container-loading/smartLoadingChannelGroups.ts @@ -15,6 +15,12 @@ export const SMART_LOADING_WAREHOUSE_GROUPS: readonly SmartLoadingWarehouseGroup { code: 'DONGGUAN', name: '东莞仓', nameKeyword: '东莞' }, ] as const +const SEA_RAIL_CHANNEL_CODES = new Set(['EXX_RAIL_NY', 'NYEXX', 'OA_CHICAGO_RAIL', 'CPHT']) + +export function isSeaRailChannel(channelCode?: string | null) { + return SEA_RAIL_CHANNEL_CODES.has(String(channelCode || '').trim().toUpperCase()) +} + export const SMART_LOADING_CHANNEL_GROUPS: readonly SmartLoadingChannelGroup[] = [ { code: 'MATSON_CLX', diff --git a/src/modules/container-loading/types.ts b/src/modules/container-loading/types.ts index 044b60fd..b8d786c1 100644 --- a/src/modules/container-loading/types.ts +++ b/src/modules/container-loading/types.ts @@ -56,6 +56,7 @@ export interface ContainerBookingResource { destinationClearanceDocumentStatus?: ContainerDocumentStatus customsDeclarationDocumentStatus?: ContainerDocumentStatus deliveryOrderDocumentStatus?: ContainerDocumentStatus + outboundConfirmedAt?: string | null version: number createdAt?: string | null updatedAt?: string | null diff --git a/src/modules/customers/pages/CustomerDetailView.test.ts b/src/modules/customers/pages/CustomerDetailView.test.ts index 045e4750..f6dd6e31 100644 --- a/src/modules/customers/pages/CustomerDetailView.test.ts +++ b/src/modules/customers/pages/CustomerDetailView.test.ts @@ -9,7 +9,6 @@ import { listTenantQualifications, type PlatformTenantResource, } from '@/modules/iam' -import { listPlatformFirstLegOrders, type PlatformFirstLegOrderResource } from '@/modules/first-leg-orders' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAuthStore } from '@/core/auth/auth' import CustomerDetailView from './CustomerDetailView.vue' @@ -39,14 +38,6 @@ vi.mock('@/modules/iam', () => ({ qualificationFileName: vi.fn((documentUrl: string) => documentUrl.split('/').at(-1) || '资质文件'), })) -vi.mock('@/modules/first-leg-orders', () => ({ - listPlatformFirstLegOrders: vi.fn(), -})) - -vi.mock('@/modules/finance', () => ({ - CustomerStatementsPanel: { template: '
财务账单
' }, -})) - class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} @@ -90,17 +81,6 @@ describe('CustomerDetailView', () => { membershipId: 'member-1', userId: 'user-1', authUserId: 'auth-1', username: 'merchant_admin', displayName: '张三', phone: '13800138000', email: 'ops@example.com', memberType: 'OWNER', memberNo: null, status: 'ACTIVE', roleIds: [], roleNames: ['企业管理员'], }]) - vi.mocked(listPlatformFirstLegOrders).mockResolvedValue({ - records: [{ - id: 'order-1', tenantId: tenant.id, orderNo: 'BY20260807001', customerId: null, customerName: tenant.tenantName, - status: 'PROCESSING', transportMode: 'SEA', tradeTerm: null, destinationCountryCode: 'US', destinationWarehouseId: null, - destinationWarehouseName: '洛杉矶', expectedShipDate: '2026-08-08', customerRemark: null, pickupMethod: 'WAREHOUSE', - receivingWarehouseId: 'warehouse-1', receivingWarehouseName: '深圳头程收货仓', receivingWarehouseAddress: '深圳市', receivingTime: null, - channelCode: 'SEA_FCL', channelName: '海运整箱', taxMode: 'DDP', containerMode: 'FULL_CONTAINER', customsInspection: false, - destinationType: 'FBA', amazonWarehouseCode: null, shipmentId: null, referenceId: null, expressCompany: null, expressTrackingNo: null, - createdAt: '2026-08-07T10:00:00', updatedAt: '2026-08-07T10:00:00', - } satisfies PlatformFirstLegOrderResource], page: 1, size: 10, total: 1, pages: 1, - }) vi.mocked(downloadTenantQualification).mockResolvedValue(new Blob(['image'], { type: 'image/png' })) }) @@ -109,7 +89,7 @@ describe('CustomerDetailView', () => { await flushPromises() expect([...document.querySelectorAll('.detail-tabs button')].map((button) => button.getAttribute('aria-label'))).toEqual([ - '基本信息', '运单记录', '财务账单', '计费规则', '操作日志', + '基本信息', '计费规则', '操作日志', ]) expect(document.body.textContent).not.toContain('累计运单') expect(document.body.textContent).not.toContain('累计应收') @@ -123,14 +103,6 @@ describe('CustomerDetailView', () => { expect(listPlatformTenantMembers).toHaveBeenCalledWith(tenant.id) expect(document.body.textContent).toContain('已替换') - const ordersTab = document.querySelector('.detail-tabs button[aria-label="运单记录"]') - ordersTab?.click() - await flushPromises() - expect(document.body.textContent).toContain('BY20260807001') - expect(document.body.textContent).toContain('海运整箱 FCL') - expect(document.body.textContent).not.toContain('暂无尺寸计算规则查看权限') - expect(listPlatformFirstLegOrders).toHaveBeenCalledWith({ tenantId: tenant.id, page: 1, size: 10 }) - const billingTab = document.querySelector('.detail-tabs button[aria-label="计费规则"]') billingTab?.click() await flushPromises() @@ -138,23 +110,6 @@ describe('CustomerDetailView', () => { expect(document.body.textContent).not.toContain('计费规则尚未接入') }) - it('does not expose unknown order status codes', async () => { - const result = await listPlatformFirstLegOrders({ tenantId: String(tenant.id), page: 1, size: 10 }) - vi.mocked(listPlatformFirstLegOrders).mockResolvedValueOnce({ - ...result, - records: result.records.map((order) => ({ ...order, status: 'LEGACY_STATUS' as PlatformFirstLegOrderResource['status'] })), - }) - - mount(CustomerDetailView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) - await flushPromises() - const ordersTab = document.querySelector('.detail-tabs button[aria-label="运单记录"]') - ordersTab?.click() - await flushPromises() - - expect(document.body.textContent).toContain('未知状态') - expect(document.body.textContent).not.toContain('LEGACY_STATUS') - }) - it('opens an authenticated qualification preview', async () => { mount(CustomerDetailView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) await flushPromises() @@ -167,28 +122,6 @@ describe('CustomerDetailView', () => { expect(document.querySelector('img[alt="license.png预览"]')).not.toBeNull() }) - it('does not duplicate the first page request when retrying from a later page', async () => { - const wrapper = mount(CustomerDetailView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) - await flushPromises() - vi.mocked(listPlatformFirstLegOrders).mockRejectedValueOnce(new Error('暂时不可用')) - - const vm = wrapper.vm as unknown as { orderPage: number } - vm.orderPage = 2 - await flushPromises() - expect(listPlatformFirstLegOrders).toHaveBeenCalledTimes(2) - - const ordersTab = document.querySelector('.detail-tabs button[aria-label="运单记录"]') - ordersTab?.click() - await flushPromises() - const retryButton = [...document.querySelectorAll('button')] - .find((button) => button.textContent?.includes('重新加载')) - retryButton?.click() - await flushPromises() - - expect(listPlatformFirstLegOrders).toHaveBeenCalledTimes(3) - expect(listPlatformFirstLegOrders).toHaveBeenLastCalledWith({ tenantId: tenant.id, page: 1, size: 10 }) - }) - it('reloads a reused detail view and ignores the previous customer response', async () => { const secondTenant = { ...tenant, id: '9', tenantCode: 'TEN9', tenantName: '远航供应链', legalName: '远航供应链有限公司' } let resolveFirstTenant!: (value: PlatformTenantResource) => void @@ -196,8 +129,6 @@ describe('CustomerDetailView', () => { vi.mocked(getPlatformTenant).mockImplementation((tenantId) => tenantId === '8' ? firstTenantRequest : Promise.resolve(secondTenant)) vi.mocked(listTenantQualifications).mockResolvedValue([]) vi.mocked(listPlatformTenantMembers).mockResolvedValue([]) - vi.mocked(listPlatformFirstLegOrders).mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 }) - mount(CustomerDetailView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) await flushPromises() routeControl.setTenantId('9') diff --git a/src/modules/customers/pages/CustomerDetailView.vue b/src/modules/customers/pages/CustomerDetailView.vue index ca1b34cf..2a585f83 100644 --- a/src/modules/customers/pages/CustomerDetailView.vue +++ b/src/modules/customers/pages/CustomerDetailView.vue @@ -4,7 +4,7 @@ import { useRoute, useRouter } from 'vue-router' import { ArrowLeft, Building2, Eye, FileCheck2, FileText, Pencil, Power, - Ship, UserRound, UsersRound, + UserRound, UsersRound, } from '@lucide/vue' import { ElMessage, ElMessageBox } from 'element-plus' import { @@ -19,15 +19,8 @@ import { type PlatformTenantResource, type PlatformTenantStatus, } from '@/modules/iam' -import { - listPlatformFirstLegOrders, - type PlatformFirstLegOrderResource, - type PlatformFirstLegOrderStatus, -} from '@/modules/first-leg-orders' import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' -import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' -import StatusBadge from '@/shared/components/StatusBadge.vue' import AdminPage from '@/shared/components/AdminPage.vue' import AppDialog from '@/shared/components/AppDialog.vue' import DetailPageLayout from '@/shared/components/DetailPageLayout.vue' @@ -36,10 +29,9 @@ import PageToolbar from '@/shared/components/PageToolbar.vue' import { useAccessPreviewStore } from '@/core/access/accessPreview' import CustomerBillingRulesPanel from '@/modules/customers/components/CustomerBillingRulesPanel.vue' import CustomerOverseasStorageFeePanel from '@/modules/customers/CustomerOverseasStorageFeePanel.vue' -import { CustomerStatementsPanel } from '@/modules/finance' import { CustomerAuditLogsPanel } from '@/modules/audit' -type DetailTab = 'basic' | 'orders' | 'statements' | 'billing' | 'logs' +type DetailTab = 'basic' | 'billing' | 'logs' type PreviewState = { url: string; kind: 'image' | 'pdf'; loading: boolean; failed: boolean } const route = useRoute() @@ -53,15 +45,7 @@ const loadError = ref(null) const customer = ref(null) const qualifications = ref([]) const members = ref([]) -const customerOrders = ref([]) -const customerOrderTotal = ref(0) -const orderPage = ref(1) -const orderPageSize = ref(10) -const customerOrdersLoading = ref(false) -const customerOrdersError = ref(null) -let skipNextOrderPageLoad = false let detailRequestSerial = 0 -let orderRequestSerial = 0 let disposed = false const previews = ref>({}) const previewOpen = ref(false) @@ -72,24 +56,11 @@ const tabList = ref(null) const tabs: Array<{ key: DetailTab; label: string; icon: string }> = [ { key: 'basic', label: '基本信息', icon: '📋' }, - { key: 'orders', label: '运单记录', icon: '📦' }, - { key: 'statements', label: '财务账单', icon: '🧾' }, { key: 'billing', label: '计费规则', icon: '⚖️' }, { key: 'logs', label: '操作日志', icon: '📝' }, ] const enterpriseMembers = computed(() => members.value.filter((member) => ['OWNER', 'EMPLOYEE'].includes(member.memberType))) -const orderColumns: DataTableColumn[] = [ - { key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' }, - { key: 'status', label: '状态', width: 106, slotName: 'status' }, - { key: 'orderNo', label: '运单号', minWidth: 180, slotName: 'orderNo' }, - { key: 'transport', label: '运输方式', minWidth: 150, slotName: 'transport' }, - { key: 'destination', label: '目的地', minWidth: 170, slotName: 'destination' }, - { key: 'receivingWarehouse', label: '平台收货仓', minWidth: 180, slotName: 'receivingWarehouse' }, - { key: 'expectedShipDate', label: '预计出运', width: 128, slotName: 'expectedShipDate' }, - { key: 'createdAt', label: '创建时间', width: 150, slotName: 'createdAt' }, -] - function displayText(value?: string | null) { return value?.trim() || '—' } function formatDate(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' } function formatCapital(value: number | string | null, currency: string | null) { @@ -120,36 +91,6 @@ function qualificationName(item: PlatformTenantQualification) { try { return qualificationFileName(item.documentUrl) } catch { return `${qualificationLabel(item.qualificationType)}文件` } } -function orderStatusLabel(status: PlatformFirstLegOrderStatus) { - return ({ DRAFT: '草稿', SUBMITTED: '待审核', APPROVED: '已审核', ACCEPTED: '已受理', PROCESSING: '处理中', COMPLETED: '已完成', CANCELLED: '已取消', REJECTED: '已驳回(旧契约)' } as Record)[status] || '未知状态' -} -function transportLabel(order: PlatformFirstLegOrderResource) { - if (order.transportMode === 'SEA') return order.containerMode === 'FULL_CONTAINER' ? '海运整箱 FCL' : '海运拼箱 LCL' - if (order.transportMode === 'AIR') return '空运' - if (order.transportMode === 'RAIL') return '铁路运输' - return '陆运' -} -function formattedDate(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' } - -async function loadCustomerOrders(page = orderPage.value, requestedTenantId = tenantId.value) { - if (!access.can('platform:order:list')) return - const serial = ++orderRequestSerial - customerOrdersLoading.value = true - customerOrdersError.value = null - try { - const result = await listPlatformFirstLegOrders({ tenantId: requestedTenantId, page, size: orderPageSize.value }) - if (disposed || serial !== orderRequestSerial || requestedTenantId !== tenantId.value) return - customerOrders.value = result.records - customerOrderTotal.value = result.total - } catch (error) { - if (disposed || serial !== orderRequestSerial || requestedTenantId !== tenantId.value) return - customerOrders.value = [] - customerOrdersError.value = getApiErrorDetails(error, '运单记录加载失败,请稍后重试') - } finally { - if (!disposed && serial === orderRequestSerial) customerOrdersLoading.value = false - } -} - function clearPreviews() { for (const preview of Object.values(previews.value)) if (preview.url) URL.revokeObjectURL(preview.url) previews.value = {} @@ -195,9 +136,6 @@ async function loadDetail() { customer.value = null qualifications.value = [] members.value = [] - customerOrders.value = [] - customerOrderTotal.value = 0 - customerOrdersError.value = null try { const [tenantResult, qualificationResult, memberResult] = await Promise.all([ getPlatformTenant(requestedTenantId), @@ -211,17 +149,6 @@ async function loadDetail() { previews.value = Object.fromEntries(qualificationResult.map((item) => [String(item.id), { url: '', kind: qualificationName(item).toLowerCase().endsWith('.pdf') ? 'pdf' : 'image', loading: false, failed: false, } satisfies PreviewState])) - - if (access.can('platform:order:list')) { - if (orderPage.value !== 1) skipNextOrderPageLoad = true - orderPage.value = 1 - void loadCustomerOrders(1, requestedTenantId) - } else { - customerOrdersLoading.value = false - customerOrdersError.value = null - customerOrders.value = [] - customerOrderTotal.value = 0 - } } catch (error) { if (disposed || serial !== detailRequestSerial || requestedTenantId !== tenantId.value) return customer.value = null @@ -233,10 +160,6 @@ async function loadDetail() { } } -function openOrder(order: PlatformFirstLegOrderResource) { - void router.push({ name: 'unified-order-detail', params: { orderId: String(order.id) }, query: { tenantId: tenantId.value } }) -} - async function changeStatus() { if (!customer.value || statusChanging.value) return const next = customer.value.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE' @@ -272,35 +195,25 @@ watch(tenantId, (value) => { if (value) void loadDetail() else { detailRequestSerial += 1 - orderRequestSerial += 1 loading.value = false customer.value = null qualifications.value = [] members.value = [] - customerOrders.value = [] } }, { immediate: true }) onBeforeUnmount(() => { disposed = true detailRequestSerial += 1 - orderRequestSerial += 1 clearPreviews() }) -watch(orderPage, (page, previousPage) => { - if (skipNextOrderPageLoad) { - skipNextOrderPageLoad = false - return - } - if (page !== previousPage) void loadCustomerOrders(page) -}) -
-

运单记录

客户关联头程运单,共 {{ customerOrderTotal }} 票

- -
暂无运单查看权限

请联系管理员申请头程订单查看权限。

-
- -
diff --git a/src/modules/customers/pages/CustomersView.test.ts b/src/modules/customers/pages/CustomersView.test.ts index fd7bc406..25d142fe 100644 --- a/src/modules/customers/pages/CustomersView.test.ts +++ b/src/modules/customers/pages/CustomersView.test.ts @@ -263,7 +263,7 @@ describe('CustomersView', () => { await wrapper.get('input[placeholder="请输入工商登记公司名称"]').setValue(fullTenant.tenantName) await wrapper.get('input[placeholder="请输入营业执照上的企业名称"]').setValue(fullTenant.legalName) - await wrapper.get('input[placeholder="请输入法定代表人姓名"]').setValue(fullTenant.legalRepresentative) + expect(wrapper.get('input[placeholder="请输入法定代表人姓名(选填)"]').attributes('aria-required')).not.toBe('true') await wrapper.get('input[placeholder="请输入主要联系人姓名"]').setValue(fullTenant.contactName) await wrapper.get('input[placeholder="请输入公司联系电话"]').setValue(fullTenant.contactPhone) await wrapper.get('input[placeholder="请输入18位大写字母或数字"]').setValue(fullTenant.unifiedSocialCreditCode) @@ -277,6 +277,7 @@ describe('CustomersView', () => { expect(createPlatformTenant).toHaveBeenCalledWith(expect.objectContaining({ tenantName: fullTenant.tenantName, contactPhone: fullTenant.contactPhone, + legalRepresentative: '', })) expect(createPlatformTenant).toHaveBeenCalledWith(expect.not.objectContaining({ unifiedSocialCreditCode: expect.anything() })) expect(uploadPlatformTenantRequiredQualificationPackage).toHaveBeenCalledWith( @@ -710,6 +711,9 @@ describe('CustomersView', () => { auth.permissions = [...auth.permissions, 'platform:customer:delete'] useAccessPreviewStore().initializeAfterLogin() vi.mocked(archivePlatformTenant).mockResolvedValue({ ...fullTenant, status: 'ARCHIVED' }) + vi.mocked(listPlatformTenants) + .mockResolvedValueOnce({ records: [fullTenant], page: 1, size: 10, total: 1, pages: 1 }) + .mockResolvedValueOnce({ records: [], page: 1, size: 10, total: 0, pages: 0 }) mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) await flushPromises() @@ -730,6 +734,7 @@ describe('CustomersView', () => { await flushPromises() expect(archivePlatformTenant).toHaveBeenCalledWith(fullTenant.id) + expect(document.body.textContent).not.toContain(fullTenant.tenantName) }) it('keeps the first operation column unfixed so it cannot cover a 320px table viewport', async () => { @@ -757,24 +762,6 @@ describe('CustomersView', () => { expect(document.body.textContent).toContain('代登录原因') }) - it('treats archived customers as read-only and terminal', async () => { - const auth = useAuthStore() - auth.permissions = [...auth.permissions, 'platform:customer:edit', 'platform:customer:status', 'platform:customer:delete'] - useAccessPreviewStore().initializeAfterLogin() - vi.mocked(listPlatformTenants).mockResolvedValue({ - records: [{ ...fullTenant, status: 'ARCHIVED' }], page: 1, size: 10, total: 1, pages: 1, - }) - - mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) - await flushPromises() - - expect(document.body.textContent).toContain('已归档') - expect(tableButtonByText('编辑')).toBeUndefined() - expect(tableButtonByText('启用')).toBeUndefined() - expect(tableButtonByText('停用')).toBeUndefined() - expect(tableButtonByText('删除')).toBeUndefined() - }) - it('renders one overflow menu per customer when permitted actions exceed three', async () => { const auth = useAuthStore() auth.permissions = [...auth.permissions, 'platform:customer:reset-password', 'platform:customer:impersonate', 'platform:customer:status', 'platform:customer:delete'] diff --git a/src/modules/customers/pages/CustomersView.vue b/src/modules/customers/pages/CustomersView.vue index 785aca13..512869a5 100644 --- a/src/modules/customers/pages/CustomersView.vue +++ b/src/modules/customers/pages/CustomersView.vue @@ -131,7 +131,7 @@ const queryFields: QueryField[] = [ { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [ { label: '待完善', value: 'PROFILE_REQUIRED' }, { label: '待审核', value: 'PENDING' }, { label: '启用', value: 'ACTIVE' }, { label: '已驳回', value: 'REJECTED' }, - { label: '已停用', value: 'DISABLED' }, { label: '已归档', value: 'ARCHIVED' }, + { label: '已停用', value: 'DISABLED' }, ] }, ] @@ -146,7 +146,7 @@ const form = ref(emptyForm()) const rules: FormRules = { tenantName: [{ required: true, message: '请输入公司名称', trigger: 'blur' }], legalName: [{ required: true, message: '请输入营业执照上的企业名称', trigger: 'blur' }], - legalRepresentative: [{ required: true, message: '请输入法定代表人姓名', trigger: 'blur' }], + legalRepresentative: [{ max: 64, message: '法定代表人最多 64 个字符', trigger: 'blur' }], unifiedSocialCreditCode: [ { required: true, message: '请输入统一社会信用代码', trigger: 'blur' }, { pattern: /^[0-9A-Z]{18}$/, message: '请输入 18 位大写字母或数字', trigger: 'blur' }, @@ -1074,7 +1074,7 @@ onMounted(() => { void loadCustomers() })
- +
diff --git a/src/modules/iam/api/platformIam.ts b/src/modules/iam/api/platformIam.ts index 3123b5f0..589b449e 100644 --- a/src/modules/iam/api/platformIam.ts +++ b/src/modules/iam/api/platformIam.ts @@ -53,7 +53,7 @@ export interface PlatformTenantResource { export interface CreatePlatformTenantRequest { tenantName: string legalName: string - legalRepresentative: string + legalRepresentative?: string contactName: string contactPhone: string contactEmail: string diff --git a/src/modules/inbounds/measurement/InboundMeasurementDialog.test.ts b/src/modules/inbounds/measurement/InboundMeasurementDialog.test.ts index 328d8d00..23162076 100644 --- a/src/modules/inbounds/measurement/InboundMeasurementDialog.test.ts +++ b/src/modules/inbounds/measurement/InboundMeasurementDialog.test.ts @@ -21,7 +21,8 @@ describe('InboundMeasurementDialog', () => { expect(source).not.toContain('class="surface-panel measurement-dialog-weight"') expect(source).toContain('border-bottom: 1px solid var(--cc-border)') expect(source).toContain('@summary-changed="handleWeightSummaryChanged"') - expect(source).toContain('measurementPanelRef.value?.updateActualWeights(actualGrossWeightKg, actualNetWeightKg)') + expect(source).toContain('measurementPanelRef.value?.updateWeightEvidence(actualGrossWeightKg, actualNetWeightKg, hasConfirmedWeight)') + expect(source).toContain('measurementPanelRef?.submissionBlockReason') }) it('offers the shared fullscreen control without forcing fullscreen on open', () => { diff --git a/src/modules/inbounds/measurement/InboundMeasurementDialog.vue b/src/modules/inbounds/measurement/InboundMeasurementDialog.vue index 7d1ba634..f0fe3a0a 100644 --- a/src/modules/inbounds/measurement/InboundMeasurementDialog.vue +++ b/src/modules/inbounds/measurement/InboundMeasurementDialog.vue @@ -45,8 +45,12 @@ async function beforeClose(done: () => void) { if (await mayClose()) done() } -function handleWeightSummaryChanged(actualGrossWeightKg: number | null, actualNetWeightKg: number | null) { - measurementPanelRef.value?.updateActualWeights(actualGrossWeightKg, actualNetWeightKg) +function handleWeightSummaryChanged( + actualGrossWeightKg: number | null, + actualNetWeightKg: number | null, + hasConfirmedWeight: boolean, +) { + measurementPanelRef.value?.updateWeightEvidence(actualGrossWeightKg, actualNetWeightKg, hasConfirmedWeight) } function handleUpdated() { @@ -95,6 +99,9 @@ function handleSubmitted() {