优化
This commit is contained in:
@@ -47,6 +47,14 @@ describe('ContainerOverviewPanel', () => {
|
|||||||
expect(wrapper.text()).not.toContain('9007199254740999')
|
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 () => {
|
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 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())
|
const headers = wrapper.findAll('.document-table thead th').map((cell) => cell.text())
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const props = defineProps<{
|
|||||||
canManageException: boolean
|
canManageException: boolean
|
||||||
documentBusy?: boolean
|
documentBusy?: boolean
|
||||||
pickupCodeBusy?: boolean
|
pickupCodeBusy?: boolean
|
||||||
|
deliveryOrderBlockedReason?: string | null
|
||||||
sections?: Array<'exception' | 'documents' | 'pda'>
|
sections?: Array<'exception' | 'documents' | 'pda'>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ function canDownloadDocument(documentType: string) {
|
|||||||
|| (documentType === 'DESTINATION_CLEARANCE' && props.canManageCustoms)
|
|| (documentType === 'DESTINATION_CLEARANCE' && props.canManageCustoms)
|
||||||
}
|
}
|
||||||
function canUploadDocument(documentType: string) {
|
function canUploadDocument(documentType: string) {
|
||||||
|
if (documentType === 'DELIVERY_ORDER' && props.deliveryOrderBlockedReason) return false
|
||||||
return containerDownloadableTypes.has(documentType) ? props.canManageDocuments : documentType === 'DESTINATION_CLEARANCE' && props.canManageCustoms
|
return containerDownloadableTypes.has(documentType) ? props.canManageDocuments : documentType === 'DESTINATION_CLEARANCE' && props.canManageCustoms
|
||||||
}
|
}
|
||||||
function hasDocumentAction(documentType: string) {
|
function hasDocumentAction(documentType: string) {
|
||||||
@@ -130,7 +132,7 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
|
|||||||
<tbody><tr v-for="item in documentTypes" :key="item.type">
|
<tbody><tr v-for="item in documentTypes" :key="item.type">
|
||||||
<td class="document-table__actions"><div class="document-actions"><el-button v-if="documentsByType.get(item.type) && canDownloadDocument(item.type)" size="small" :loading="documentBusy" @click="emit('downloadDocument', documentsByType.get(item.type)!)"><Download :size="15" />下载</el-button><el-button v-if="canUploadDocument(item.type) && !documentsByType.get(item.type)?.completed" size="small" @click="emit('uploadDocument', item.type)"><FileUp :size="15" />{{ documentsByType.get(item.type) ? '更新附件' : '上传附件' }}</el-button><span v-if="!hasDocumentAction(item.type)" class="no-action">—</span></div></td>
|
<td class="document-table__actions"><div class="document-actions"><el-button v-if="documentsByType.get(item.type) && canDownloadDocument(item.type)" size="small" :loading="documentBusy" @click="emit('downloadDocument', documentsByType.get(item.type)!)"><Download :size="15" />下载</el-button><el-button v-if="canUploadDocument(item.type) && !documentsByType.get(item.type)?.completed" size="small" @click="emit('uploadDocument', item.type)"><FileUp :size="15" />{{ documentsByType.get(item.type) ? '更新附件' : '上传附件' }}</el-button><span v-if="!hasDocumentAction(item.type)" class="no-action">—</span></div></td>
|
||||||
<td><strong>{{ item.label }}</strong></td>
|
<td><strong>{{ item.label }}</strong></td>
|
||||||
<td><span class="document-file-name" :title="documentsByType.get(item.type)?.fileName || undefined">{{ documentsByType.get(item.type)?.fileName || '—' }}</span></td>
|
<td><span class="document-file-name" :title="documentsByType.get(item.type)?.fileName || undefined">{{ documentsByType.get(item.type)?.fileName || '—' }}</span><small v-if="item.type === 'DELIVERY_ORDER' && deliveryOrderBlockedReason" class="document-blocked-reason">{{ deliveryOrderBlockedReason }}</small></td>
|
||||||
<td>{{ documentsByType.get(item.type)?.uploadedByName || '—' }}</td>
|
<td>{{ documentsByType.get(item.type)?.uploadedByName || '—' }}</td>
|
||||||
<td>{{ documentsByType.get(item.type) ? formatTime(documentsByType.get(item.type)?.uploadedAt) : '—' }}</td>
|
<td>{{ documentsByType.get(item.type) ? formatTime(documentsByType.get(item.type)?.uploadedAt) : '—' }}</td>
|
||||||
<td><el-tag size="small" :type="documentStatusType(item.type)">{{ documentStatusLabel(item.type) }}</el-tag></td>
|
<td><el-tag size="small" :type="documentStatusType(item.type)">{{ documentStatusLabel(item.type) }}</el-tag></td>
|
||||||
@@ -165,7 +167,7 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
|
|||||||
.overview-section header > span, .overview-section small { color: var(--cc-muted); font-size: 12px; }
|
.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; }
|
.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}
|
.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; }
|
.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 > div { display: grid; gap: 2px; padding: 6px 9px; background: var(--cc-bg-subtle, #f7f8fa); }
|
||||||
.metric-grid span { color: var(--cc-muted); font-size: 12px; }
|
.metric-grid span { color: var(--cc-muted); font-size: 12px; }
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import ContainerCustomsPanel from '../components/ContainerCustomsPanel.vue'
|
|||||||
import ContainerLastLegFeesPanel from '../components/ContainerLastLegFeesPanel.vue'
|
import ContainerLastLegFeesPanel from '../components/ContainerLastLegFeesPanel.vue'
|
||||||
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
|
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
|
||||||
import OceanBookingLinkDialog from '../components/OceanBookingLinkDialog.vue'
|
import OceanBookingLinkDialog from '../components/OceanBookingLinkDialog.vue'
|
||||||
import { getSmartLoadingChannelGroup } from '../smartLoadingChannelGroups'
|
import { getSmartLoadingChannelGroup, isSeaRailChannel } from '../smartLoadingChannelGroups'
|
||||||
import {
|
import {
|
||||||
cancelContainerException,
|
cancelContainerException,
|
||||||
completeContainerClearance,
|
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 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 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 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
|
const canRecordEmptyReturn = computed(() => isOverseas.value
|
||||||
&& access.can('platform:firstleg:terminal-drayage:empty-return:confirm')
|
&& access.can('platform:firstleg:terminal-drayage:empty-return:confirm')
|
||||||
&& hasAction('RECORD_EMPTY_RETURN')
|
&& hasAction('RECORD_EMPTY_RETURN')
|
||||||
@@ -373,6 +379,7 @@ function truckReferenceText(status?: string | null) {
|
|||||||
function openDocument(type: string) {
|
function openDocument(type: string) {
|
||||||
if (type === 'DESTINATION_CLEARANCE') { if (canManageDestinationDocuments.value) void openClearance(); return }
|
if (type === 'DESTINATION_CLEARANCE') { if (canManageDestinationDocuments.value) void openClearance(); return }
|
||||||
if (type !== 'DELIVERY_ORDER' || !canManageDestinationDocuments.value) return
|
if (type !== 'DELIVERY_ORDER' || !canManageDestinationDocuments.value) return
|
||||||
|
if (deliveryOrderBlockedReason.value) { ElMessage.warning(deliveryOrderBlockedReason.value); return }
|
||||||
documentType.value = type as UploadDocumentType
|
documentType.value = type as UploadDocumentType
|
||||||
documentFile.value = null; documentError.value = null; documentRefreshWarning.value = null; documentUploadKey.value += 1; documentOpen.value = true
|
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
|
|||||||
|
|
||||||
<section v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-customs-heading">
|
<section v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-customs-heading">
|
||||||
<h2 id="container-customs-heading">{{ isDomestic ? '出口报关信息' : '目的港清关与提柜资料' }}</h2>
|
<h2 id="container-customs-heading">{{ isDomestic ? '出口报关信息' : '目的港清关与提柜资料' }}</h2>
|
||||||
<ContainerOverviewPanel v-if="isOverseas && overview" :overview="overview" :sections="['documents']" :can-manage-documents="canManageDestinationDocuments" :can-manage-customs="canManageDestinationDocuments" :can-manage-exception="false" :document-busy="documentDownloading" :pickup-code-busy="pickupCodeSaving" @upload-document="openDocument" @download-document="downloadDocument" @save-pickup-code="savePickupCode" />
|
<ContainerOverviewPanel v-if="isOverseas && overview" :overview="overview" :sections="['documents']" :can-manage-documents="canManageDestinationDocuments" :can-manage-customs="canManageDestinationDocuments" :can-manage-exception="false" :document-busy="documentDownloading" :pickup-code-busy="pickupCodeSaving" :delivery-order-blocked-reason="deliveryOrderBlockedReason" @upload-document="openDocument" @download-document="downloadDocument" @save-pickup-code="savePickupCode" />
|
||||||
<el-alert v-if="documentRefreshWarning" type="warning" :closable="false" :title="documentRefreshWarning.message"><template #default><ApiErrorMeta :details="documentRefreshWarning" /></template></el-alert>
|
<el-alert v-if="documentRefreshWarning" type="warning" :closable="false" :title="documentRefreshWarning.message"><template #default><ApiErrorMeta :details="documentRefreshWarning" /></template></el-alert>
|
||||||
<ContainerCustomsPanel v-if="isDomestic && detail.planStatus === 'COMPLETED' && overview?.loadingPlanId && canListCustoms" :loading-plan-id="overview.loadingPlanId" :can-list="canListCustoms" :can-manage="canManageCustoms" :departure-state="customsDepartureState" @refreshed="refreshAfterCustoms" />
|
<ContainerCustomsPanel v-if="isDomestic && detail.planStatus === 'COMPLETED' && overview?.loadingPlanId && canListCustoms" :loading-plan-id="overview.loadingPlanId" :can-list="canListCustoms" :can-manage="canManageCustoms" :departure-state="customsDepartureState" @refreshed="refreshAfterCustoms" />
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import {
|
import {
|
||||||
SMART_LOADING_CHANNEL_GROUPS,
|
SMART_LOADING_CHANNEL_GROUPS,
|
||||||
SMART_LOADING_WAREHOUSE_GROUPS,
|
SMART_LOADING_WAREHOUSE_GROUPS,
|
||||||
|
isSeaRailChannel,
|
||||||
resolveSmartLoadingChannelGroup,
|
resolveSmartLoadingChannelGroup,
|
||||||
resolveSmartLoadingWarehouseGroup,
|
resolveSmartLoadingWarehouseGroup,
|
||||||
} from './smartLoadingChannelGroups'
|
} from './smartLoadingChannelGroups'
|
||||||
@@ -33,6 +34,11 @@ describe('smartLoadingChannelGroups', () => {
|
|||||||
expect(resolveSmartLoadingChannelGroup('UNKNOWN', '未确认新渠道')).toBeNull()
|
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', () => {
|
it('exposes Yiwu and Dongguan as the fixed top-level warehouses', () => {
|
||||||
expect(SMART_LOADING_WAREHOUSE_GROUPS.map((group) => group.name)).toEqual(['义乌仓', '东莞仓'])
|
expect(SMART_LOADING_WAREHOUSE_GROUPS.map((group) => group.name)).toEqual(['义乌仓', '东莞仓'])
|
||||||
expect(resolveSmartLoadingWarehouseGroup('义乌国内仓')?.code).toBe('YIWU')
|
expect(resolveSmartLoadingWarehouseGroup('义乌国内仓')?.code).toBe('YIWU')
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ export const SMART_LOADING_WAREHOUSE_GROUPS: readonly SmartLoadingWarehouseGroup
|
|||||||
{ code: 'DONGGUAN', name: '东莞仓', nameKeyword: '东莞' },
|
{ code: 'DONGGUAN', name: '东莞仓', nameKeyword: '东莞' },
|
||||||
] as const
|
] 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[] = [
|
export const SMART_LOADING_CHANNEL_GROUPS: readonly SmartLoadingChannelGroup[] = [
|
||||||
{
|
{
|
||||||
code: 'MATSON_CLX',
|
code: 'MATSON_CLX',
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export interface ContainerBookingResource {
|
|||||||
destinationClearanceDocumentStatus?: ContainerDocumentStatus
|
destinationClearanceDocumentStatus?: ContainerDocumentStatus
|
||||||
customsDeclarationDocumentStatus?: ContainerDocumentStatus
|
customsDeclarationDocumentStatus?: ContainerDocumentStatus
|
||||||
deliveryOrderDocumentStatus?: ContainerDocumentStatus
|
deliveryOrderDocumentStatus?: ContainerDocumentStatus
|
||||||
|
outboundConfirmedAt?: string | null
|
||||||
version: number
|
version: number
|
||||||
createdAt?: string | null
|
createdAt?: string | null
|
||||||
updatedAt?: string | null
|
updatedAt?: string | null
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
listTenantQualifications,
|
listTenantQualifications,
|
||||||
type PlatformTenantResource,
|
type PlatformTenantResource,
|
||||||
} from '@/modules/iam'
|
} from '@/modules/iam'
|
||||||
import { listPlatformFirstLegOrders, type PlatformFirstLegOrderResource } from '@/modules/first-leg-orders'
|
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { useAuthStore } from '@/core/auth/auth'
|
import { useAuthStore } from '@/core/auth/auth'
|
||||||
import CustomerDetailView from './CustomerDetailView.vue'
|
import CustomerDetailView from './CustomerDetailView.vue'
|
||||||
@@ -39,14 +38,6 @@ vi.mock('@/modules/iam', () => ({
|
|||||||
qualificationFileName: vi.fn((documentUrl: string) => documentUrl.split('/').at(-1) || '资质文件'),
|
qualificationFileName: vi.fn((documentUrl: string) => documentUrl.split('/').at(-1) || '资质文件'),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/modules/first-leg-orders', () => ({
|
|
||||||
listPlatformFirstLegOrders: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/modules/finance', () => ({
|
|
||||||
CustomerStatementsPanel: { template: '<div>财务账单</div>' },
|
|
||||||
}))
|
|
||||||
|
|
||||||
class ResizeObserverStub implements ResizeObserver {
|
class ResizeObserverStub implements ResizeObserver {
|
||||||
observe() {}
|
observe() {}
|
||||||
unobserve() {}
|
unobserve() {}
|
||||||
@@ -90,17 +81,6 @@ describe('CustomerDetailView', () => {
|
|||||||
membershipId: 'member-1', userId: 'user-1', authUserId: 'auth-1', username: 'merchant_admin', displayName: '张三',
|
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: ['企业管理员'],
|
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' }))
|
vi.mocked(downloadTenantQualification).mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -109,7 +89,7 @@ describe('CustomerDetailView', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect([...document.querySelectorAll('.detail-tabs button')].map((button) => button.getAttribute('aria-label'))).toEqual([
|
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('累计运单')
|
||||||
expect(document.body.textContent).not.toContain('累计应收')
|
expect(document.body.textContent).not.toContain('累计应收')
|
||||||
@@ -123,14 +103,6 @@ describe('CustomerDetailView', () => {
|
|||||||
expect(listPlatformTenantMembers).toHaveBeenCalledWith(tenant.id)
|
expect(listPlatformTenantMembers).toHaveBeenCalledWith(tenant.id)
|
||||||
expect(document.body.textContent).toContain('已替换')
|
expect(document.body.textContent).toContain('已替换')
|
||||||
|
|
||||||
const ordersTab = document.querySelector<HTMLButtonElement>('.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<HTMLButtonElement>('.detail-tabs button[aria-label="计费规则"]')
|
const billingTab = document.querySelector<HTMLButtonElement>('.detail-tabs button[aria-label="计费规则"]')
|
||||||
billingTab?.click()
|
billingTab?.click()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
@@ -138,23 +110,6 @@ describe('CustomerDetailView', () => {
|
|||||||
expect(document.body.textContent).not.toContain('计费规则尚未接入')
|
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<HTMLButtonElement>('.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 () => {
|
it('opens an authenticated qualification preview', async () => {
|
||||||
mount(CustomerDetailView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
mount(CustomerDetailView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
@@ -167,28 +122,6 @@ describe('CustomerDetailView', () => {
|
|||||||
expect(document.querySelector('img[alt="license.png预览"]')).not.toBeNull()
|
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<HTMLButtonElement>('.detail-tabs button[aria-label="运单记录"]')
|
|
||||||
ordersTab?.click()
|
|
||||||
await flushPromises()
|
|
||||||
const retryButton = [...document.querySelectorAll<HTMLButtonElement>('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 () => {
|
it('reloads a reused detail view and ignores the previous customer response', async () => {
|
||||||
const secondTenant = { ...tenant, id: '9', tenantCode: 'TEN9', tenantName: '远航供应链', legalName: '远航供应链有限公司' }
|
const secondTenant = { ...tenant, id: '9', tenantCode: 'TEN9', tenantName: '远航供应链', legalName: '远航供应链有限公司' }
|
||||||
let resolveFirstTenant!: (value: PlatformTenantResource) => void
|
let resolveFirstTenant!: (value: PlatformTenantResource) => void
|
||||||
@@ -196,8 +129,6 @@ describe('CustomerDetailView', () => {
|
|||||||
vi.mocked(getPlatformTenant).mockImplementation((tenantId) => tenantId === '8' ? firstTenantRequest : Promise.resolve(secondTenant))
|
vi.mocked(getPlatformTenant).mockImplementation((tenantId) => tenantId === '8' ? firstTenantRequest : Promise.resolve(secondTenant))
|
||||||
vi.mocked(listTenantQualifications).mockResolvedValue([])
|
vi.mocked(listTenantQualifications).mockResolvedValue([])
|
||||||
vi.mocked(listPlatformTenantMembers).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] } })
|
mount(CustomerDetailView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
routeControl.setTenantId('9')
|
routeControl.setTenantId('9')
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import {
|
import {
|
||||||
ArrowLeft, Building2, Eye,
|
ArrowLeft, Building2, Eye,
|
||||||
FileCheck2, FileText, Pencil, Power,
|
FileCheck2, FileText, Pencil, Power,
|
||||||
Ship, UserRound, UsersRound,
|
UserRound, UsersRound,
|
||||||
} from '@lucide/vue'
|
} from '@lucide/vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import {
|
import {
|
||||||
@@ -19,15 +19,8 @@ import {
|
|||||||
type PlatformTenantResource,
|
type PlatformTenantResource,
|
||||||
type PlatformTenantStatus,
|
type PlatformTenantStatus,
|
||||||
} from '@/modules/iam'
|
} from '@/modules/iam'
|
||||||
import {
|
|
||||||
listPlatformFirstLegOrders,
|
|
||||||
type PlatformFirstLegOrderResource,
|
|
||||||
type PlatformFirstLegOrderStatus,
|
|
||||||
} from '@/modules/first-leg-orders'
|
|
||||||
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
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 AdminPage from '@/shared/components/AdminPage.vue'
|
||||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.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 { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import CustomerBillingRulesPanel from '@/modules/customers/components/CustomerBillingRulesPanel.vue'
|
import CustomerBillingRulesPanel from '@/modules/customers/components/CustomerBillingRulesPanel.vue'
|
||||||
import CustomerOverseasStorageFeePanel from '@/modules/customers/CustomerOverseasStorageFeePanel.vue'
|
import CustomerOverseasStorageFeePanel from '@/modules/customers/CustomerOverseasStorageFeePanel.vue'
|
||||||
import { CustomerStatementsPanel } from '@/modules/finance'
|
|
||||||
import { CustomerAuditLogsPanel } from '@/modules/audit'
|
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 }
|
type PreviewState = { url: string; kind: 'image' | 'pdf'; loading: boolean; failed: boolean }
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -53,15 +45,7 @@ const loadError = ref<ApiErrorDetails | null>(null)
|
|||||||
const customer = ref<PlatformTenantResource | null>(null)
|
const customer = ref<PlatformTenantResource | null>(null)
|
||||||
const qualifications = ref<PlatformTenantQualification[]>([])
|
const qualifications = ref<PlatformTenantQualification[]>([])
|
||||||
const members = ref<PlatformTenantMember[]>([])
|
const members = ref<PlatformTenantMember[]>([])
|
||||||
const customerOrders = ref<PlatformFirstLegOrderResource[]>([])
|
|
||||||
const customerOrderTotal = ref(0)
|
|
||||||
const orderPage = ref(1)
|
|
||||||
const orderPageSize = ref(10)
|
|
||||||
const customerOrdersLoading = ref(false)
|
|
||||||
const customerOrdersError = ref<ApiErrorDetails | null>(null)
|
|
||||||
let skipNextOrderPageLoad = false
|
|
||||||
let detailRequestSerial = 0
|
let detailRequestSerial = 0
|
||||||
let orderRequestSerial = 0
|
|
||||||
let disposed = false
|
let disposed = false
|
||||||
const previews = ref<Record<string, PreviewState>>({})
|
const previews = ref<Record<string, PreviewState>>({})
|
||||||
const previewOpen = ref(false)
|
const previewOpen = ref(false)
|
||||||
@@ -72,24 +56,11 @@ const tabList = ref<HTMLElement | null>(null)
|
|||||||
|
|
||||||
const tabs: Array<{ key: DetailTab; label: string; icon: string }> = [
|
const tabs: Array<{ key: DetailTab; label: string; icon: string }> = [
|
||||||
{ key: 'basic', label: '基本信息', icon: '📋' },
|
{ key: 'basic', label: '基本信息', icon: '📋' },
|
||||||
{ key: 'orders', label: '运单记录', icon: '📦' },
|
|
||||||
{ key: 'statements', label: '财务账单', icon: '🧾' },
|
|
||||||
{ key: 'billing', label: '计费规则', icon: '⚖️' },
|
{ key: 'billing', label: '计费规则', icon: '⚖️' },
|
||||||
{ key: 'logs', label: '操作日志', icon: '📝' },
|
{ key: 'logs', label: '操作日志', icon: '📝' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const enterpriseMembers = computed(() => members.value.filter((member) => ['OWNER', 'EMPLOYEE'].includes(member.memberType)))
|
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 displayText(value?: string | null) { return value?.trim() || '—' }
|
||||||
function formatDate(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
|
function formatDate(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
|
||||||
function formatCapital(value: number | string | null, currency: string | null) {
|
function formatCapital(value: number | string | null, currency: string | null) {
|
||||||
@@ -120,36 +91,6 @@ function qualificationName(item: PlatformTenantQualification) {
|
|||||||
try { return qualificationFileName(item.documentUrl) }
|
try { return qualificationFileName(item.documentUrl) }
|
||||||
catch { return `${qualificationLabel(item.qualificationType)}文件` }
|
catch { return `${qualificationLabel(item.qualificationType)}文件` }
|
||||||
}
|
}
|
||||||
function orderStatusLabel(status: PlatformFirstLegOrderStatus) {
|
|
||||||
return ({ DRAFT: '草稿', SUBMITTED: '待审核', APPROVED: '已审核', ACCEPTED: '已受理', PROCESSING: '处理中', COMPLETED: '已完成', CANCELLED: '已取消', REJECTED: '已驳回(旧契约)' } as Record<PlatformFirstLegOrderStatus, string>)[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() {
|
function clearPreviews() {
|
||||||
for (const preview of Object.values(previews.value)) if (preview.url) URL.revokeObjectURL(preview.url)
|
for (const preview of Object.values(previews.value)) if (preview.url) URL.revokeObjectURL(preview.url)
|
||||||
previews.value = {}
|
previews.value = {}
|
||||||
@@ -195,9 +136,6 @@ async function loadDetail() {
|
|||||||
customer.value = null
|
customer.value = null
|
||||||
qualifications.value = []
|
qualifications.value = []
|
||||||
members.value = []
|
members.value = []
|
||||||
customerOrders.value = []
|
|
||||||
customerOrderTotal.value = 0
|
|
||||||
customerOrdersError.value = null
|
|
||||||
try {
|
try {
|
||||||
const [tenantResult, qualificationResult, memberResult] = await Promise.all([
|
const [tenantResult, qualificationResult, memberResult] = await Promise.all([
|
||||||
getPlatformTenant(requestedTenantId),
|
getPlatformTenant(requestedTenantId),
|
||||||
@@ -211,17 +149,6 @@ async function loadDetail() {
|
|||||||
previews.value = Object.fromEntries(qualificationResult.map((item) => [String(item.id), {
|
previews.value = Object.fromEntries(qualificationResult.map((item) => [String(item.id), {
|
||||||
url: '', kind: qualificationName(item).toLowerCase().endsWith('.pdf') ? 'pdf' : 'image', loading: false, failed: false,
|
url: '', kind: qualificationName(item).toLowerCase().endsWith('.pdf') ? 'pdf' : 'image', loading: false, failed: false,
|
||||||
} satisfies PreviewState]))
|
} 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) {
|
} catch (error) {
|
||||||
if (disposed || serial !== detailRequestSerial || requestedTenantId !== tenantId.value) return
|
if (disposed || serial !== detailRequestSerial || requestedTenantId !== tenantId.value) return
|
||||||
customer.value = null
|
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() {
|
async function changeStatus() {
|
||||||
if (!customer.value || statusChanging.value) return
|
if (!customer.value || statusChanging.value) return
|
||||||
const next = customer.value.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'
|
const next = customer.value.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'
|
||||||
@@ -272,35 +195,25 @@ watch(tenantId, (value) => {
|
|||||||
if (value) void loadDetail()
|
if (value) void loadDetail()
|
||||||
else {
|
else {
|
||||||
detailRequestSerial += 1
|
detailRequestSerial += 1
|
||||||
orderRequestSerial += 1
|
|
||||||
loading.value = false
|
loading.value = false
|
||||||
customer.value = null
|
customer.value = null
|
||||||
qualifications.value = []
|
qualifications.value = []
|
||||||
members.value = []
|
members.value = []
|
||||||
customerOrders.value = []
|
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
disposed = true
|
disposed = true
|
||||||
detailRequestSerial += 1
|
detailRequestSerial += 1
|
||||||
orderRequestSerial += 1
|
|
||||||
clearPreviews()
|
clearPreviews()
|
||||||
})
|
})
|
||||||
watch(orderPage, (page, previousPage) => {
|
|
||||||
if (skipNextOrderPageLoad) {
|
|
||||||
skipNextOrderPageLoad = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (page !== previousPage) void loadCustomerOrders(page)
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!--
|
<!--
|
||||||
THESIS: 客户档案是一张可核验的运营工作台,不是被抽屉压缩的字段清单。
|
THESIS: 客户档案是一张可核验的运营工作台,不是被抽屉压缩的字段清单。
|
||||||
OWN-WORLD: 延续后台冷灰底、白色数据面板、紫蓝主色与克制的状态色。
|
OWN-WORLD: 延续后台冷灰底、白色数据面板、紫蓝主色与克制的状态色。
|
||||||
STORY: 运营先确认企业状态,再从六个业务入口查看资料、用户和后续记录。
|
STORY: 运营先确认企业状态,再查看客户资料、计费规则和操作日志。
|
||||||
FIRST VIEWPORT: 客户身份与动作居首,三项真实概况紧随其后,六页签固定形成内容索引。
|
FIRST VIEWPORT: 客户身份与动作居首,三个页签形成稳定内容索引。
|
||||||
FORM: 精确继承原型的详情工作台结构,以现有后台组件体系实现。
|
FORM: 精确继承原型的详情工作台结构,以现有后台组件体系实现。
|
||||||
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
|
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
|
||||||
-->
|
-->
|
||||||
@@ -391,26 +304,6 @@ watch(orderPage, (page, previousPage) => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<section v-else-if="activeTab === 'orders'" class="detail-panel module-panel">
|
|
||||||
<div class="panel-title"><div><h2>运单记录</h2><p>客户关联头程运单,共 {{ customerOrderTotal }} 票</p></div><Ship :size="19" /></div>
|
|
||||||
<template v-if="access.can('platform:order:list')">
|
|
||||||
<el-alert v-if="customerOrdersError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ customerOrdersError.message }}</span><ApiErrorMeta :details="customerOrdersError" /></div></template><el-button link type="danger" @click="loadDetail">重新加载</el-button></el-alert>
|
|
||||||
<DataTable v-else v-model:page="orderPage" v-model:page-size="orderPageSize" :data="customerOrders" :columns="orderColumns" :loading="customerOrdersLoading" :total="customerOrderTotal" :page-sizes="[10]" empty-text="当前客户暂无运单记录" row-key="id">
|
|
||||||
<template #actions="{ row }"><el-button link type="primary" @click="openOrder(row as PlatformFirstLegOrderResource)"><Eye :size="15" />详情</el-button></template>
|
|
||||||
<template #status="{ row }"><StatusBadge :status="orderStatusLabel((row as PlatformFirstLegOrderResource).status)" /></template>
|
|
||||||
<template #orderNo="{ row }"><strong>{{ (row as PlatformFirstLegOrderResource).orderNo || '—' }}</strong></template>
|
|
||||||
<template #transport="{ row }"><span>{{ transportLabel(row as PlatformFirstLegOrderResource) }}</span></template>
|
|
||||||
<template #destination="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).destinationWarehouseName || '—' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).destinationCountryCode || '—' }}</span></div></template>
|
|
||||||
<template #receivingWarehouse="{ row }"><span>{{ (row as PlatformFirstLegOrderResource).receivingWarehouseName || '—' }}</span></template>
|
|
||||||
<template #expectedShipDate="{ row }"><span>{{ formattedDate((row as PlatformFirstLegOrderResource).expectedShipDate).slice(0, 10) }}</span></template>
|
|
||||||
<template #createdAt="{ row }"><span>{{ formattedDate((row as PlatformFirstLegOrderResource).createdAt) }}</span></template>
|
|
||||||
<template #empty><el-empty description="当前客户暂无运单记录" /></template>
|
|
||||||
</DataTable>
|
|
||||||
</template>
|
|
||||||
<div v-else class="module-empty"><Ship :size="30" /><strong>暂无运单查看权限</strong><p>请联系管理员申请头程订单查看权限。</p></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section v-else-if="activeTab === 'statements'" class="detail-panel module-panel"><CustomerStatementsPanel :customer-id="tenantId" :customer-name="customer.tenantName" /></section>
|
|
||||||
<section v-else-if="activeTab === 'logs'" class="detail-panel module-panel"><CustomerAuditLogsPanel :customer-id="tenantId" :customer-name="customer.tenantName" /></section>
|
<section v-else-if="activeTab === 'logs'" class="detail-panel module-panel"><CustomerAuditLogsPanel :customer-id="tenantId" :customer-name="customer.tenantName" /></section>
|
||||||
<section v-else class="detail-panel module-panel"><CustomerBillingRulesPanel :customer-id="tenantId" :customer-name="customer.tenantName" :customer-code="customer.tenantCode" :dimension-initialization-status="customer.dimensionInitializationStatus" /><CustomerOverseasStorageFeePanel :customer-id="tenantId" :customer-name="customer.tenantName" /></section>
|
<section v-else class="detail-panel module-panel"><CustomerBillingRulesPanel :customer-id="tenantId" :customer-name="customer.tenantName" :customer-code="customer.tenantCode" :dimension-initialization-status="customer.dimensionInitializationStatus" /><CustomerOverseasStorageFeePanel :customer-id="tenantId" :customer-name="customer.tenantName" /></section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ describe('CustomersView', () => {
|
|||||||
|
|
||||||
await wrapper.get('input[placeholder="请输入工商登记公司名称"]').setValue(fullTenant.tenantName)
|
await wrapper.get('input[placeholder="请输入工商登记公司名称"]').setValue(fullTenant.tenantName)
|
||||||
await wrapper.get('input[placeholder="请输入营业执照上的企业名称"]').setValue(fullTenant.legalName)
|
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.contactName)
|
||||||
await wrapper.get('input[placeholder="请输入公司联系电话"]').setValue(fullTenant.contactPhone)
|
await wrapper.get('input[placeholder="请输入公司联系电话"]').setValue(fullTenant.contactPhone)
|
||||||
await wrapper.get('input[placeholder="请输入18位大写字母或数字"]').setValue(fullTenant.unifiedSocialCreditCode)
|
await wrapper.get('input[placeholder="请输入18位大写字母或数字"]').setValue(fullTenant.unifiedSocialCreditCode)
|
||||||
@@ -277,6 +277,7 @@ describe('CustomersView', () => {
|
|||||||
expect(createPlatformTenant).toHaveBeenCalledWith(expect.objectContaining({
|
expect(createPlatformTenant).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
tenantName: fullTenant.tenantName,
|
tenantName: fullTenant.tenantName,
|
||||||
contactPhone: fullTenant.contactPhone,
|
contactPhone: fullTenant.contactPhone,
|
||||||
|
legalRepresentative: '',
|
||||||
}))
|
}))
|
||||||
expect(createPlatformTenant).toHaveBeenCalledWith(expect.not.objectContaining({ unifiedSocialCreditCode: expect.anything() }))
|
expect(createPlatformTenant).toHaveBeenCalledWith(expect.not.objectContaining({ unifiedSocialCreditCode: expect.anything() }))
|
||||||
expect(uploadPlatformTenantRequiredQualificationPackage).toHaveBeenCalledWith(
|
expect(uploadPlatformTenantRequiredQualificationPackage).toHaveBeenCalledWith(
|
||||||
@@ -710,6 +711,9 @@ describe('CustomersView', () => {
|
|||||||
auth.permissions = [...auth.permissions, 'platform:customer:delete']
|
auth.permissions = [...auth.permissions, 'platform:customer:delete']
|
||||||
useAccessPreviewStore().initializeAfterLogin()
|
useAccessPreviewStore().initializeAfterLogin()
|
||||||
vi.mocked(archivePlatformTenant).mockResolvedValue({ ...fullTenant, status: 'ARCHIVED' })
|
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] } })
|
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
@@ -730,6 +734,7 @@ describe('CustomersView', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(archivePlatformTenant).toHaveBeenCalledWith(fullTenant.id)
|
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 () => {
|
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('代登录原因')
|
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 () => {
|
it('renders one overflow menu per customer when permitted actions exceed three', async () => {
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
auth.permissions = [...auth.permissions, 'platform:customer:reset-password', 'platform:customer:impersonate', 'platform:customer:status', 'platform:customer:delete']
|
auth.permissions = [...auth.permissions, 'platform:customer:reset-password', 'platform:customer:impersonate', 'platform:customer:status', 'platform:customer:delete']
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ const queryFields: QueryField[] = [
|
|||||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [
|
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [
|
||||||
{ label: '待完善', value: 'PROFILE_REQUIRED' }, { label: '待审核', value: 'PENDING' },
|
{ label: '待完善', value: 'PROFILE_REQUIRED' }, { label: '待审核', value: 'PENDING' },
|
||||||
{ label: '启用', value: 'ACTIVE' }, { label: '已驳回', value: 'REJECTED' },
|
{ 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 = {
|
const rules: FormRules = {
|
||||||
tenantName: [{ required: true, message: '请输入公司名称', trigger: 'blur' }],
|
tenantName: [{ required: true, message: '请输入公司名称', trigger: 'blur' }],
|
||||||
legalName: [{ 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: [
|
unifiedSocialCreditCode: [
|
||||||
{ required: true, message: '请输入统一社会信用代码', trigger: 'blur' },
|
{ required: true, message: '请输入统一社会信用代码', trigger: 'blur' },
|
||||||
{ pattern: /^[0-9A-Z]{18}$/, message: '请输入 18 位大写字母或数字', trigger: 'blur' },
|
{ pattern: /^[0-9A-Z]{18}$/, message: '请输入 18 位大写字母或数字', trigger: 'blur' },
|
||||||
@@ -1074,7 +1074,7 @@ onMounted(() => { void loadCustomers() })
|
|||||||
<FormSection title="基础资料" description="维护工商登记和企业主体信息。"><div class="form-grid two-cols">
|
<FormSection title="基础资料" description="维护工商登记和企业主体信息。"><div class="form-grid two-cols">
|
||||||
<el-form-item label="公司名称" prop="tenantName" required><el-input v-model="form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></el-form-item>
|
<el-form-item label="公司名称" prop="tenantName" required><el-input v-model="form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></el-form-item>
|
||||||
<el-form-item label="营业执照企业名称" prop="legalName" required><el-input v-model="form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></el-form-item>
|
<el-form-item label="营业执照企业名称" prop="legalName" required><el-input v-model="form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></el-form-item>
|
||||||
<el-form-item label="法定代表人" prop="legalRepresentative" required><el-input v-model="form.legalRepresentative" placeholder="请输入法定代表人姓名" maxlength="64" /></el-form-item>
|
<el-form-item label="法定代表人" prop="legalRepresentative"><el-input v-model="form.legalRepresentative" placeholder="请输入法定代表人姓名(选填)" maxlength="64" /></el-form-item>
|
||||||
<el-form-item label="企业类型" prop="enterpriseType"><el-input v-model="form.enterpriseType" placeholder="如:有限责任公司" maxlength="64" /></el-form-item>
|
<el-form-item label="企业类型" prop="enterpriseType"><el-input v-model="form.enterpriseType" placeholder="如:有限责任公司" maxlength="64" /></el-form-item>
|
||||||
<el-form-item label="成立日期" prop="establishedDate"><el-date-picker style="width: 100%;" v-model="form.establishedDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择成立日期" :disabled-date="(date: Date) => date.getTime() > Date.now()" /></el-form-item>
|
<el-form-item label="成立日期" prop="establishedDate"><el-date-picker style="width: 100%;" v-model="form.establishedDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择成立日期" :disabled-date="(date: Date) => date.getTime() > Date.now()" /></el-form-item>
|
||||||
<el-form-item label="注册资本" prop="registeredCapital"><div class="capital-input"><el-input-number v-model="form.registeredCapital" :min="0" :precision="2" controls-position="right" placeholder="请输入注册资本(选填)" /><el-select v-model="form.registeredCapitalCurrency" aria-label="注册资本币种"><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /><el-option label="港币 HKD" value="HKD" /></el-select></div></el-form-item>
|
<el-form-item label="注册资本" prop="registeredCapital"><div class="capital-input"><el-input-number v-model="form.registeredCapital" :min="0" :precision="2" controls-position="right" placeholder="请输入注册资本(选填)" /><el-select v-model="form.registeredCapitalCurrency" aria-label="注册资本币种"><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /><el-option label="港币 HKD" value="HKD" /></el-select></div></el-form-item>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export interface PlatformTenantResource {
|
|||||||
export interface CreatePlatformTenantRequest {
|
export interface CreatePlatformTenantRequest {
|
||||||
tenantName: string
|
tenantName: string
|
||||||
legalName: string
|
legalName: string
|
||||||
legalRepresentative: string
|
legalRepresentative?: string
|
||||||
contactName: string
|
contactName: string
|
||||||
contactPhone: string
|
contactPhone: string
|
||||||
contactEmail: string
|
contactEmail: string
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ describe('InboundMeasurementDialog', () => {
|
|||||||
expect(source).not.toContain('class="surface-panel measurement-dialog-weight"')
|
expect(source).not.toContain('class="surface-panel measurement-dialog-weight"')
|
||||||
expect(source).toContain('border-bottom: 1px solid var(--cc-border)')
|
expect(source).toContain('border-bottom: 1px solid var(--cc-border)')
|
||||||
expect(source).toContain('@summary-changed="handleWeightSummaryChanged"')
|
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', () => {
|
it('offers the shared fullscreen control without forcing fullscreen on open', () => {
|
||||||
|
|||||||
@@ -45,8 +45,12 @@ async function beforeClose(done: () => void) {
|
|||||||
if (await mayClose()) done()
|
if (await mayClose()) done()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleWeightSummaryChanged(actualGrossWeightKg: number | null, actualNetWeightKg: number | null) {
|
function handleWeightSummaryChanged(
|
||||||
measurementPanelRef.value?.updateActualWeights(actualGrossWeightKg, actualNetWeightKg)
|
actualGrossWeightKg: number | null,
|
||||||
|
actualNetWeightKg: number | null,
|
||||||
|
hasConfirmedWeight: boolean,
|
||||||
|
) {
|
||||||
|
measurementPanelRef.value?.updateWeightEvidence(actualGrossWeightKg, actualNetWeightKg, hasConfirmedWeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleUpdated() {
|
function handleUpdated() {
|
||||||
@@ -95,6 +99,9 @@ function handleSubmitted() {
|
|||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="measurement-dialog-footer">
|
<div class="measurement-dialog-footer">
|
||||||
|
<span v-if="measurementPanelRef?.submissionBlockReason" class="measurement-dialog-footer__hint">
|
||||||
|
{{ measurementPanelRef.submissionBlockReason }}
|
||||||
|
</span>
|
||||||
<el-button :disabled="Boolean(measurementPanelRef?.saving || measurementPanelRef?.submitting)" @click="requestClose">取消</el-button>
|
<el-button :disabled="Boolean(measurementPanelRef?.saving || measurementPanelRef?.submitting)" @click="requestClose">取消</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="measurementPanelRef?.canEdit"
|
v-if="measurementPanelRef?.canEdit"
|
||||||
@@ -119,7 +126,8 @@ function handleSubmitted() {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.measurement-dialog-workspace { display: grid; min-width: 0; }
|
.measurement-dialog-workspace { display: grid; min-width: 0; }
|
||||||
.measurement-dialog-weight { min-width: 0; padding: 0 0 16px; border-bottom: 1px solid var(--cc-border); }
|
.measurement-dialog-weight { min-width: 0; padding: 0 0 16px; border-bottom: 1px solid var(--cc-border); }
|
||||||
.measurement-dialog-footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
|
.measurement-dialog-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 10px; }
|
||||||
|
.measurement-dialog-footer__hint { margin-right: auto; color: var(--el-color-warning-dark-2); font-size: 12px; }
|
||||||
.measurement-dialog-footer :deep(.el-button) { margin-left: 0; }
|
.measurement-dialog-footer :deep(.el-button) { margin-left: 0; }
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.measurement-dialog-weight { padding-bottom: 14px; }
|
.measurement-dialog-weight { padding-bottom: 14px; }
|
||||||
|
|||||||
@@ -112,6 +112,10 @@ function measurement(overrides: Partial<InboundMeasurementDetail> = {}): Inbound
|
|||||||
outerLengthCm: null,
|
outerLengthCm: null,
|
||||||
outerWidthCm: null,
|
outerWidthCm: null,
|
||||||
outerHeightCm: null,
|
outerHeightCm: null,
|
||||||
|
calculatedOuterLengthCm: 50,
|
||||||
|
calculatedOuterWidthCm: 40,
|
||||||
|
calculatedOuterHeightCm: 30,
|
||||||
|
calculationRuleVersion: 'DIMENSION_ACTUAL_V1',
|
||||||
remark: '设备自动回传',
|
remark: '设备自动回传',
|
||||||
}],
|
}],
|
||||||
weightResults: [{
|
weightResults: [{
|
||||||
@@ -278,7 +282,7 @@ describe('InboundMeasurementPanel', () => {
|
|||||||
expect(source).toContain('.measurement-data-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr));')
|
expect(source).toContain('.measurement-data-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr));')
|
||||||
expect(source).toContain('.measurement-table { width: 100%; font-size: 12px; }')
|
expect(source).toContain('.measurement-table { width: 100%; font-size: 12px; }')
|
||||||
expect(source).toContain('.measurement-data-grid :deep(.el-input__wrapper)')
|
expect(source).toContain('.measurement-data-grid :deep(.el-input__wrapper)')
|
||||||
expect(source).toContain('defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, maySubmit, saving, submitting, canEdit, canSubmit, isDirty })')
|
expect(source).toContain('defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlockReason, mayMaintain, maySubmit, saving, submitting, canEdit, canSubmit, isDirty })')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders customer declaration facts as plain read-only values', async () => {
|
it('renders customer declaration facts as plain read-only values', async () => {
|
||||||
@@ -296,6 +300,8 @@ describe('InboundMeasurementPanel', () => {
|
|||||||
expect(declaration.findAll('.measurement-readonly-field')).toHaveLength(4)
|
expect(declaration.findAll('.measurement-readonly-field')).toHaveLength(4)
|
||||||
expect(declaration.findAll('input')).toHaveLength(1)
|
expect(declaration.findAll('input')).toHaveLength(1)
|
||||||
expect(declaration.find('.measurement-goods-name input').exists()).toBe(true)
|
expect(declaration.find('.measurement-goods-name input').exists()).toBe(true)
|
||||||
|
expect(declaration.find('[aria-label="客户申报箱规明细"]').text()).toContain('蓝牙音箱')
|
||||||
|
expect(declaration.find('[aria-label="客户申报箱规明细"]').text()).toContain('50 × 40 × 30')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not render differences when the corresponding declaration is unavailable', async () => {
|
it('does not render differences when the corresponding declaration is unavailable', async () => {
|
||||||
@@ -352,7 +358,7 @@ describe('InboundMeasurementPanel', () => {
|
|||||||
const countInput = wrapper.find('.measurement-count-editor input')
|
const countInput = wrapper.find('.measurement-count-editor input')
|
||||||
await countInput.setValue('9')
|
await countInput.setValue('9')
|
||||||
|
|
||||||
wrapper.vm.updateActualWeights(125.75, 115.75)
|
wrapper.vm.updateWeightEvidence(125.75, 115.75, true)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.find('.measurement-metrics').text()).toContain('125.75')
|
expect(wrapper.find('.measurement-metrics').text()).toContain('125.75')
|
||||||
@@ -360,6 +366,22 @@ describe('InboundMeasurementPanel', () => {
|
|||||||
expect((countInput.element as HTMLInputElement).value).toBe('9')
|
expect((countInput.element as HTMLInputElement).value).toBe('9')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows the complete server-calculated measurement facts in the expanded row', async () => {
|
||||||
|
const wrapper = await mountPanel()
|
||||||
|
await wrapper.get('.el-table__expand-icon').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const text = wrapper.text()
|
||||||
|
expect(text).toContain('箱规')
|
||||||
|
expect(text).toContain('50×40×30cm')
|
||||||
|
expect(text).toContain('计算方式')
|
||||||
|
expect(text).toContain('实测原尺寸')
|
||||||
|
expect(text).toContain('计算后外箱')
|
||||||
|
expect(text).toContain('50 × 40 × 30 cm')
|
||||||
|
expect(text).toContain('计算规则版本')
|
||||||
|
expect(text).toContain('DIMENSION_ACTUAL_V1')
|
||||||
|
})
|
||||||
|
|
||||||
it('reuses the established first-leg detail surface and status patterns', async () => {
|
it('reuses the established first-leg detail surface and status patterns', async () => {
|
||||||
const wrapper = await mountPanel()
|
const wrapper = await mountPanel()
|
||||||
|
|
||||||
@@ -486,15 +508,32 @@ describe('InboundMeasurementPanel', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('requires at least one confirmed weight image before submitting for review', async () => {
|
it('requires at least one confirmed weight image before submitting for review', async () => {
|
||||||
|
const messageSpy = vi.spyOn(ElMessage, 'warning')
|
||||||
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ weightResults: [] }))
|
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ weightResults: [] }))
|
||||||
const wrapper = await mountPanel()
|
const wrapper = await mountPanel()
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('至少上传并确认一张称重图片后才能提交审核')
|
expect(wrapper.text()).toContain('即使只有一箱,也必须上传并确认至少一张称重图片后才能提交审核')
|
||||||
const submit = wrapper.findAll('button').find((button) => button.text().includes('提交审核'))
|
const submit = wrapper.findAll('button').find((button) => button.text().includes('提交审核'))
|
||||||
expect(submit?.attributes('disabled')).toBeDefined()
|
expect(submit?.attributes('disabled')).toBeDefined()
|
||||||
|
await wrapper.vm.submitForReview()
|
||||||
|
expect(messageSpy).toHaveBeenCalledWith('即使只有一箱,也必须上传并确认至少一张称重图片后才能提交审核')
|
||||||
expect(submitInboundMeasurement).not.toHaveBeenCalled()
|
expect(submitInboundMeasurement).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('updates submission eligibility when the weight panel confirms or removes the last image', async () => {
|
||||||
|
const wrapper = await mountPanel()
|
||||||
|
expect(wrapper.vm.maySubmit).toBe(true)
|
||||||
|
|
||||||
|
wrapper.vm.updateWeightEvidence(null, null, false)
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.vm.maySubmit).toBe(false)
|
||||||
|
expect(wrapper.vm.submissionBlockReason).toContain('必须上传并确认')
|
||||||
|
|
||||||
|
wrapper.vm.updateWeightEvidence(125.75, 115.75, true)
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.vm.maySubmit).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
it('emits updated without closing the parent task after manual carton entry', async () => {
|
it('emits updated without closing the parent task after manual carton entry', async () => {
|
||||||
const wrapper = await mountPanel()
|
const wrapper = await mountPanel()
|
||||||
const source = await import('./InboundMeasurementPanel.vue?raw').then((module) => module.default)
|
const source = await import('./InboundMeasurementPanel.vue?raw').then((module) => module.default)
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ type EditableMeasurementItem = Omit<SaveInboundMeasurementItemRequest, 'coeffici
|
|||||||
serverValues: Pick<InboundMeasurementItem, 'girthCm' | 'unitVolumeCbm' | 'totalWeightKg' | 'totalVolumeCbm' | 'volumeWeightKg'>
|
serverValues: Pick<InboundMeasurementItem, 'girthCm' | 'unitVolumeCbm' | 'totalWeightKg' | 'totalVolumeCbm' | 'volumeWeightKg'>
|
||||||
chargeRuleId: string | null
|
chargeRuleId: string | null
|
||||||
coefficientMode: 'ACTUAL' | 'ADD' | 'MULTIPLY'
|
coefficientMode: 'ACTUAL' | 'ADD' | 'MULTIPLY'
|
||||||
|
calculatedOuterLengthCm: number | null
|
||||||
|
calculatedOuterWidthCm: number | null
|
||||||
|
calculatedOuterHeightCm: number | null
|
||||||
|
calculationRuleVersion: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MeasurementFormModel {
|
interface MeasurementFormModel {
|
||||||
@@ -77,6 +81,7 @@ const imagePreviewUrl = ref('')
|
|||||||
const imagePreviewError = ref<ApiErrorDetails | null>(null)
|
const imagePreviewError = ref<ApiErrorDetails | null>(null)
|
||||||
const validationErrors = ref<Record<string, string[]>>({})
|
const validationErrors = ref<Record<string, string[]>>({})
|
||||||
const baseline = ref('')
|
const baseline = ref('')
|
||||||
|
const confirmedWeightAvailable = ref(false)
|
||||||
let requestSequence = 0
|
let requestSequence = 0
|
||||||
let itemSequence = 0
|
let itemSequence = 0
|
||||||
|
|
||||||
@@ -114,10 +119,18 @@ const confirmedPackageCount = computed(() => form.items.reduce((sum, item) => su
|
|||||||
const expectedPackageCount = computed(() => detail.value?.originalPackageCount ?? null)
|
const expectedPackageCount = computed(() => detail.value?.originalPackageCount ?? null)
|
||||||
const packageCountDifference = computed(() => expectedPackageCount.value == null ? null : confirmedPackageCount.value - expectedPackageCount.value)
|
const packageCountDifference = computed(() => expectedPackageCount.value == null ? null : confirmedPackageCount.value - expectedPackageCount.value)
|
||||||
const packageCountMatched = computed(() => packageCountDifference.value === null || packageCountDifference.value === 0)
|
const packageCountMatched = computed(() => packageCountDifference.value === null || packageCountDifference.value === 0)
|
||||||
const hasConfirmedWeightResult = computed(() => detail.value?.weightResults.some((item) => item.recognitionStatus === 'CONFIRMED') === true)
|
const hasConfirmedWeightResult = computed(() => confirmedWeightAvailable.value)
|
||||||
const isDropship = computed(() => detail.value?.fulfillmentType === 'ONE_CLICK_DROPSHIP')
|
const isDropship = computed(() => detail.value?.fulfillmentType === 'ONE_CLICK_DROPSHIP')
|
||||||
const expectedPalletCountValid = computed(() => !isDropship.value || (Number.isInteger(form.expectedPalletCount) && Number(form.expectedPalletCount) >= 1))
|
const expectedPalletCountValid = computed(() => !isDropship.value || (Number.isInteger(form.expectedPalletCount) && Number(form.expectedPalletCount) >= 1))
|
||||||
const maySubmit = computed(() => canSubmit.value && submitEditableProcess.value && Boolean(form.measurementId) && packageCountMatched.value && hasConfirmedWeightResult.value && expectedPalletCountValid.value && !loading.value && !saving.value)
|
const maySubmit = computed(() => canSubmit.value && submitEditableProcess.value && Boolean(form.measurementId) && packageCountMatched.value && hasConfirmedWeightResult.value && expectedPalletCountValid.value && !loading.value && !saving.value)
|
||||||
|
const submissionBlockReason = computed(() => {
|
||||||
|
if (!canSubmit.value || !submitEditableProcess.value || !form.measurementId) return ''
|
||||||
|
if (!hasConfirmedWeightResult.value) return '即使只有一箱,也必须上传并确认至少一张称重图片后才能提交审核'
|
||||||
|
if (!packageCountMatched.value) return '确认箱数合计必须与订单总箱数一致后才能提交审核'
|
||||||
|
if (!expectedPalletCountValid.value) return '请先填写并保存有效的预计托数后再提交审核'
|
||||||
|
if (isDirty.value) return '请先保存当前修改,再提交审核'
|
||||||
|
return ''
|
||||||
|
})
|
||||||
const snapshot = computed(() => JSON.stringify(form))
|
const snapshot = computed(() => JSON.stringify(form))
|
||||||
const isDirty = computed(() => Boolean(detail.value) && snapshot.value !== baseline.value)
|
const isDirty = computed(() => Boolean(detail.value) && snapshot.value !== baseline.value)
|
||||||
const registrationOpen = computed(() => hasContext.value && canView.value && Boolean(detail.value))
|
const registrationOpen = computed(() => hasContext.value && canView.value && Boolean(detail.value))
|
||||||
@@ -302,10 +315,15 @@ function editableItem(item: InboundMeasurementItem): EditableMeasurementItem {
|
|||||||
},
|
},
|
||||||
chargeRuleId: item.chargeRuleId == null ? null : String(item.chargeRuleId),
|
chargeRuleId: item.chargeRuleId == null ? null : String(item.chargeRuleId),
|
||||||
coefficientMode: item.coefficientMode === 'MULTIPLY' ? 'MULTIPLY' : item.coefficientMode === 'ADD' ? 'ADD' : 'ACTUAL',
|
coefficientMode: item.coefficientMode === 'MULTIPLY' ? 'MULTIPLY' : item.coefficientMode === 'ADD' ? 'ADD' : 'ACTUAL',
|
||||||
|
calculatedOuterLengthCm: item.calculatedOuterLengthCm ?? null,
|
||||||
|
calculatedOuterWidthCm: item.calculatedOuterWidthCm ?? null,
|
||||||
|
calculatedOuterHeightCm: item.calculatedOuterHeightCm ?? null,
|
||||||
|
calculationRuleVersion: item.calculationRuleVersion ?? null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearForm() {
|
function clearForm() {
|
||||||
|
confirmedWeightAvailable.value = false
|
||||||
form.measurementId = null
|
form.measurementId = null
|
||||||
form.version = 0
|
form.version = 0
|
||||||
form.actualGoodsName = ''
|
form.actualGoodsName = ''
|
||||||
@@ -317,6 +335,7 @@ function clearForm() {
|
|||||||
|
|
||||||
function applyDetail(resource: InboundMeasurementDetail) {
|
function applyDetail(resource: InboundMeasurementDetail) {
|
||||||
detail.value = resource
|
detail.value = resource
|
||||||
|
confirmedWeightAvailable.value = resource.weightResults.some((item) => item.recognitionStatus === 'CONFIRMED')
|
||||||
form.measurementId = resource.measurementId == null ? null : String(resource.measurementId)
|
form.measurementId = resource.measurementId == null ? null : String(resource.measurementId)
|
||||||
form.version = resource.version ?? 0
|
form.version = resource.version ?? 0
|
||||||
form.actualGoodsName = resource.actualGoodsName || ''
|
form.actualGoodsName = resource.actualGoodsName || ''
|
||||||
@@ -486,6 +505,10 @@ async function saveDraft() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitForReview() {
|
async function submitForReview() {
|
||||||
|
if (!hasConfirmedWeightResult.value) {
|
||||||
|
ElMessage.warning('即使只有一箱,也必须上传并确认至少一张称重图片后才能提交审核')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!maySubmit.value || !form.measurementId) return
|
if (!maySubmit.value || !form.measurementId) return
|
||||||
if (!validateDraft()) return
|
if (!validateDraft()) return
|
||||||
if (isDirty.value) {
|
if (isDirty.value) {
|
||||||
@@ -567,9 +590,18 @@ function processStatusLabel(status: string | undefined) {
|
|||||||
return inboundStatusLabel(status, inboundProcessStatusLabels)
|
return inboundStatusLabel(status, inboundProcessStatusLabels)
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateActualWeights(actualGrossWeightKg: number | null, actualNetWeightKg: number | null) {
|
function updateWeightEvidence(
|
||||||
|
actualGrossWeightKg: number | null,
|
||||||
|
actualNetWeightKg: number | null,
|
||||||
|
hasConfirmedWeight: boolean,
|
||||||
|
) {
|
||||||
if (!detail.value) return
|
if (!detail.value) return
|
||||||
detail.value = { ...detail.value, actualGrossWeightKg, actualNetWeightKg }
|
detail.value = { ...detail.value, actualGrossWeightKg, actualNetWeightKg }
|
||||||
|
confirmedWeightAvailable.value = hasConfirmedWeight
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculationModeLabel(value: EditableMeasurementItem['coefficientMode']) {
|
||||||
|
return value === 'ADD' ? '箱规尺寸' : value === 'MULTIPLY' ? '箱规系数' : '实测原尺寸'
|
||||||
}
|
}
|
||||||
|
|
||||||
function measurementRowClassName({ row }: { row: EditableMeasurementItem }) {
|
function measurementRowClassName({ row }: { row: EditableMeasurementItem }) {
|
||||||
@@ -581,6 +613,7 @@ watch(
|
|||||||
() => {
|
() => {
|
||||||
requestSequence += 1
|
requestSequence += 1
|
||||||
detail.value = null
|
detail.value = null
|
||||||
|
confirmedWeightAvailable.value = false
|
||||||
loadError.value = null
|
loadError.value = null
|
||||||
operationError.value = null
|
operationError.value = null
|
||||||
conflictDetected.value = false
|
conflictDetected.value = false
|
||||||
@@ -593,7 +626,7 @@ watch(
|
|||||||
|
|
||||||
onBeforeUnmount(releaseImagePreview)
|
onBeforeUnmount(releaseImagePreview)
|
||||||
|
|
||||||
defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, maySubmit, saving, submitting, canEdit, canSubmit, isDirty })
|
defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlockReason, mayMaintain, maySubmit, saving, submitting, canEdit, canSubmit, isDirty })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -639,6 +672,15 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
|
|||||||
|
|
||||||
<el-alert v-else-if="!canEdit" type="info" :closable="false" show-icon title="当前账号仅可查看测量数据" />
|
<el-alert v-else-if="!canEdit" type="info" :closable="false" show-icon title="当前账号仅可查看测量数据" />
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
v-if="submissionBlockReason && !hasConfirmedWeightResult"
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
class="measurement-alert"
|
||||||
|
:title="submissionBlockReason"
|
||||||
|
/>
|
||||||
|
|
||||||
<el-alert v-if="conflictDetected" type="warning" :closable="false" show-icon class="measurement-alert">
|
<el-alert v-if="conflictDetected" type="warning" :closable="false" show-icon class="measurement-alert">
|
||||||
<template #title>数据版本已变化,当前修改尚未丢失</template>
|
<template #title>数据版本已变化,当前修改尚未丢失</template>
|
||||||
<p>{{ operationError?.message }}</p>
|
<p>{{ operationError?.message }}</p>
|
||||||
@@ -699,6 +741,22 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
|
|||||||
<div v-else-if="isDropship" class="measurement-readonly-field"><span>预计托数</span><strong>{{ detail.expectedPalletCount ?? '—' }}</strong></div>
|
<div v-else-if="isDropship" class="measurement-readonly-field"><span>预计托数</span><strong>{{ detail.expectedPalletCount ?? '—' }}</strong></div>
|
||||||
<div class="measurement-readonly-field"><span>原单据号码</span><strong>{{ detail.originalDocumentNo || '—' }}</strong></div>
|
<div class="measurement-readonly-field"><span>原单据号码</span><strong>{{ detail.originalDocumentNo || '—' }}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
|
<el-table
|
||||||
|
v-if="detail.latestDeclaration?.items.length"
|
||||||
|
:data="detail.latestDeclaration.items"
|
||||||
|
border
|
||||||
|
stripe
|
||||||
|
size="small"
|
||||||
|
class="measurement-declaration-table"
|
||||||
|
aria-label="客户申报箱规明细"
|
||||||
|
>
|
||||||
|
<el-table-column prop="itemNo" label="#" width="70" />
|
||||||
|
<el-table-column prop="cargoName" label="货物品名" min-width="150" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="packageCount" label="申报箱数" width="100" />
|
||||||
|
<el-table-column label="申报尺寸 长×宽×高(cm)" min-width="190">
|
||||||
|
<template #default="{ row }">{{ formatNumber(row.lengthCm) }} × {{ formatNumber(row.widthCm) }} × {{ formatNumber(row.heightCm) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -722,11 +780,16 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
|
|||||||
<el-table-column type="expand" width="42">
|
<el-table-column type="expand" width="42">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<dl class="measurement-row-details">
|
<dl class="measurement-row-details">
|
||||||
|
<div><dt>箱规</dt><dd>{{ row.boxSpecification || '—' }}</dd></div>
|
||||||
<div><dt>机器编号</dt><dd>{{ row.machineNo || '人工录入' }}</dd></div>
|
<div><dt>机器编号</dt><dd>{{ row.machineNo || '人工录入' }}</dd></div>
|
||||||
<div><dt>测量时间</dt><dd>{{ row.measuredAt ? row.measuredAt.replace('T', ' ') : '—' }}</dd></div>
|
<div><dt>测量时间</dt><dd>{{ row.measuredAt ? row.measuredAt.replace('T', ' ') : '—' }}</dd></div>
|
||||||
|
<div><dt>计算方式</dt><dd>{{ calculationModeLabel(row.coefficientMode) }}</dd></div>
|
||||||
|
<div><dt>计算后外箱</dt><dd>{{ formatNumber(row.calculatedOuterLengthCm) }} × {{ formatNumber(row.calculatedOuterWidthCm) }} × {{ formatNumber(row.calculatedOuterHeightCm) }} cm</dd></div>
|
||||||
<div><dt>单箱体积</dt><dd>{{ formatNumber(row.serverValues.unitVolumeCbm, 4) }} m³</dd></div>
|
<div><dt>单箱体积</dt><dd>{{ formatNumber(row.serverValues.unitVolumeCbm, 4) }} m³</dd></div>
|
||||||
<div><dt>箱规周长</dt><dd>{{ formatNumber(row.serverValues.girthCm) }} cm</dd></div>
|
<div><dt>箱规周长</dt><dd>{{ formatNumber(row.serverValues.girthCm) }} cm</dd></div>
|
||||||
<div><dt>总材积重</dt><dd>{{ formatNumber(row.serverValues.volumeWeightKg) }} kg</dd></div>
|
<div><dt>总材积重</dt><dd>{{ formatNumber(row.serverValues.volumeWeightKg) }} kg</dd></div>
|
||||||
|
<div><dt>计算规则版本</dt><dd>{{ row.calculationRuleVersion || '—' }}</dd></div>
|
||||||
|
<div><dt>超限提醒</dt><dd>{{ row.overLimitWarning || '—' }}</dd></div>
|
||||||
<div><dt>备注</dt><dd>{{ row.remark || '—' }}</dd></div>
|
<div><dt>备注</dt><dd>{{ row.remark || '—' }}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</template>
|
</template>
|
||||||
@@ -848,6 +911,7 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
|
|||||||
.measurement-panel h3 { font-size: 14px; }
|
.measurement-panel h3 { font-size: 14px; }
|
||||||
.measurement-panel__heading p, .measurement-section__heading p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.55; }
|
.measurement-panel__heading p, .measurement-section__heading p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.55; }
|
||||||
.measurement-panel__actions, .measurement-panel__footer > div { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
.measurement-panel__actions, .measurement-panel__footer > div { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||||
|
.measurement-declaration-table { margin-top: 12px; }
|
||||||
.measurement-panel__actions :deep(.el-button), .measurement-panel__footer :deep(.el-button) { margin-left: 0; }
|
.measurement-panel__actions :deep(.el-button), .measurement-panel__footer :deep(.el-button) { margin-left: 0; }
|
||||||
.measurement-skeleton { padding: 24px; }
|
.measurement-skeleton { padding: 24px; }
|
||||||
.measurement-panel > :deep(.el-alert), .measurement-alert { margin: 16px 20px 0; width: auto; }
|
.measurement-panel > :deep(.el-alert), .measurement-alert { margin: 16px 20px 0; width: auto; }
|
||||||
|
|||||||
@@ -447,7 +447,7 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
|||||||
await dialogConfirmButton().trigger('click')
|
await dialogConfirmButton().trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.emitted('summaryChanged')).toEqual([[1787.5, 1747.5]])
|
expect(wrapper.emitted('summaryChanged')).toEqual([[1787.5, 1747.5, true]])
|
||||||
expect(wrapper.emitted('changed')).toHaveLength(1)
|
expect(wrapper.emitted('changed')).toHaveLength(1)
|
||||||
expect(wrapper.text()).toContain('实测毛重')
|
expect(wrapper.text()).toContain('实测毛重')
|
||||||
expect(wrapper.text()).toContain('货物重量')
|
expect(wrapper.text()).toContain('货物重量')
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ describe('WeightOcrPanel contract', () => {
|
|||||||
it('uses customer language at the page boundary while retaining a compatibility input', () => {
|
it('uses customer language at the page boundary while retaining a compatibility input', () => {
|
||||||
expect(source).toContain('reviewMode?: boolean')
|
expect(source).toContain('reviewMode?: boolean')
|
||||||
expect(source).toContain("const customerId = computed(() => props.customerId || props.tenantId || '')")
|
expect(source).toContain("const customerId = computed(() => props.customerId || props.tenantId || '')")
|
||||||
expect(source).toContain('summaryChanged: [actualGrossWeightKg: number | null, actualNetWeightKg: number | null]')
|
expect(source).toContain('summaryChanged: [actualGrossWeightKg: number | null, actualNetWeightKg: number | null, hasConfirmedWeight: boolean]')
|
||||||
expect(source).toContain("defineExpose({ reload: loadWorkspace })")
|
expect(source).toContain("defineExpose({ reload: loadWorkspace })")
|
||||||
const template = source.match(/<template>([\s\S]*?)<\/template>/)?.[1] || ''
|
const template = source.match(/<template>([\s\S]*?)<\/template>/)?.[1] || ''
|
||||||
expect(template).not.toContain('租户')
|
expect(template).not.toContain('租户')
|
||||||
@@ -46,7 +46,7 @@ describe('WeightOcrPanel contract', () => {
|
|||||||
expect(source).toContain('<div><dt>库位</dt><dd')
|
expect(source).toContain('<div><dt>库位</dt><dd')
|
||||||
expect(source).not.toContain('workspace.palletLocationGuide.slots')
|
expect(source).not.toContain('workspace.palletLocationGuide.slots')
|
||||||
expect(source).toContain('对应库位仍为本票保留,不会释放给其他订单')
|
expect(source).toContain('对应库位仍为本票保留,不会释放给其他订单')
|
||||||
expect(source).toContain("emit('summaryChanged', workspace.value?.actualGrossWeightKg ?? null, workspace.value?.actualNetWeightKg ?? null)")
|
expect(source).toContain("workspace.value?.weightResults.some((item) => item.recognitionStatus === 'CONFIRMED') === true")
|
||||||
})
|
})
|
||||||
|
|
||||||
it('matches backend image limits and keeps technical OCR data out of the list', () => {
|
it('matches backend image limits and keeps technical OCR data out of the list', () => {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ const props = withDefaults(defineProps<{
|
|||||||
})
|
})
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
changed: []
|
changed: []
|
||||||
summaryChanged: [actualGrossWeightKg: number | null, actualNetWeightKg: number | null]
|
summaryChanged: [actualGrossWeightKg: number | null, actualNetWeightKg: number | null, hasConfirmedWeight: boolean]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const access = useAccessPreviewStore()
|
const access = useAccessPreviewStore()
|
||||||
@@ -213,7 +213,12 @@ function buildAdjustmentReason(row: WeightResultResource) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function emitWorkspaceChanged() {
|
function emitWorkspaceChanged() {
|
||||||
emit('summaryChanged', workspace.value?.actualGrossWeightKg ?? null, workspace.value?.actualNetWeightKg ?? null)
|
emit(
|
||||||
|
'summaryChanged',
|
||||||
|
workspace.value?.actualGrossWeightKg ?? null,
|
||||||
|
workspace.value?.actualNetWeightKg ?? null,
|
||||||
|
workspace.value?.weightResults.some((item) => item.recognitionStatus === 'CONFIRMED') === true,
|
||||||
|
)
|
||||||
emit('changed')
|
emit('changed')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO
|
|||||||
transferWarehouse: text(dto.transferWarehouseName),
|
transferWarehouse: text(dto.transferWarehouseName),
|
||||||
fbaWarehouse: text(dto.fbaWarehouseCode),
|
fbaWarehouse: text(dto.fbaWarehouseCode),
|
||||||
containerNo: text(dto.containerNo),
|
containerNo: text(dto.containerNo),
|
||||||
containerStatus: text(dto.containerStatus),
|
containerNode: text(dto.containerNode),
|
||||||
abnormal: yesNo(dto.abnormal),
|
abnormal: yesNo(dto.abnormal),
|
||||||
held: yesNo(dto.detained),
|
held: yesNo(dto.detained),
|
||||||
detained: Boolean(dto.detained),
|
detained: Boolean(dto.detained),
|
||||||
|
|||||||
@@ -39,6 +39,15 @@ const feeForm = reactive({ itemCode: '', itemName: '', direction: 'RECEIVABLE' a
|
|||||||
const feeItemOptions = [{ code: 'STORAGE_FEE', name: '仓租' }, { code: 'INBOUND_OPERATION', name: '入库操作费' }, { code: 'OUTBOUND_OPERATION', name: '出库操作费' }, { code: 'RELABEL_FEE', name: '换标费' }, { code: 'REBOX_FEE', name: '换箱费' }, { code: 'PALLET_FEE', name: '托盘费' }, { code: 'DELIVERY_FEE', name: '派送费' }, { code: 'TRANSFER_FEE', name: '转仓费' }, { code: 'EXCEPTION_FEE', name: '异常处理费' }, { code: 'OTHER', name: '其他' }]
|
const feeItemOptions = [{ code: 'STORAGE_FEE', name: '仓租' }, { code: 'INBOUND_OPERATION', name: '入库操作费' }, { code: 'OUTBOUND_OPERATION', name: '出库操作费' }, { code: 'RELABEL_FEE', name: '换标费' }, { code: 'REBOX_FEE', name: '换箱费' }, { code: 'PALLET_FEE', name: '托盘费' }, { code: 'DELIVERY_FEE', name: '派送费' }, { code: 'TRANSFER_FEE', name: '转仓费' }, { code: 'EXCEPTION_FEE', name: '异常处理费' }, { code: 'OTHER', name: '其他' }]
|
||||||
|
|
||||||
function text(value: unknown) { return String(value ?? '').trim() || '—' }
|
function text(value: unknown) { return String(value ?? '').trim() || '—' }
|
||||||
|
function containerNodeLabel(value: unknown) {
|
||||||
|
const code = String(value ?? '').trim()
|
||||||
|
return ({
|
||||||
|
DEPARTED_WAREHOUSE: '已离开国内仓库', AT_ORIGIN_PORT: '重箱已进入起运港',
|
||||||
|
LOADED_ON_VESSEL: '已装船', DEPARTED_ORIGIN_PORT: '船舶已离开起运港', IN_TRANSIT: '海运途中',
|
||||||
|
ARRIVED_DESTINATION_PORT: '船舶已到目的港', DISCHARGED_FROM_VESSEL: '已卸船、未清关',
|
||||||
|
PICKUP_READY: '已清关、可提柜', PICKED_UP: '已提柜', EMPTY_RETURNED: '已还柜',
|
||||||
|
} as Record<string, string>)[code] || text(code)
|
||||||
|
}
|
||||||
function yesNo(value: boolean | null | undefined) { return value == null ? '—' : value ? '是' : '否' }
|
function yesNo(value: boolean | null | undefined) { return value == null ? '—' : value ? '是' : '否' }
|
||||||
function feeStatusLabel(value: string) { return ({ PENDING_REVIEW: '待审核', APPROVED: '审核通过', REJECTED: '已驳回' } as Record<string, string>)[value] || value }
|
function feeStatusLabel(value: string) { return ({ PENDING_REVIEW: '待审核', APPROVED: '审核通过', REJECTED: '已驳回' } as Record<string, string>)[value] || value }
|
||||||
function isSingleLineBusinessField(field: DetailField) { return field.label === '入仓码' || field.label === '集装箱号' }
|
function isSingleLineBusinessField(field: DetailField) { return field.label === '入仓码' || field.label === '集装箱号' }
|
||||||
@@ -70,7 +79,7 @@ const containerFields = computed<DetailField[]>(() => {
|
|||||||
{ label: '预计到港时间', value: formatOverseasDateTime(order?.estimatedArrivalAt) },
|
{ label: '预计到港时间', value: formatOverseasDateTime(order?.estimatedArrivalAt) },
|
||||||
{ label: '实际到港时间', value: formatOverseasDateTime(order?.actualArrivalAt) },
|
{ label: '实际到港时间', value: formatOverseasDateTime(order?.actualArrivalAt) },
|
||||||
{ label: '预计到仓时间', value: formatOverseasDateTime(order?.estimatedWarehouseAt) },
|
{ label: '预计到仓时间', value: formatOverseasDateTime(order?.estimatedWarehouseAt) },
|
||||||
{ label: '清关状态', value: text(order?.containerStatus) },
|
{ label: '货柜节点', value: containerNodeLabel(order?.containerNode) },
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
const planFields = computed<DetailField[]>(() => {
|
const planFields = computed<DetailField[]>(() => {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ describe('overseas warehouse mounted real API pages', () => {
|
|||||||
vi.mocked(listInboundOrders).mockReset().mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 })
|
vi.mocked(listInboundOrders).mockReset().mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 })
|
||||||
vi.mocked(getInboundPlan).mockReset().mockResolvedValue({ id: '9007199254740993', planNo: 'IP-1', customerId: '10001', sourceOrderId: '9007199254740995', sourceOrderNo: 'FL-WH-20260911-000001', customerName: '客户甲', orderType: 'ONE_CLICK_DROPSHIP', overseasWarehouseCode: 'US-LAX-01', overseasWarehouseName: '洛杉矶接柜仓', status: 'PENDING_ASSIGNMENT', lineCount: 1, palletCount: 1, totalVolumeCbm: 2, createdAt: '2026-09-11T10:00:00', updatedAt: '2026-09-11T10:10:00', remark: '页面验收', lines: [{ id: '1', orderId: '9007199254740995', inboundCode: 'IN-1', fbaWarehouseCode: 'ABE3', destinationWarehouseCode: 'ABE3', destinationWarehouseName: 'ABE3目的仓', volumeCbm: 2, requiredPackageCount: 10, plannedPalletCount: 1 }], pallets: [], allocations: [] })
|
vi.mocked(getInboundPlan).mockReset().mockResolvedValue({ id: '9007199254740993', planNo: 'IP-1', customerId: '10001', sourceOrderId: '9007199254740995', sourceOrderNo: 'FL-WH-20260911-000001', customerName: '客户甲', orderType: 'ONE_CLICK_DROPSHIP', overseasWarehouseCode: 'US-LAX-01', overseasWarehouseName: '洛杉矶接柜仓', status: 'PENDING_ASSIGNMENT', lineCount: 1, palletCount: 1, totalVolumeCbm: 2, createdAt: '2026-09-11T10:00:00', updatedAt: '2026-09-11T10:10:00', remark: '页面验收', lines: [{ id: '1', orderId: '9007199254740995', inboundCode: 'IN-1', fbaWarehouseCode: 'ABE3', destinationWarehouseCode: 'ABE3', destinationWarehouseName: 'ABE3目的仓', volumeCbm: 2, requiredPackageCount: 10, plannedPalletCount: 1 }], pallets: [], allocations: [] })
|
||||||
vi.mocked(listInventoryLedger).mockReset().mockResolvedValue({ records: [{ id: '9007199254740997', customerId: '10001', customerName: '客户甲', destinationWarehouseCode: 'LAX', inboundCode: 'NYEXX2608300001', inboundOrderNo: 'IP-1', businessType: 'STOCK_IN', beforePackages: 0, deltaPackages: 10, afterPackages: 10, occurredAt: '2026-08-20T10:00:00' }], page: 1, size: 20, total: 1, pages: 1 })
|
vi.mocked(listInventoryLedger).mockReset().mockResolvedValue({ records: [{ id: '9007199254740997', customerId: '10001', customerName: '客户甲', destinationWarehouseCode: 'LAX', inboundCode: 'NYEXX2608300001', inboundOrderNo: 'IP-1', businessType: 'STOCK_IN', beforePackages: 0, deltaPackages: 10, afterPackages: 10, occurredAt: '2026-08-20T10:00:00' }], page: 1, size: 20, total: 1, pages: 1 })
|
||||||
vi.mocked(getLastMileOrder).mockReset().mockResolvedValue({ order: { id: '9007199254740999', customerId: '10001', customerName: '华远贸易', orderNo: 'LMO-260827-0001', inboundCode: 'NYEXX2608280033', containerNo: 'TSTU0000061', currentStatus: 'PENDING_ASSIGNMENT' }, cargo: [], statusHistory: [], fees: [], operationHistory: [] })
|
vi.mocked(getLastMileOrder).mockReset().mockResolvedValue({ order: { id: '9007199254740999', customerId: '10001', customerName: '华远贸易', orderNo: 'LMO-260827-0001', inboundCode: 'NYEXX2608280033', containerNo: 'TSTU0000061', containerNode: 'PICKUP_READY', currentStatus: 'PENDING_ASSIGNMENT' }, cargo: [], statusHistory: [], fees: [], operationHistory: [] })
|
||||||
})
|
})
|
||||||
it('keeps receiving warehouse and FBA warehouse independent in the inbound-plan list', async () => {
|
it('keeps receiving warehouse and FBA warehouse independent in the inbound-plan list', async () => {
|
||||||
vi.mocked(listInboundOrders).mockResolvedValue({
|
vi.mocked(listInboundOrders).mockResolvedValue({
|
||||||
@@ -80,5 +80,5 @@ describe('overseas warehouse mounted real API pages', () => {
|
|||||||
expect(wrapper.text()).not.toContain('9007199254740995')
|
expect(wrapper.text()).not.toContain('9007199254740995')
|
||||||
})
|
})
|
||||||
it('mounts inventory ledger as localized read-only business facts without exposing internal ids', async () => { const wrapper = mount(InventoryTransactionsView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises(); expect(listInventoryLedger).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 10 })); expect(listInventoryLedger).not.toHaveBeenCalledWith(expect.objectContaining({ customerId: expect.anything() })); expect(wrapper.text()).toContain('客户甲'); expect(wrapper.text()).toContain('NYEXX2608300001'); expect(wrapper.text()).not.toContain('10001'); expect(wrapper.text()).toContain('IP-1'); expect(wrapper.text()).toContain('10'); expect(wrapper.text()).toContain('入库'); expect(wrapper.text()).not.toContain('STOCK_IN'); expect(wrapper.text()).toContain('不提供出库单跳转'); expect(wrapper.text()).not.toContain('当前流水 DTO'); expect(wrapper.text()).not.toContain('操作人名称待补充') })
|
it('mounts inventory ledger as localized read-only business facts without exposing internal ids', async () => { const wrapper = mount(InventoryTransactionsView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises(); expect(listInventoryLedger).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 10 })); expect(listInventoryLedger).not.toHaveBeenCalledWith(expect.objectContaining({ customerId: expect.anything() })); expect(wrapper.text()).toContain('客户甲'); expect(wrapper.text()).toContain('NYEXX2608300001'); expect(wrapper.text()).not.toContain('10001'); expect(wrapper.text()).toContain('IP-1'); expect(wrapper.text()).toContain('10'); expect(wrapper.text()).toContain('入库'); expect(wrapper.text()).not.toContain('STOCK_IN'); expect(wrapper.text()).toContain('不提供出库单跳转'); expect(wrapper.text()).not.toContain('当前流水 DTO'); expect(wrapper.text()).not.toContain('操作人名称待补充') })
|
||||||
it('renders complete business references with single-line ellipsis and titles', async () => { routing.route.params = { orderId: '9007199254740999' }; const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises(); expect(getLastMileOrder).toHaveBeenCalledWith('9007199254740999'); expect(wrapper.text()).toContain('华远贸易'); expect(wrapper.text()).toContain('LMO-260827-0001'); expect(wrapper.get('.order-reference-text').attributes('title')).toBe('LMO-260827-0001'); const businessValues = wrapper.findAll('.detail-field-value--business'); expect(businessValues.map((item) => item.attributes('title'))).toEqual(expect.arrayContaining(['NYEXX2608280033', 'TSTU0000061'])); expect(businessValues.every((item) => item.text().length > 0)).toBe(true) })
|
it('renders complete business references with single-line ellipsis and titles', async () => { routing.route.params = { orderId: '9007199254740999' }; const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises(); expect(getLastMileOrder).toHaveBeenCalledWith('9007199254740999'); expect(wrapper.text()).toContain('华远贸易'); expect(wrapper.text()).toContain('LMO-260827-0001'); expect(wrapper.text()).toContain('货柜节点'); expect(wrapper.text()).toContain('已清关、可提柜'); expect(wrapper.text()).not.toContain('清关状态'); expect(wrapper.get('.order-reference-text').attributes('title')).toBe('LMO-260827-0001'); const businessValues = wrapper.findAll('.detail-field-value--business'); expect(businessValues.map((item) => item.attributes('title'))).toEqual(expect.arrayContaining(['NYEXX2608280033', 'TSTU0000061'])); expect(businessValues.every((item) => item.text().length > 0)).toBe(true) })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export interface LastMileOrderListItem {
|
|||||||
transferWarehouse: string
|
transferWarehouse: string
|
||||||
fbaWarehouse: string
|
fbaWarehouse: string
|
||||||
containerNo: string
|
containerNo: string
|
||||||
containerStatus: string
|
containerNode: string
|
||||||
abnormal: string
|
abnormal: string
|
||||||
held: string
|
held: string
|
||||||
detained: boolean
|
detained: boolean
|
||||||
|
|||||||
@@ -134,7 +134,12 @@ describe('UnifiedOrdersView contract', () => {
|
|||||||
for (const uncontractedKey of [
|
for (const uncontractedKey of [
|
||||||
'internalOrderStatus', 'holdCargo', 'companyName', 'pickupMethod', 'associatedFiles', 'createdBy',
|
'internalOrderStatus', 'holdCargo', 'companyName', 'pickupMethod', 'associatedFiles', 'createdBy',
|
||||||
]) expect(columnSource).not.toContain(`key: '${uncontractedKey}'`)
|
]) expect(columnSource).not.toContain(`key: '${uncontractedKey}'`)
|
||||||
expect(columnSource.indexOf("key: 'actions'")).toBeLessThan(columnSource.indexOf("key: 'inboundCode'"))
|
const actionsColumnIndex = columnSource.indexOf("key: 'actions'")
|
||||||
|
const selectionColumnIndex = columnSource.indexOf("key: 'selection'")
|
||||||
|
const inboundCodeColumnIndex = columnSource.indexOf("key: 'inboundCode'")
|
||||||
|
expect(actionsColumnIndex).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(selectionColumnIndex).toBeGreaterThan(actionsColumnIndex)
|
||||||
|
expect(inboundCodeColumnIndex).toBeGreaterThan(selectionColumnIndex)
|
||||||
expect(columnSource).not.toContain("key: 'coLoadGroup'")
|
expect(columnSource).not.toContain("key: 'coLoadGroup'")
|
||||||
expect(columnSource).not.toContain("key: 'orderNo'")
|
expect(columnSource).not.toContain("key: 'orderNo'")
|
||||||
expect(columnSource).toContain("key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 160, slotName: 'inboundCode'")
|
expect(columnSource).toContain("key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 160, slotName: 'inboundCode'")
|
||||||
|
|||||||
@@ -621,6 +621,7 @@ const queryFields = computed(() => showAdvancedFilters.value
|
|||||||
: primaryQueryFields.value)
|
: primaryQueryFields.value)
|
||||||
|
|
||||||
const columns = computed<DataTableColumn[]>(() => [
|
const columns = computed<DataTableColumn[]>(() => [
|
||||||
|
// 业务表格保持操作列第一;批量选择列仅在有对应权限时紧随其后。
|
||||||
{ key: 'actions', label: '操作', width: 290, fixed: 'left', slotName: 'actions' },
|
{ key: 'actions', label: '操作', width: 290, fixed: 'left', slotName: 'actions' },
|
||||||
...(can('platform:order:approve') || can('platform:firstleg:smart-loading:assign') || can('platform:order:export') || can('platform:firstleg:inbound:view') ? [{
|
...(can('platform:order:approve') || can('platform:firstleg:smart-loading:assign') || can('platform:order:export') || can('platform:firstleg:inbound:view') ? [{
|
||||||
key: 'selection', type: 'selection', width: 48, fixed: 'left',
|
key: 'selection', type: 'selection', width: 48, fixed: 'left',
|
||||||
|
|||||||
Reference in New Issue
Block a user