feat(client): optimize first-leg order actions
This commit is contained in:
@@ -59,6 +59,7 @@ export interface MerchantUnifiedOrderRow {
|
|||||||
lxCode?: string | null
|
lxCode?: string | null
|
||||||
mainStatus: ApiUnifiedOrderMainStatus
|
mainStatus: ApiUnifiedOrderMainStatus
|
||||||
nextAction: string | null
|
nextAction: string | null
|
||||||
|
customerFeeConfirmationRequired: boolean
|
||||||
internalOrderStatus: ApiFirstLegStatus | null
|
internalOrderStatus: ApiFirstLegStatus | null
|
||||||
customerId: number | string | null
|
customerId: number | string | null
|
||||||
customerName: string
|
customerName: string
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ function timeline(detail: MerchantFirstLegOrderDetail): FirstLegOrderTimelineIte
|
|||||||
|
|
||||||
function businessSummary(summary?: MerchantUnifiedOrderRow): FirstLegOrder['businessSummary'] {
|
function businessSummary(summary?: MerchantUnifiedOrderRow): FirstLegOrder['businessSummary'] {
|
||||||
return {
|
return {
|
||||||
|
customerFeeConfirmationRequired: summary?.customerFeeConfirmationRequired ?? false,
|
||||||
totalPackageCount: summary?.totalPackageCount ?? null,
|
totalPackageCount: summary?.totalPackageCount ?? null,
|
||||||
actualGrossWeightKg: summary?.actualGrossWeightKg ?? null,
|
actualGrossWeightKg: summary?.actualGrossWeightKg ?? null,
|
||||||
actualVolumeCbm: summary?.actualVolumeCbm ?? null,
|
actualVolumeCbm: summary?.actualVolumeCbm ?? null,
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export interface FirstLegOrderTimelineItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FirstLegOrderBusinessSummary {
|
export interface FirstLegOrderBusinessSummary {
|
||||||
|
customerFeeConfirmationRequired: boolean
|
||||||
totalPackageCount: number | null
|
totalPackageCount: number | null
|
||||||
actualGrossWeightKg: number | null
|
actualGrossWeightKg: number | null
|
||||||
actualVolumeCbm: number | null
|
actualVolumeCbm: number | null
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const editorShellSource = readFileSync(new URL('../components/FirstLegOrderEdito
|
|||||||
const progressSource = readFileSync(new URL('../components/OrderProgressPanel.vue', import.meta.url), 'utf8')
|
const progressSource = readFileSync(new URL('../components/OrderProgressPanel.vue', import.meta.url), 'utf8')
|
||||||
const customsDialogSource = readFileSync(new URL('../components/CustomsDocumentsDialog.vue', import.meta.url), 'utf8')
|
const customsDialogSource = readFileSync(new URL('../components/CustomsDocumentsDialog.vue', import.meta.url), 'utf8')
|
||||||
const customsApiSource = readFileSync(new URL('../api/customs.ts', import.meta.url), 'utf8')
|
const customsApiSource = readFileSync(new URL('../api/customs.ts', import.meta.url), 'utf8')
|
||||||
|
const orderStoreSource = readFileSync(new URL('../store/firstLegOrders.ts', import.meta.url), 'utf8')
|
||||||
|
|
||||||
test('routes the confirmed first-leg path to the implemented order view', () => {
|
test('routes the confirmed first-leg path to the implemented order view', () => {
|
||||||
assert.match(routerSource, /path: 'orders\/first-leg'[\s\S]*?component: \(\) => import\('\.\/views\/FirstLegOrdersView\.vue'\)[\s\S]*?release: 'active'/)
|
assert.match(routerSource, /path: 'orders\/first-leg'[\s\S]*?component: \(\) => import\('\.\/views\/FirstLegOrdersView\.vue'\)[\s\S]*?release: 'active'/)
|
||||||
@@ -88,7 +89,7 @@ test('uses the shared query toolbar and data table without metric cards', () =>
|
|||||||
assert.match(source, /import QueryToolbar, \{ type QueryField, type QueryModel \}/)
|
assert.match(source, /import QueryToolbar, \{ type QueryField, type QueryModel \}/)
|
||||||
assert.match(source, /import DataTable, \{ type DataTableColumn \}/)
|
assert.match(source, /import DataTable, \{ type DataTableColumn \}/)
|
||||||
assert.match(source, /const filters = ref<QueryModel>/)
|
assert.match(source, /const filters = ref<QueryModel>/)
|
||||||
assert.match(source, /<QueryToolbar v-model="filters" class="first-leg-query-toolbar" :fields="queryFields" ariaLabel="订单查询" @search="searchOrders">/)
|
assert.match(source, /<QueryToolbar v-model="filters" class="first-leg-query-toolbar" :fields="queryFields" ariaLabel="订单查询" @search="searchOrders" \/>/)
|
||||||
assert.match(source, /async function searchOrders\(model: QueryModel\)/)
|
assert.match(source, /async function searchOrders\(model: QueryModel\)/)
|
||||||
assert.match(source, /await store\.load\(\{/)
|
assert.match(source, /await store\.load\(\{/)
|
||||||
assert.match(source, /<DataTable v-model:page="tablePage" v-model:page-size="tablePageSize" :data="pagedOrders" :columns="visibleOrderColumns"/)
|
assert.match(source, /<DataTable v-model:page="tablePage" v-model:page-size="tablePageSize" :data="pagedOrders" :columns="visibleOrderColumns"/)
|
||||||
@@ -96,7 +97,7 @@ test('uses the shared query toolbar and data table without metric cards', () =>
|
|||||||
assert.doesNotMatch(source, /order-metrics|订单概览/)
|
assert.doesNotMatch(source, /order-metrics|订单概览/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('adds prototype download actions to the order query panel', () => {
|
test('adds download actions to the order-list toolbar', () => {
|
||||||
assert.match(source, /下载唛头/)
|
assert.match(source, /下载唛头/)
|
||||||
assert.match(source, /导出订单信息/)
|
assert.match(source, /导出订单信息/)
|
||||||
assert.match(source, /function downloadShippingMarks\(\)/)
|
assert.match(source, /function downloadShippingMarks\(\)/)
|
||||||
@@ -237,12 +238,12 @@ test('keeps the seven-stage progress and business summary usable on mobile', ()
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('keeps the first viewport compact and moves create into the table toolbar', () => {
|
test('keeps the first viewport compact and moves create into the table toolbar', () => {
|
||||||
const queryToolbarSource = source.slice(source.indexOf('<QueryToolbar'), source.indexOf('</QueryToolbar>') + '</QueryToolbar>'.length)
|
const tableToolbarSource = source.slice(source.indexOf('class="order-table-toolbar"'), source.indexOf('class="order-eligibility is-pending"'))
|
||||||
assert.match(source, /class="order-table-toolbar"/)
|
assert.match(source, /class="order-table-toolbar"/)
|
||||||
assert.match(source, /<strong>订单列表<\/strong><span>共 \{\{ filteredOrders\.length \}\} 条<\/span>/)
|
assert.match(source, /<strong>订单列表<\/strong><span>共 \{\{ filteredOrders\.length \}\} 条<\/span>/)
|
||||||
assert.match(source, /class="order-table-toolbar"[\s\S]*order:first-leg:create[\s\S]*创建订单[\s\S]*class="order-eligibility is-pending"/)
|
assert.match(source, /class="order-table-toolbar"[\s\S]*order:first-leg:create[\s\S]*创建订单[\s\S]*class="order-eligibility is-pending"/)
|
||||||
assert.match(queryToolbarSource, /<template #actions>[\s\S]*下载唛头[\s\S]*导出订单信息/)
|
assert.match(tableToolbarSource, /下载唛头[\s\S]*导出订单信息[\s\S]*批量提交草稿[\s\S]*创建订单/)
|
||||||
assert.doesNotMatch(queryToolbarSource, /order:first-leg:create|创建订单/)
|
assert.doesNotMatch(source.slice(source.indexOf('<QueryToolbar'), source.indexOf('<div class="client-card order-table-card">')), /下载唛头|导出订单信息|创建订单/)
|
||||||
assert.match(source, /client-main:has\(> \.first-leg-page:not\(\.is-create-route\)\) > \.client-page-heading > div\) \{ position: absolute; width: 1px; height: 1px;/)
|
assert.match(source, /client-main:has\(> \.first-leg-page:not\(\.is-create-route\)\) > \.client-page-heading > div\) \{ position: absolute; width: 1px; height: 1px;/)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -532,11 +533,18 @@ test('opens billing-weight confirmation independently from order details', () =>
|
|||||||
assert.match(source, /unavailable-message="未推送计费重"/)
|
assert.match(source, /unavailable-message="未推送计费重"/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('shows the standalone billing-weight entry at every order node', () => {
|
test('shows billing-weight confirmation only when the backend marks it pending', () => {
|
||||||
const templateSource = source.slice(source.indexOf('<template>'), source.lastIndexOf('</template>'))
|
const templateSource = source.slice(source.indexOf('<template>'), source.lastIndexOf('</template>'))
|
||||||
const entries = templateSource.match(/<el-button link type="primary" @click="openFeeConfirmation\([^)]*\)">确认计费重<\/el-button>/g) ?? []
|
const entries = templateSource.match(/<el-button v-if="canConfirmInboundFee && [^"]*customerFeeConfirmationRequired" link type="primary" @click="openFeeConfirmation\([^)]*\)">确认计费重<\/el-button>/g) ?? []
|
||||||
assert.equal(entries.length, 2)
|
assert.equal(entries.length, 2)
|
||||||
assert.doesNotMatch(templateSource, /v-if="[^"]*status === 'WAIT_SPLIT'[^"]*"[^>]*>确认计费重/)
|
assert.match(orderStoreSource, /customerFeeConfirmationRequired: summary\?\.customerFeeConfirmationRequired \?\? false/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('submits only selected draft orders in one batch action', () => {
|
||||||
|
assert.match(source, /selectedDraftOrders = computed\(\(\) => selectedOrders\.value\.filter\(\(order\) => order\.status === 'DRAFT'\)\)/)
|
||||||
|
assert.match(source, /Promise\.allSettled\(drafts\.map\(\(order\) => submitMerchantFirstLegOrder/)
|
||||||
|
assert.match(source, /createIdempotencyKey\(`first-leg-batch-submit-\$\{order\.id\}`\)/)
|
||||||
|
assert.match(source, /v-if="canSubmitOrder"[\s\S]*批量提交草稿/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('discards stale inbound billing responses when billing views close or the user changes orders', () => {
|
test('discards stale inbound billing responses when billing views close or the user changes orders', () => {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
getMerchantOrderAddresses,
|
getMerchantOrderAddresses,
|
||||||
getMerchantPricingChannels,
|
getMerchantPricingChannels,
|
||||||
importMerchantFirstLegOrders,
|
importMerchantFirstLegOrders,
|
||||||
|
submitMerchantFirstLegOrder,
|
||||||
updateMerchantOrderAddress,
|
updateMerchantOrderAddress,
|
||||||
uploadMerchantProductImage,
|
uploadMerchantProductImage,
|
||||||
type CustomerOrderAddress,
|
type CustomerOrderAddress,
|
||||||
@@ -72,6 +73,7 @@ const companyAuditLoaded = ref(false)
|
|||||||
const companySubmissionBlocked = computed(() => companyAuditLoaded.value && tenantStore.company.auditStatus !== 'ACTIVE')
|
const companySubmissionBlocked = computed(() => companyAuditLoaded.value && tenantStore.company.auditStatus !== 'ACTIVE')
|
||||||
const canCreateOrder = computed(() => auth.hasPermission('order:first-leg:create'))
|
const canCreateOrder = computed(() => auth.hasPermission('order:first-leg:create'))
|
||||||
const canEditOrder = computed(() => auth.hasPermission('order:first-leg:edit'))
|
const canEditOrder = computed(() => auth.hasPermission('order:first-leg:edit'))
|
||||||
|
const canSubmitOrder = computed(() => auth.hasPermission('order:first-leg:submit'))
|
||||||
const canConfirmInboundFee = computed(() => auth.hasPermission('order:first-leg:edit'))
|
const canConfirmInboundFee = computed(() => auth.hasPermission('order:first-leg:edit'))
|
||||||
const availableWarehouses = computed(() => store.warehouses.filter((warehouse) => warehouse.warehouseType === 'DOMESTIC'))
|
const availableWarehouses = computed(() => store.warehouses.filter((warehouse) => warehouse.warehouseType === 'DOMESTIC'))
|
||||||
const availableDestinationWarehouses = computed(() => store.warehouses.filter((warehouse) => warehouse.warehouseType === form.destinationType))
|
const availableDestinationWarehouses = computed(() => store.warehouses.filter((warehouse) => warehouse.warehouseType === form.destinationType))
|
||||||
@@ -182,8 +184,10 @@ const tablePage = ref(1)
|
|||||||
const tablePageSize = ref(10)
|
const tablePageSize = ref(10)
|
||||||
const downloadingMarks = ref(false)
|
const downloadingMarks = ref(false)
|
||||||
const exportingOrders = ref(false)
|
const exportingOrders = ref(false)
|
||||||
|
const batchSubmitting = ref(false)
|
||||||
const selectedOrders = ref<FirstLegOrder[]>([])
|
const selectedOrders = ref<FirstLegOrder[]>([])
|
||||||
const selectedOrderCount = computed(() => selectedOrders.value.length)
|
const selectedOrderCount = computed(() => selectedOrders.value.length)
|
||||||
|
const selectedDraftOrders = computed(() => selectedOrders.value.filter((order) => order.status === 'DRAFT'))
|
||||||
const orderTableVersion = ref(0)
|
const orderTableVersion = ref(0)
|
||||||
const editorOpen = ref(false)
|
const editorOpen = ref(false)
|
||||||
const orderFormRef = ref<FormInstance>()
|
const orderFormRef = ref<FormInstance>()
|
||||||
@@ -1485,6 +1489,46 @@ function handleOrderSelectionChange(rows: FirstLegOrder[]) {
|
|||||||
selectedOrders.value = rows
|
selectedOrders.value = rows
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function submitSelectedDraftOrders() {
|
||||||
|
const drafts = selectedDraftOrders.value
|
||||||
|
if (batchSubmitting.value || !drafts.length) return
|
||||||
|
if (companySubmissionBlocked.value) {
|
||||||
|
ElMessage.warning('企业资料审核通过后才能提交订单')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认提交选中的 ${drafts.length} 条草稿订单吗?`, '批量提交订单', {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '确认提交',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (error === 'cancel' || error === 'close') return
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
batchSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const results = await Promise.allSettled(drafts.map((order) => submitMerchantFirstLegOrder(
|
||||||
|
order.id,
|
||||||
|
createIdempotencyKey(`first-leg-batch-submit-${order.id}`),
|
||||||
|
)))
|
||||||
|
const successCount = results.filter((result) => result.status === 'fulfilled').length
|
||||||
|
const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
|
||||||
|
await searchOrders(appliedFilters.value)
|
||||||
|
if (!failures.length) {
|
||||||
|
ElMessage.success(`已提交 ${successCount} 条草稿订单`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const firstReason = failures[0]?.reason
|
||||||
|
const reason = firstReason instanceof Error ? firstReason.message : '提交失败'
|
||||||
|
if (successCount > 0) ElMessage.warning(`${successCount} 条提交成功,${failures.length} 条失败:${reason}`)
|
||||||
|
else ElMessage.error(`${failures.length} 条草稿订单提交失败:${reason}`)
|
||||||
|
} finally {
|
||||||
|
batchSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function downloadShippingMarks() {
|
async function downloadShippingMarks() {
|
||||||
if (downloadingMarks.value) return
|
if (downloadingMarks.value) return
|
||||||
const orders = selectedOrders.value
|
const orders = selectedOrders.value
|
||||||
@@ -1816,12 +1860,7 @@ async function searchOrders(model: QueryModel) {
|
|||||||
<template>
|
<template>
|
||||||
<section class="first-leg-page" :class="{ 'is-create-route': isCreatePage }">
|
<section class="first-leg-page" :class="{ 'is-create-route': isCreatePage }">
|
||||||
<template v-if="!isCreatePage">
|
<template v-if="!isCreatePage">
|
||||||
<QueryToolbar v-model="filters" class="first-leg-query-toolbar" :fields="queryFields" ariaLabel="订单查询" @search="searchOrders">
|
<QueryToolbar v-model="filters" class="first-leg-query-toolbar" :fields="queryFields" ariaLabel="订单查询" @search="searchOrders" />
|
||||||
<template #actions>
|
|
||||||
<el-button class="query-export-button" type="primary" :loading="downloadingMarks" :disabled="selectedOrderCount === 0" @click="downloadShippingMarks"><Download :size="16" />下载唛头</el-button>
|
|
||||||
<el-button class="query-export-button" type="primary" :loading="exportingOrders" :disabled="selectedOrderCount === 0" @click="exportOrderInformation"><Download :size="16" />导出订单信息</el-button>
|
|
||||||
</template>
|
|
||||||
</QueryToolbar>
|
|
||||||
|
|
||||||
<div class="client-card order-table-card">
|
<div class="client-card order-table-card">
|
||||||
<div class="order-table-toolbar">
|
<div class="order-table-toolbar">
|
||||||
@@ -1853,6 +1892,9 @@ async function searchOrders(model: QueryModel) {
|
|||||||
<el-button link class="column-settings-reset" @click="resetOrderColumns"><RotateCcw :size="17" />恢复默认</el-button>
|
<el-button link class="column-settings-reset" @click="resetOrderColumns"><RotateCcw :size="17" />恢复默认</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-popover>
|
</el-popover>
|
||||||
|
<el-button :loading="downloadingMarks" :disabled="selectedOrderCount === 0" @click="downloadShippingMarks"><Download :size="16" />下载唛头</el-button>
|
||||||
|
<el-button :loading="exportingOrders" :disabled="selectedOrderCount === 0" @click="exportOrderInformation"><Download :size="16" />导出订单信息</el-button>
|
||||||
|
<el-button v-if="canSubmitOrder" :loading="batchSubmitting" :disabled="selectedDraftOrders.length === 0 || companySubmissionBlocked" @click="submitSelectedDraftOrders"><Send :size="17" />批量提交草稿</el-button>
|
||||||
<el-button v-if="auth.hasPermission('order:first-leg:create')" @click="openBatchImport"><Upload :size="17" />批量导入订单</el-button>
|
<el-button v-if="auth.hasPermission('order:first-leg:create')" @click="openBatchImport"><Upload :size="17" />批量导入订单</el-button>
|
||||||
<el-button v-if="auth.hasPermission('order:first-leg:create')" type="primary" :loading="pageDataLoading" :disabled="pageDataLoading || availableWarehouses.length === 0" @click="openCreate"><FilePlus2 :size="17" />创建订单</el-button>
|
<el-button v-if="auth.hasPermission('order:first-leg:create')" type="primary" :loading="pageDataLoading" :disabled="pageDataLoading || availableWarehouses.length === 0" @click="openCreate"><FilePlus2 :size="17" />创建订单</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1878,7 +1920,7 @@ async function searchOrders(model: QueryModel) {
|
|||||||
<template #actions="{ row }">
|
<template #actions="{ row }">
|
||||||
<div class="order-row-actions">
|
<div class="order-row-actions">
|
||||||
<el-button link type="primary" @click="openDetail(row as FirstLegOrder)"><Eye :size="15" />详情</el-button>
|
<el-button link type="primary" @click="openDetail(row as FirstLegOrder)"><Eye :size="15" />详情</el-button>
|
||||||
<el-button link type="primary" @click="openFeeConfirmation(row as FirstLegOrder)">确认计费重</el-button>
|
<el-button v-if="canConfirmInboundFee && (row as FirstLegOrder).businessSummary.customerFeeConfirmationRequired" link type="primary" @click="openFeeConfirmation(row as FirstLegOrder)">确认计费重</el-button>
|
||||||
<el-button v-if="canEditOrder && canClientEditFirstLegOrder((row as FirstLegOrder).status)" link type="primary" :loading="openingEditor" @click="openEdit(row as FirstLegOrder)"><Pencil :size="15" />编辑</el-button>
|
<el-button v-if="canEditOrder && canClientEditFirstLegOrder((row as FirstLegOrder).status)" link type="primary" :loading="openingEditor" @click="openEdit(row as FirstLegOrder)"><Pencil :size="15" />编辑</el-button>
|
||||||
<el-button v-if="eligibleCustomsDetail(row as FirstLegOrder)" link type="primary" @click="openCustomsDocuments(row as FirstLegOrder)"><FileText :size="15" />报关资料</el-button>
|
<el-button v-if="eligibleCustomsDetail(row as FirstLegOrder)" link type="primary" @click="openCustomsDocuments(row as FirstLegOrder)"><FileText :size="15" />报关资料</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1944,7 +1986,7 @@ async function searchOrders(model: QueryModel) {
|
|||||||
<span>更新于 {{ formatDateTime(order.updatedAt) }}</span>
|
<span>更新于 {{ formatDateTime(order.updatedAt) }}</span>
|
||||||
<div>
|
<div>
|
||||||
<el-button link type="primary" @click="openDetail(order)"><Eye :size="15" />详情</el-button>
|
<el-button link type="primary" @click="openDetail(order)"><Eye :size="15" />详情</el-button>
|
||||||
<el-button link type="primary" @click="openFeeConfirmation(order)">确认计费重</el-button>
|
<el-button v-if="canConfirmInboundFee && order.businessSummary.customerFeeConfirmationRequired" link type="primary" @click="openFeeConfirmation(order)">确认计费重</el-button>
|
||||||
<el-button v-if="canEditOrder && canClientEditFirstLegOrder(order.status)" link type="primary" :loading="openingEditor" @click="openEdit(order)"><Pencil :size="15" />编辑</el-button>
|
<el-button v-if="canEditOrder && canClientEditFirstLegOrder(order.status)" link type="primary" :loading="openingEditor" @click="openEdit(order)"><Pencil :size="15" />编辑</el-button>
|
||||||
<el-button v-if="eligibleCustomsDetail(order)" link type="primary" @click="openCustomsDocuments(order)"><FileText :size="15" />报关资料</el-button>
|
<el-button v-if="eligibleCustomsDetail(order)" link type="primary" @click="openCustomsDocuments(order)"><FileText :size="15" />报关资料</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user