This commit is contained in:
2026-11-03 11:43:50 +08:00
parent f8408b8076
commit 1c2dd8797a
34 changed files with 354 additions and 84 deletions
@@ -12,7 +12,7 @@ import { ApiResponseError } from '@/shared/api/http'
import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants } from '@/modules/iam'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { createReconciliationStatement, getReconciliationStatement, listFinanceFeeSources, listFinanceStatements } from '../api/finance'
import { actionReconciliationStatement, createReconciliationStatement, getReconciliationStatement, listFinanceFeeSources, listFinanceStatements } from '../api/finance'
import type { FinanceFeeSourceSummary, FinanceReconciliationDetail, FinanceStatementReadModel } from '../types'
import FinanceCustomerStatementsView from './FinanceCustomerStatementsView.vue'
@@ -76,6 +76,29 @@ enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
describe('FinanceCustomerStatementsView', () => {
it('omits reopening voided and rejected statements and ignores stale reopen commands', async () => {
vi.mocked(listFinanceStatements).mockResolvedValue([{ ...statement, status: 'VOIDED' }, { ...statement, id: '502', status: 'REJECTED' }])
const { wrapper } = await mountPage(['platform:finance:customer-statement:list', 'platform:finance:reconciliation:void'])
const page = wrapper.vm as unknown as { handleStatementAction(command: string, row: FinanceStatementReadModel): void }
expect(wrapper.text()).not.toContain('重开')
page.handleStatementAction('reopen', { ...statement, status: 'VOIDED' })
await flushPromises()
expect(actionReconciliationStatement).not.toHaveBeenCalled()
})
it('keeps statement rows visible and shows only the business error after a failed action', async () => {
const draft = { ...statement, status: 'DRAFT' }
vi.mocked(listFinanceStatements).mockResolvedValue([draft])
vi.mocked(actionReconciliationStatement).mockRejectedValueOnce(new ApiResponseError('费用已生成新的对账单', 'FIN_409', 409, null, 'REQUEST-FAIL', 'TRACE-FAIL'))
const { wrapper } = await mountPage(['platform:finance:customer-statement:list', 'platform:finance:reconciliation:create'])
const page = wrapper.vm as unknown as { handleStatementAction(command: string, row: FinanceStatementReadModel): void }
page.handleStatementAction('submit', draft)
await flushPromises()
expect(wrapper.text()).toContain('费用已生成新的对账单')
expect(wrapper.text()).toContain('REC-501')
expect(wrapper.text()).not.toMatch(/REQUEST-FAIL|TRACE-FAIL|客户对账单加载失败/)
})
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('localStorage', memoryStorage())
@@ -52,6 +52,7 @@ const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const actionError = ref('')
const customerError = ref<ApiErrorDetails | null>(null)
const selectedId = ref('')
const detail = ref<FinanceReconciliationDetail | null>(null)
@@ -263,6 +264,7 @@ async function load(model?: QueryModel) {
detail.value = null
detailCache.clear()
error.value = null
actionError.value = ''
loading.value = true
try {
const statements = await listFinanceStatements(String(appliedFilters.value.customerId || '').trim() || undefined)
@@ -516,17 +518,18 @@ async function createStatement() {
}
async function performAction(row: FinanceStatementReadModel, actionName: ReconciliationActionPayload['action']) {
const title = ({ REJECT: '驳回对账单', VOID: '作废对账单', REOPEN: '重开对账单' } as Partial<Record<ReconciliationActionPayload['action'], string>>)[actionName] || '对账单操作'
const reason = ['REJECT', 'VOID', 'REOPEN'].includes(actionName)
const title = ({ REJECT: '驳回对账单', VOID: '作废对账单' } as Partial<Record<ReconciliationActionPayload['action'], string>>)[actionName] || '对账单操作'
const reason = ['REJECT', 'VOID'].includes(actionName)
? await ElMessageBox.prompt('请输入操作原因', `${title} · ${row.billNo || '—'}`, { customClass: 'admin-density-confirm admin-workbench', inputType: 'textarea', inputPattern: /\S+/, inputErrorMessage: '操作原因不能为空', confirmButtonText: '确认', cancelButtonText: '取消' }).then((result) => result.value).catch(() => null)
: undefined
if (reason === null) return
actionError.value = ''
actionBusy.value = true
try {
await actionReconciliationStatement(row.id, { action: actionName, expectedVersion: row.version, ...(reason ? { reason } : {}) })
ElMessage.success('对账单状态已更新')
await load(appliedFilters.value)
} catch (cause) { error.value = getApiErrorDetails(cause, '对账单操作失败') }
} catch (cause) { actionError.value = getApiErrorMessage(cause, '对账单操作失败') }
finally { actionBusy.value = false }
}
@@ -537,7 +540,6 @@ function statementActions(row: FinanceStatementReadModel): RowActionItem[] {
{ key: 'approve', label: '通过', priority: 250, tone: 'warning', visible: row.status === 'PENDING_REVIEW' && canReview.value, loading: actionBusy.value },
{ key: 'reject', label: '驳回', priority: 200, tone: 'danger', visible: row.status === 'PENDING_REVIEW' && canReview.value, loading: actionBusy.value },
{ key: 'issue', label: '出账', priority: 200, visible: row.status === 'APPROVED' && canIssue.value, loading: actionBusy.value },
{ key: 'reopen', label: '重开', priority: 150, visible: ['REJECTED', 'VOIDED'].includes(String(row.status)) && canVoid.value, loading: actionBusy.value },
{ key: 'void', label: '作废', tone: 'danger', divided: true, visible: ['DRAFT', 'PENDING_REVIEW'].includes(String(row.status)) && canVoid.value, loading: actionBusy.value },
]
}
@@ -546,7 +548,7 @@ function handleStatementAction(command: string, row: FinanceStatementReadModel)
if (command === 'detail') void openDetail(row)
else {
const actions: Record<string, ReconciliationActionPayload['action']> = {
submit: 'SUBMIT', approve: 'APPROVE', reject: 'REJECT', issue: 'ISSUE', reopen: 'REOPEN', void: 'VOID',
submit: 'SUBMIT', approve: 'APPROVE', reject: 'REJECT', issue: 'ISSUE', void: 'VOID',
}
const action = actions[command]
if (action) void performAction(row, action)
@@ -686,7 +688,7 @@ watch(detailRouteId, (statementId) => {
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!customerError && !error" v-model:page="page" v-model:page-size="pageSize" :data="paginatedRows" :columns="columns" :loading="loading" :total="filteredRows.length" aria-label="客户对账单列表" row-key="id" empty-text="暂无客户对账单" preference-key="finance-customer-statements" @row-click="openDetail">
<template #toolbar-start><h1>客户对账单</h1></template>
<template #filters><QueryToolbar id="statement-query-filters" v-model="filters" ariaLabel="客户对账单筛选" :fields="visibleFields" :available-fields="fields" :loading="loading" preference-key="finance-statements-query" :search-on-clear="false" :search-on-reset="false" @search="load" @reset="resetFilters"><template #actions><el-button :aria-expanded="showAdvancedFilters" aria-controls="statement-query-filters" @click="showAdvancedFilters = !showAdvancedFilters"><SlidersHorizontal :size="16" />{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}</el-button></template></QueryToolbar></template>
<template #notices><template v-if="customerError || error"><LoadErrorState title="客户对账单加载失败" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template></template>
<template #notices><el-alert v-if="actionError" :title="actionError" type="error" :closable="false" role="alert" /><template v-if="customerError || error"><LoadErrorState title="客户对账单加载失败" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template></template>
<template #toolbar-actions>
@@ -345,6 +345,7 @@ watch(() => createForm.value.supplierId, () => {
if (createForm.value.customerId) void refreshCreateDependencies(createForm.value.customerId).catch(reportCreateSourceError)
})
// 待客户确认的对账单生成应收前,提醒操作员确认后再继续。
async function submitCreate() {
if (createBusy.value) return
const customerId = String(createForm.value.customerId || '')
@@ -354,6 +355,14 @@ async function submitCreate() {
createBusy.value = true
error.value = null
try {
const statement = statementOptions.value.find((item) => item.id === createForm.value.reconciliationStatementId)
if (props.kind === 'receivable' && statement?.customerConfirmationStatus === 'PENDING_CONFIRMATION') {
const confirmed = await ElMessageBox.confirm('客户暂未确认!', '生成应收结算单', {
customClass: 'admin-density-confirm admin-workbench', type: 'warning',
confirmButtonText: '继续生成', cancelButtonText: '取消',
}).then(() => true).catch(() => false)
if (!confirmed) return
}
if (props.kind === 'receivable') await createReceivableSettlement({ reconciliationStatementId: createForm.value.reconciliationStatementId, currency: 'CNY' })
else await createPayableSettlement({ customerId, supplierId: createForm.value.supplierId, feeSourceIds: createForm.value.feeSourceIds, dueDate: createForm.value.dueDate, currency: 'CNY' })
createOpen.value = false
@@ -157,6 +157,21 @@ describe('formal settlement native dialogs', () => {
await click(dialog, '确定')
expect(api.createReceivableSettlement).toHaveBeenCalledWith({ reconciliationStatementId: statement.id, currency: 'CNY' })
})
it.each([true, false])('warns before creating receivables from an unconfirmed statement (continue=%s)', async (proceed) => {
vi.mocked(api.listFinanceStatements).mockResolvedValue([{ ...statement, customerConfirmationStatus: 'PENDING_CONFIRMATION' }])
const confirmation = vi.spyOn(ElMessageBox, 'confirm')
if (proceed) confirmation.mockResolvedValue('confirm')
else confirmation.mockRejectedValue('cancel')
const { dialog } = await openCreate('receivable')
const selects = dialog.findAllComponents({ name: 'ElSelect' })
selects[0]!.vm.$emit('update:modelValue', String(customer.id))
await flushPromises()
selects[1]!.vm.$emit('update:modelValue', statement.id)
await click(dialog, '确定')
expect(confirmation).toHaveBeenCalledWith('客户暂未确认!', '生成应收结算单', expect.objectContaining({ type: 'warning' }))
expect(api.createReceivableSettlement).toHaveBeenCalledTimes(proceed ? 1 : 0)
if (!proceed) expect(dialog.props('modelValue')).toBe(true)
})
it('keeps active suppliers and excludes directly paid or locked payable sources', async () => {
const { dialog } = await openCreate('payable')
const selects = dialog.findAllComponents({ name: 'ElSelect' })
@@ -1,5 +1,16 @@
# 海外业务原型核对矩阵
## 2026-11-02 海外自动仓租计算过程
依据:本轮用户确认“退货余一箱仍计一托、沿用现有阶梯”,以及“海外自动计算的仓租展示费用计算过程”。沿用既有费用详情、审核和调价弹框,按 Admin 高密度规范的正文分区与表格规则展示,不改变入口、权限及资金动作。
| 直接消费者 | 操作与现有承载 | 本次实现与核对 | 验收 |
|---|---|---|---|
| OverseasFeesView | 海外费用管理的费用详情弹框 | StorageRentFeeDetails 展示冻结快照的计租基数、原库龄、已计费日、逐档天数×基数×单价、小计及汇率换算 | 定向回归通过;待人工 1920×1080、100% 截图确认 |
| BusinessOrderFees | 退货、废弃、换标等业务单费用详情弹框 | 复用相同计算过程;旧比例费用明确保留原计费口径,缺少依据不猜算 | 定向回归通过;待人工截图确认 |
| StorageRentFeeActions | 业务单仓租审核、调价弹框 | 原计算过程保持可见;调整金额不改写原快照 | 定向回归通过;待人工截图确认 |
| StorageRentPanel | 仓租库存的原批次详情弹框 | 出库计租基数优先采用费用快照,新整托费用不再展示件数分摊的零点几托 | 定向回归通过;待人工截图确认 |
采购样品履约的当前操作与页面承载以[采购订单样品履约流程变更需求第 10.6 节](../../../../../docs/海外仓业务/采购订单样品履约流程变更需求.md#106-平台页面实施核对矩阵)为准。下表采购旧五分区和通用编辑仅保留原型定位;当前详情使用履约聚合数据,独立编辑只维护内部备注,节点写入使用各自权限和命令。
本土散板列表、本土卡车新增/编辑共用`../overseas-master-data/index.ts`公开的`TransportRouteDialog`。字段来源、原型证据和缺值处理唯一见[运输路线地图](../overseas-master-data/PROTOTYPE_MATRIX.md#运输路线地图)。按已有起终点规划,不写订单、不使用固定演示、不采集GPS。
@@ -2,8 +2,15 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export interface FeePage<T> { records: T[]; page: number; size: number; total: number; pages: number }
export type OverseasFeeDirection = 'RECEIVABLE' | 'PAYABLE'
export interface StorageRentCalculation {
sourceBusinessNo: string | null; batchNo: string | null; ruleCode: 'RETURN_PALLET' | 'RETURN_SHARE' | 'TRANSIT'
billingQuantity: number; billingUnit: 'PALLET' | 'CBM'; storageDays: number; previouslyChargedDays: number
tiers: Array<{ startDay: number; endDay: number; days: number; unitPrice: number; amount: number }>
calculatedAmount: number
}
export interface OverseasFee {
storageRent?: { outboundNo: string; outboundAt: string; calculatedAmount: number; chargedAmount: number | null;
calculations?: StorageRentCalculation[]
lines: Array<{ lotId: string; inboundAt: string; quantity: number; days: number; calculatedAmount: number;
sourceBusinessType?: string | null; sourceBusinessNo?: string | null; batchNo?: string | null }> } | null
id: string; sourceFeeId: string; sourceVersion: number; customerId: string; customerName: string
@@ -15,6 +15,7 @@ export interface StorageRentLot {
settledAmount?: number; chargeCount?: number; settledChargeCount?: number
}
export interface StorageRentCharge {
billingQuantity?: number | null; billingUnit?: string | null
id: string; outboundNo: string; businessNo: string; quantity: number; storageDays: number
originalAmount: number; currency: string; settlementAmount: number; currencyRate: number; status: string
createdAt: string; outboundAt: string
@@ -188,17 +188,17 @@ describe('单据费用操作', () => {
await flushPromises(); await click(dialog, '保存')
expect(api.createOverseasFee).not.toHaveBeenCalled()
})
it('blocks automatic storage rent from manual addition', async () => {
it('allows manual storage rent using the normal fee submission', async () => {
vi.mocked(api.listOverseasFeeItems).mockResolvedValue([{ feeCode: 'FEE_TYPE_077', feeTypeName: '仓储费', defaultPrice: null }])
const wrapper = await mountPage()
await click(wrapper, '新增费用')
const dialog = activeDialog(wrapper)
const select = dialog.findAllComponents({ name: 'ElSelect' })[0]!
expect(select.findAllComponents({ name: 'ElOption' })).toHaveLength(0)
expect(select.findAllComponents({ name: 'ElOption' })).toHaveLength(1)
select.vm.$emit('update:modelValue', 'FEE_TYPE_077')
dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 10)
await flushPromises(); await click(dialog, '保存')
expect(api.createOverseasFee).not.toHaveBeenCalled()
expect(api.createOverseasFee).toHaveBeenCalledWith(expect.objectContaining({ feeItemCode: 'FEE_TYPE_077', amount: 10 }), expect.any(String))
})
it('includes verified related orders but rejects another customer and keeps adjustment on the original order', async () => {
const related = { ...business, businessId: 'return-1', businessType: 'RETURN_ORDER' }
@@ -32,8 +32,7 @@ let optionSequence = 0
let savedFingerprint = '', requestKey = ''
let openedBusinessKey = ''
const businessKey = () => props.business ? `${props.business.businessType}:${props.business.businessId}:${props.business.customerId}` : ''
const automaticRentCode = 'FEE_TYPE_077'
const manualItems = computed(() => props.items.filter(item => item.feeCode !== automaticRentCode))
const manualItems = computed(() => props.items)
const hasExistingItem = computed(() => !!props.fee && manualItems.value.some(item => item.feeCode === props.fee?.feeItemCode))
async function loadOptions(keyword = '') {
if (props.business) return
@@ -90,7 +89,6 @@ const sourceTypes: Record<string, string> = {
const selectableTypes = Object.keys(sourceTypes)
async function save() {
if (!access.can(feePermissions.edit) || saving.value || optionsLoading.value || !await formRef.value?.validate().catch(() => false)) return
if (form.feeItemCode === automaticRentCode) { ElMessage.error('仓租自动计算,请勿手动添加'); return }
const order = selected.value
if (!order) { ElMessage.error('请重新选择业务订单'); return }
if (existingLoading.value || existingError.value) { ElMessage.warning(existingError.value || '正在核对已有费用'); return }
@@ -132,7 +130,7 @@ async function save() {
<div class="fee-editor-grid">
<el-form-item v-if="!business" label="业务类型" prop="businessType"><el-select v-model="form.businessType" :disabled="!!fee" @change="changeBusinessType"><el-option v-for="type in selectableTypes" :key="type" :value="type" :label="businessLabels[type]" /></el-select></el-form-item>
<el-form-item v-if="!business" label="业务订单" prop="businessId"><el-select v-model="form.businessId" filterable remote :remote-method="loadOptions" :loading="optionsLoading" :disabled="!!fee" placeholder="搜索订单号"><el-option v-for="row in options" :key="row.businessId" :value="row.businessId" :label="`${row.businessNo} · ${row.customerName}`" /></el-select></el-form-item>
<el-form-item label="费用项目" prop="feeItemCode"><el-select v-model="form.feeItemCode" filterable><el-option v-if="fee && fee.feeItemCode !== automaticRentCode && !hasExistingItem" :value="fee.feeItemCode" :label="fee.feeItemName" /><el-option v-for="item in manualItems" :key="item.feeCode" :value="item.feeCode" :label="item.feeTypeName" /></el-select></el-form-item>
<el-form-item label="费用项目" prop="feeItemCode"><el-select v-model="form.feeItemCode" filterable><el-option v-if="fee && !hasExistingItem" :value="fee.feeItemCode" :label="fee.feeItemName" /><el-option v-for="item in manualItems" :key="item.feeCode" :value="item.feeCode" :label="item.feeTypeName" /></el-select></el-form-item>
<el-form-item label="币种"><el-select v-model="form.currency"><el-option label="美元 USD" value="USD" /><el-option label="人民币 CNY" value="CNY" /></el-select></el-form-item>
<el-form-item label="金额" prop="amount"><el-input-number v-model="form.amount" :min="0" :max="999999999.99" :precision="2" :controls="false" /></el-form-item>
<el-form-item v-if="(props.fee?.direction || 'RECEIVABLE') === 'RECEIVABLE'" label="结算方式"><el-select v-model="form.settlementMode" clearable placeholder="按业务默认规则"><el-option label="推送结算(账期)" value="BILL" /><el-option label="余额扣费" value="WALLET" /></el-select></el-form-item>
@@ -0,0 +1,52 @@
import { createRequire } from 'node:module'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { OverseasFee, StorageRentCalculation } from '../api/overseasFees'
import StorageRentFeeDetails from './StorageRentFeeDetails.vue'
const { default: ElementPlus } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus')
enableAutoUnmount(afterEach)
beforeEach(() => vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }))
afterEach(() => vi.unstubAllGlobals())
const calculation: StorageRentCalculation = {
sourceBusinessNo: 'TH202611020001', batchNo: 'RB1', ruleCode: 'RETURN_PALLET', billingQuantity: 1, billingUnit: 'PALLET',
storageDays: 40, previouslyChargedDays: 10, calculatedAmount: 50,
tiers: [{ startDay: 11, endDay: 30, days: 20, unitPrice: 1.5, amount: 30 }, { startDay: 31, endDay: 40, days: 10, unitPrice: 2, amount: 20 }],
}
function fee(calculations?: StorageRentCalculation[]) {
return { originalCurrency: 'USD', currency: 'CNY', originalAmount: 50, amount: 500, exchangeRate: 10,
storageRent: { outboundNo: 'OUT1', outboundAt: '2026-11-02T12:00:00Z', calculatedAmount: 50, chargedAmount: null,
lines: [{ lotId: '1', inboundAt: '2026-09-24T12:00:00Z', quantity: 1, days: 40, calculatedAmount: 50 }], calculations },
} as OverseasFee
}
it('explains whole pallet tiers and skips previously charged days in every fee detail consumer', async () => {
const wrapper = mount(StorageRentFeeDetails, { props: { fee: fee([calculation]) }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('余一箱仍计一托')
expect(wrapper.text()).toContain('此前已计费10 天')
expect(wrapper.text()).toContain('20 天 × 1 托 × USD 1.5 = USD 30')
expect(wrapper.text()).toContain('10 天 × 1 托 × USD 2 = USD 20')
expect(wrapper.text()).toContain('USD 50.00 × 10 = CNY 500.00')
expect(wrapper.text()).toContain('本批次计算金额:USD 50')
})
it('shows transit volume and free tier calculations with the saved currency', async () => {
const transit: StorageRentCalculation = { ...calculation, ruleCode: 'TRANSIT', billingUnit: 'CBM', billingQuantity: 0.8,
storageDays: 10, previouslyChargedDays: 0, calculatedAmount: 1.92,
tiers: [{ startDay: 1, endDay: 7, days: 7, unitPrice: 0, amount: 0 }, { startDay: 8, endDay: 10, days: 3, unitPrice: 0.8, amount: 1.92 }] }
const wrapper = mount(StorageRentFeeDetails, { props: { fee: fee([transit]) }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('7 天 × 0.8 CBM × USD 0 = USD 0')
expect(wrapper.text()).toContain('3 天 × 0.8 CBM × USD 0.8 = USD 1.92')
})
it('distinguishes historical fractional pricing from new whole pallet pricing', async () => {
const historical: StorageRentCalculation = { ...calculation, ruleCode: 'RETURN_SHARE', billingQuantity: 0.5625,
storageDays: 1, previouslyChargedDays: 0, calculatedAmount: 0.84375,
tiers: [{ startDay: 1, endDay: 1, days: 1, unitPrice: 1.5, amount: 0.84375 }] }
const wrapper = mount(StorageRentFeeDetails, { props: { fee: fee([historical]) }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('历史费用:按原托份额计费')
expect(wrapper.text()).toContain('1 天 × 0.5625 托 × USD 1.5 = USD 0.84375')
await wrapper.setProps({ fee: fee([]) })
expect(wrapper.text()).toContain('未保存完整计算依据')
expect(wrapper.text()).not.toContain('1 天 × 0.5625')
})
@@ -4,6 +4,8 @@ import type { OverseasFee } from '../api/overseasFees'
import { feeMoney } from '../overseasFeePresentation'
import { storageRentSourceType, storageRentTime } from '../storageRentPresentation'
defineProps<{ fee: OverseasFee }>()
const number = (value: number) => new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 12 }).format(Number(value))
const unit = (value: string) => value === 'PALLET' ? '托' : 'CBM'
</script>
<template>
<section v-if="fee.storageRent" aria-label="仓租计费明细">
@@ -13,6 +15,7 @@ defineProps<{ fee: OverseasFee }>()
<el-descriptions-item label="原计算金额">{{ feeMoney(fee.storageRent.calculatedAmount, fee.originalCurrency) }}</el-descriptions-item>
<el-descriptions-item label="应扣金额">{{ feeMoney(fee.amount, fee.currency) }}</el-descriptions-item>
<el-descriptions-item label="实扣金额">{{ feeMoney(fee.storageRent.chargedAmount, fee.currency) }}</el-descriptions-item>
<el-descriptions-item v-if="fee.exchangeRate != null" label="结算换算">{{ feeMoney(fee.originalAmount, fee.originalCurrency) }} × {{ number(fee.exchangeRate) }} = {{ feeMoney(fee.amount, fee.currency) }}</el-descriptions-item>
</el-descriptions>
<el-table :data="fee.storageRent.lines" border empty-text="暂无计租批次">
<el-table-column label="来源类型" min-width="130"><template #default="{ row }">{{ storageRentSourceType(row.sourceBusinessType) }}</template></el-table-column>
@@ -20,8 +23,29 @@ defineProps<{ fee: OverseasFee }>()
<el-table-column label="入库批次" min-width="160"><template #default="{ row }">{{ row.batchNo || '—' }}</template></el-table-column>
<el-table-column label="入库时间" min-width="170"><template #default="{ row }">{{ storageRentTime(row.inboundAt) }}</template></el-table-column>
<el-table-column prop="quantity" label="本次出库数量" width="130" />
<el-table-column prop="days" label="计租天数" width="100" />
<el-table-column prop="days" label="库龄天数" width="100" />
<el-table-column label="原计算仓租" min-width="130"><template #default="{ row }">{{ feeMoney(row.calculatedAmount, fee.originalCurrency) }}</template></el-table-column>
</el-table>
<h4>仓租计算过程</h4>
<template v-if="fee.storageRent.calculations?.length">
<div v-for="(calculation, index) in fee.storageRent.calculations" :key="index">
<el-descriptions :column="2" border class="density-descriptions overseas-fee-descriptions">
<el-descriptions-item label="来源单号">{{ calculation.sourceBusinessNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="入库批次">{{ calculation.batchNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="计费口径">{{ calculation.ruleCode === 'RETURN_PALLET' ? '按原托整托计费,余一箱仍计一托' : calculation.ruleCode === 'RETURN_SHARE' ? '历史费用:按原托份额计费' : '按本次出库体积计费' }}</el-descriptions-item>
<el-descriptions-item label="计租数量">{{ number(calculation.billingQuantity) }} {{ unit(calculation.billingUnit) }}</el-descriptions-item>
<el-descriptions-item label="累计库龄">{{ calculation.storageDays }} 天</el-descriptions-item>
<el-descriptions-item label="此前已计费">{{ calculation.previouslyChargedDays }} 天</el-descriptions-item>
</el-descriptions>
<el-table :data="calculation.tiers" border empty-text="本计租日已计费,本次不重复收费" aria-label="仓租阶梯计算">
<el-table-column label="计费区间" min-width="130"><template #default="{ row }">第 {{ row.startDay }}–{{ row.endDay }} 天</template></el-table-column>
<el-table-column label="单价" min-width="160"><template #default="{ row }">{{ fee.originalCurrency }} {{ number(row.unitPrice) }}/{{ unit(calculation.billingUnit) }}/天</template></el-table-column>
<el-table-column label="计算过程" min-width="300"><template #default="{ row }">{{ row.days }} 天 × {{ number(calculation.billingQuantity) }} {{ unit(calculation.billingUnit) }} × {{ fee.originalCurrency }} {{ number(row.unitPrice) }} = {{ fee.originalCurrency }} {{ number(row.amount) }}</template></el-table-column>
</el-table>
<p>本批次计算金额:{{ fee.originalCurrency }} {{ number(calculation.calculatedAmount) }}</p>
</div>
<p>各阶梯按原始精度累加,仓租合计四舍五入至 2 位小数;计价采用生成费用时保存的报价。</p>
</template>
<p v-else>该笔费用未保存完整计算依据,暂无法展示计算过程。</p>
</section>
</template>
@@ -28,6 +28,19 @@ beforeEach(() => {
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' })
describe('仓租列表服务端契约', () => {
it('uses the whole pallet billing basis from the charge snapshot when only one box leaves', async () => {
vi.mocked(api.getStorageRentLot).mockResolvedValue({ lot: { ...lot, businessType: 'RETURN_ORDER', billingUnit: 'PALLET', originalQuantity: 16 },
charges: [{ id: '3', outboundNo: 'OUT1', businessNo: 'CD1', quantity: 1, storageDays: 1, originalAmount: 1.5,
currency: 'USD', settlementAmount: 15, currencyRate: 10, status: 'PENDING_REVIEW', createdAt: '', outboundAt: '', billingQuantity: 1, billingUnit: 'PALLET' }] })
const wrapper = mount(StorageRentPanel, { attachTo: document.body, props: { customers: [] }, global: { plugins: [ElementPlus] } })
await flushPromises()
await wrapper.findAll('.el-table__body-wrapper button').find(button => button.text().trim() === '详情')!.trigger('click')
await flushPromises()
const dialog = document.body.querySelector('[role="dialog"]')!
expect(dialog.textContent).toContain('本次计租基数')
expect(dialog.textContent).toContain('1 托')
expect(dialog.textContent).not.toContain('0.0625 托')
})
it('shows storage age and remaining quantity and traces each half pallet without financial actions', async () => {
const returned = { ...lot, businessType: 'RETURN_ORDER', businessNo: 'TH1', quantityUnit: 'PIECE', originalQuantity: 6, remainingQuantity: 2, billingQuantity: 1.5, billingUnit: 'PALLET', status: 'IN_STOCK', generatedAmounts: [{ currency: 'USD', amount: 1.5 }], settledAmount: 10, chargeCount: 2, settledChargeCount: 2 }
vi.mocked(api.listStorageRentLots).mockResolvedValue({ records: [returned], total: 1, page: 1, size: 20, pages: 1 })
@@ -39,7 +39,7 @@ const chargeColumns = computed<DataTableColumn[]>(() => [
{ prop: 'outboundNo', label: '出库批次', minWidth: 170 },
{ prop: 'businessNo', label: '出库业务单号', minWidth: 170 },
{ prop: 'quantity', label: '出库数量', width: 105, align: 'right', formatter: (row: { quantity: number }) => `${row.quantity} ${unitLabels[detail.value?.lot.quantityUnit || ''] || ''}`.trim() }, { prop: 'storageDays', label: '计租天数', width: 100, align: 'right' },
{ key: 'billingShare', label: '本次计租份额', width: 120, formatter: (row: StorageRentCharge) => detail.value ? storageRentBillingShare(row.quantity, detail.value.lot) : '—' },
{ key: 'billingShare', label: '本次计租基数', width: 120, formatter: (row: StorageRentCharge) => detail.value ? storageRentBillingShare(row.quantity, detail.value.lot, row) : '—' },
{ prop: 'outboundAt', label: '出库时间', minWidth: 165, formatter: (row: { outboundAt: string }) => storageRentTime(row.outboundAt) },
])
let sequence = 0
@@ -3,7 +3,6 @@ import '@/styles/adminWorkbench.css'
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -107,7 +106,7 @@ async function submit() {
<template v-if="active">
<p v-if="active !== 'review' && active !== 'arrange'">{{ detail.orderNo }} · {{ truckCommandHints[active] }}</p>
<el-alert v-if="validationError" :title="validationError" type="warning" :closable="false" role="alert" />
<div v-if="error" role="alert"><el-alert :title="error.message" type="error" :closable="false" /><ApiErrorMeta :details="error" /><p>可修改后重新提交;如提示版本冲突,请关闭弹框并刷新订单,核对最新状态及操作记录。</p></div>
<el-alert v-if="error" :title="error.message" type="error" :closable="false" role="alert" />
<el-form :model="form" label-width="130px" :disabled="saving || !!pending" @submit.prevent="submit">
<el-form-item v-if="review" label="审核结果"><el-radio-group v-model="form.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">拒绝</el-radio></el-radio-group></el-form-item>
<el-form-item v-if="active === 'arrange' || (active === 'review' && form.decision === 'APPROVE')" label="调度仓库" required><el-select v-model="form.warehouseId" placeholder="" filterable><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select></el-form-item>
@@ -407,7 +407,7 @@ describe('overseas business mounted pages', () => {
? ['B点取货地', 'C点送达地', 'B点联系人', 'C点联系人', 'B点联系电话', 'C点联系电话', 'B点取货地址', 'C点送达地址']
: ['起运地', '目的地', '起运地联系人', '目的地联系人', '起运地联系电话', '目的地联系电话', '起运详细地址', '目的详细地址']
expect(labels.slice(0, domain === 'truck' ? 10 : 9)).toEqual(['客户', ...(domain === 'truck' ? ['车辆类型'] : []), ...routeLabels])
Object.assign(model, { vehicleType: 'PALLET_15', estimatedPalletCount: domain === 'truck' ? 2 : 0, origin: ' LA ', destination: ' ONT ', originContactName: ' 发货人 ', originContactPhone: ' 1 ', destinationContactName: ' 收货人 ', destinationContactPhone: ' 2 ', pickupAt: '2026-10-20T09:30:45', deliveryAt: '2026-10-20T15:45:12', budgetAmount: domain === 'truck' ? 100 : 0, currency: 'CNY' })
Object.assign(model, { vehicleType: 'PALLET_15', estimatedPalletCount: domain === 'truck' ? 2 : 0, origin: ' LA ', destination: ' ONT ', originAddress: ' Pickup street ', destinationAddress: ' Delivery street ', originContactName: ' 发货人 ', originContactPhone: ' 1 ', destinationContactName: ' 收货人 ', destinationContactPhone: ' 2 ', pickupAt: '2026-10-20T09:30:45', deliveryAt: '2026-10-20T15:45:12', budgetAmount: domain === 'truck' ? 100 : 0, currency: 'CNY' })
const submit = wrapper.findAll('button').find(button => button.text() === '提交订单')!
await submit.trigger('click'); await flushPromises()
expect(createUsTruckOrder).not.toHaveBeenCalled(); expect(createLocalLooseboardOrder).not.toHaveBeenCalled()
@@ -438,7 +438,7 @@ describe('overseas business mounted pages', () => {
it('loads and submits pending-review truck edit', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002'); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, vehicleType: 'PALLET_15', originContactName: 'Bob', destinationContactName: 'Alice', pickupAt: '2026-08-28T09:30:00' })) })
it('keeps truck edit form and version unchanged after a 409', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; vi.mocked(updateUsTruckOrder).mockRejectedValue(new ApiResponseError('订单版本冲突', 'B0409', 409)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await destination.setValue('Ontario-MARKER-EDIT'); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, destination: 'Ontario-MARKER-EDIT' })); expect((destination.element as HTMLInputElement).value).toBe('Ontario-MARKER-EDIT'); expect(document.body.textContent).toContain('订单版本冲突'); expect(routing.push).not.toHaveBeenCalled() })
it('mounts truck create with explicit customer selector and enabled submit', async () => { const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200, status: 'ACTIVE' }); expect(wrapper.text()).toContain('客户信息'); expect(wrapper.text()).toContain('请选择客户'); expect(wrapper.findAll('button').find((button) => button.text().includes('提交订单'))?.attributes('disabled')).toBeUndefined() })
it('reuses one idempotency key and preserves truck create input after retries fail', async () => { vi.mocked(createUsTruckOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); selects[1].vm.$emit('update:modelValue', 'PALLET_30'); const model = wrapper.findComponent({ name: 'ElForm' }).props('model'); Object.assign(model, { estimatedPalletCount: 2, budgetAmount: 100 }); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); for (const label of ['B点联系人', 'C点联系人', 'B点联系电话', 'C点联系电话']) await wrapper.findAll('.el-form-item').find(item => item.text().startsWith(label))!.find('input').setValue(label.includes('电话') ? '+1-555-0100' : '联系人'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('100'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('2.5'); wrapper.findAllComponents({ name: 'ElDatePicker' })[0].vm.$emit('update:modelValue', '2026-08-31T09:30:00'); wrapper.findAllComponents({ name: 'ElDatePicker' })[1].vm.$emit('update:modelValue', '2026-08-31T15:45:00'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createUsTruckOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createUsTruckOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(document.body.textContent).toContain('服务暂时不可用'); expect(routing.push).not.toHaveBeenCalled() })
it('reuses one idempotency key and preserves truck create input after retries fail', async () => { vi.mocked(createUsTruckOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); selects[1].vm.$emit('update:modelValue', 'PALLET_30'); const model = wrapper.findComponent({ name: 'ElForm' }).props('model'); Object.assign(model, { estimatedPalletCount: 2, budgetAmount: 100, originAddress: 'Pickup street', destinationAddress: 'Delivery street' }); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); for (const label of ['B点联系人', 'C点联系人', 'B点联系电话', 'C点联系电话']) await wrapper.findAll('.el-form-item').find(item => item.text().startsWith(label))!.find('input').setValue(label.includes('电话') ? '+1-555-0100' : '联系人'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('100'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('2.5'); wrapper.findAllComponents({ name: 'ElDatePicker' })[0].vm.$emit('update:modelValue', '2026-08-31T09:30:00'); wrapper.findAllComponents({ name: 'ElDatePicker' })[1].vm.$emit('update:modelValue', '2026-08-31T15:45:00'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createUsTruckOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createUsTruckOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(document.body.textContent).toContain('服务暂时不可用'); expect(routing.push).not.toHaveBeenCalled() })
it('loads the real looseboard list', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'looseboard' }); await flushPromises(); expect(listLocalLooseboardOrders).toHaveBeenCalledWith({ page: 1, size: 20, orderNo: '', routeKeyword: '', status: '' }); expect(wrapper.text()).toContain('LB-300'); expect(wrapper.text()).toContain('待接单'); expect(wrapper.text()).toContain('2026-08-01 10:00'); expect(listUsTruckOrders).not.toHaveBeenCalled() })
it('loads and submits pending-acceptance looseboard edit with PUT adapter', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003'); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, estimatedPalletCount: 2 })) })
it('requires split contacts and both local times, rejecting reversed times', async () => {
@@ -83,7 +83,7 @@ describe('truck review and fulfillment', () => {
expect(header.find('.status-badge').text()).toBe('待审核')
expect(wrapper.findAll('.status-badge')).toHaveLength(1)
expect(header.find('.truck-order-actions').text()).toContain('审核')
expect(header.find('.truck-order-actions').text()).toContain('取消订单')
expect(header.find('.truck-order-actions').text()).not.toContain('取消订单')
await click(wrapper, '刷新订单')
expect(getUsTruckOrder).toHaveBeenCalledTimes(2)
await click(wrapper, '返回列表')
@@ -126,6 +126,9 @@ describe('truck review and fulfillment', () => {
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核并生成排车')
formModel(wrapper).remark = '核对完成'; await confirm(wrapper)
expect(wrapper.text()).toContain('订单版本冲突')
expect(wrapper.text()).not.toContain('TRUCK-409')
expect(wrapper.text()).not.toContain('请求 ID')
expect(wrapper.text()).not.toContain('可修改后重新提交')
expect(wrapper.findComponent({ name: 'ElForm' }).props('disabled')).toBe(true)
const original = JSON.parse(JSON.stringify(vi.mocked(executeUsTruckCommand).mock.calls[0][2]))
wrapper.findComponent(TruckOrderActions).findComponent(AppDialog).vm.$emit('update:modelValue', false); await flushPromises()
@@ -160,11 +163,10 @@ describe('truck review and fulfillment', () => {
expect(wrapper.text()).not.toContain('登记实收'); expect(wrapper.text()).not.toContain('分配托盘'); expect(wrapper.text()).not.toContain('超托审核')
})
it('requires cancellation reason and submits only the cancellation status', async () => {
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '取消订单'); await confirm(wrapper)
it('does not offer platform cancellation of a customer truck order', async () => {
const wrapper = mountDetail(); await flushPromises()
expect(wrapper.text()).not.toContain('取消订单')
expect(executeUsTruckCommand).not.toHaveBeenCalled()
formModel(wrapper).remark = '客户取消需求'; vi.mocked(executeUsTruckCommand).mockResolvedValue(order({ status: 'CANCELLED', version: 2 })); await confirm(wrapper)
expect(executeUsTruckCommand).toHaveBeenCalledWith('TR-TEST', '9007199254741999', expect.objectContaining({ command: 'cancel', payload: expect.objectContaining({ status: 'CANCELLED', remark: '客户取消需求' }) }))
})
it('keeps list-only users read-only and guards direct attempts to emit submit', async () => {
@@ -9,6 +9,7 @@ it('distinguishes missing charges, genuine free rent and incomplete settlement',
expect(storageRentSummaryStatus({ ...lot, settledAmount: 0 })).toBe('免租 · 已结算')
expect(storageRentSummaryStatus({ ...lot, chargeCount: 0 })).toBe('仓租记录待核对')
expect(storageRentBillingShare(2, lot)).toBe('0.5 托')
expect(storageRentBillingShare(1, lot, { billingQuantity: 1, billingUnit: 'PALLET' })).toBe('1 托')
})
describe('仓租原币汇总显示', () => {
@@ -1,5 +1,5 @@
import { feeMoney } from './overseasFeePresentation'
import type { StorageRentLot } from './api/storageRent'
import type { StorageRentLot, StorageRentCharge } from './api/storageRent'
export function storageRentSourceType(type?: string | null) {
const labels: Record<string, string> = { LASTLEG_ORDER: '头程尾程订单', WAREHOUSE_TRANSFER: '仓储转运单', RETURN_ORDER: '退货单' }
@@ -24,7 +24,8 @@ export function storageRentSummaryStatus(lot: StorageRentLot) {
return Number(lot.settledAmount) === 0 ? '免租 · 已结算' : '已扣款'
}
export function storageRentBillingShare(quantity: number, lot: StorageRentLot) {
export function storageRentBillingShare(quantity: number, lot: StorageRentLot, charge?: Pick<StorageRentCharge, 'billingQuantity' | 'billingUnit'>) {
if (charge?.billingQuantity != null && charge.billingUnit) return `${charge.billingQuantity} ${charge.billingUnit === 'PALLET' ? '托' : charge.billingUnit}`
if (!(Number(lot.originalQuantity) > 0) || lot.billingQuantity == null) return '—'
const units = Number(quantity) * Number(lot.billingQuantity) / Number(lot.originalQuantity)
return `${Number(units.toFixed(6))} ${lot.billingUnit === 'PALLET' ? '托' : lot.billingUnit}`
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { createTransportDemandForm, transportDemandError, transportDemandRules, buildTransportDemand } from './transportDemandForm'
import { truckStatusLabels } from './truckWorkflow'
describe('truck order validation and state filters', () => {
it('requires both detailed addresses before submitting a truck order', () => {
const form = Object.assign(createTransportDemandForm(), {
vehicleType: 'PALLET_15' as const, origin: 'LA', destination: 'ONT',
originContactName: '发货人', originContactPhone: '1', destinationContactName: '收货人', destinationContactPhone: '2',
pickupAt: '2026-11-02T12:00:00', deliveryAt: '2026-11-03T12:00:00', estimatedPalletCount: 1, budgetAmount: 100,
})
expect(transportDemandRules('truck', form)).toMatchObject({ originAddress: [{ required: true }], destinationAddress: [{ required: true }] })
expect(transportDemandError('truck', form)).toBe('请输入取货地址')
form.originAddress = ' 取货街道1号 '
expect(transportDemandError('truck', form)).toBe('请输入送达地址')
form.destinationAddress = ' '
expect(() => buildTransportDemand('truck', form)).toThrow('请输入送达地址')
form.destinationAddress = ' 送达街道2号 '
expect(buildTransportDemand('truck', form)).toMatchObject({ originAddress: '取货街道1号', destinationAddress: '送达街道2号' })
})
it('assigns each filter a distinct Chinese state label', () => {
const labels = Object.values(truckStatusLabels)
expect(new Set(labels).size).toBe(labels.length)
expect(truckStatusLabels.PENDING_DISPATCH).toBe('待排车')
expect(truckStatusLabels.DISPATCHED).toBe('待取货')
expect(labels.every(label => /[\u4e00-\u9fff]/.test(label))).toBe(true)
})
})
@@ -42,6 +42,7 @@ export function createTransportDemandForm(): TransportDemandForm {
}
const requiredText = (kind: TransportDemandKind, form: TransportDemandForm) => ({
...(kind === 'truck' ? { originAddress: '取货地址', destinationAddress: '送达地址' } : {}),
...(kind === 'truck' || form.deliveryMode === 'WAREHOUSE_PICKUP' ? { origin: kind === 'truck' ? '取货地' : '起运地', originContactName: kind === 'truck' ? '取货联系人' : '起运地联系人', originContactPhone: kind === 'truck' ? '取货联系电话' : '起运地联系电话' } : {}),
destination: kind === 'truck' ? '送达地' : '目的地',
destinationContactName: kind === 'truck' ? '送达联系人' : '目的地联系人',
@@ -1,9 +1,9 @@
import type { LocalTransportDetail, LocalTransportStatus } from './types'
export const truckStatusLabels: Record<LocalTransportStatus, string> = {
FUNDING_PENDING: '订单受理中', FUNDING_FAILED: '订单处理失败', PENDING_REVIEW: '待审核', WAITING_INBOUND: '待取货', RECEIVING: '待取货', WAITING_CONSOLIDATION: '待取货',
PALLET_OVERAGE_REVIEW: '待取货', PARTIALLY_ALLOCATED: '待取货', ALLOCATED: '待取货',
PENDING_DISPATCH: '待取货', DISPATCHED: '待取货', IN_TRANSIT: '派送中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消',
FUNDING_PENDING: '订单受理中', FUNDING_FAILED: '订单处理失败', PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托',
PALLET_OVERAGE_REVIEW: '超托待审核', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配',
PENDING_DISPATCH: '待排车', DISPATCHED: '待取货', IN_TRANSIT: '派送中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消',
}
export const truckCommandLabels = {
review: '审核并生成排车', arrange: '补建排车单', 'receive-pallets': '登记实收', 'review-pallet-overage': '超托审核', 'allocate-pallets': '分配托盘',
@@ -23,8 +23,8 @@ export type TruckOperation =
/** 拼车来源只读;原单只保留审核入口,排车后的执行动作统一在排车任务完成。 */
export function availableTruckCommands(order: Pick<LocalTransportDetail, 'status' | 'fleetArrangement'>): TruckCommand[] {
if (order.fleetArrangement) return []
if (order.status === 'PENDING_REVIEW') return ['review', 'cancel']
return order.status === 'PENDING_DISPATCH' ? ['arrange', 'cancel'] : []
if (order.status === 'PENDING_REVIEW') return ['review']
return order.status === 'PENDING_DISPATCH' ? ['arrange'] : []
}
export const truckCommandHints: Record<TruckCommand, string> = {
@@ -2,7 +2,7 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
export interface CargoDiscardQuery { page: number; size: number; customerId?: string; discardNo?: string; inboundCode?: string; overseasWarehouseId?: string; status?: string }
export interface CargoDiscardSummary { skuCodes?: string[]; skuCount?: number; requestedPackages?: number; actualPackages?: number | null; id: string; customerId?: string | null; discardNo: string; outboundNo?: string | null; inboundCodes?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; totalPackages?: number | null; totalFee?: number | string | null; currency?: string | null; status?: string | null; createdBy?: string | null; createdAt?: string | null; reviewedAt?: string | null }
export interface CargoDiscardSummary { skuCodes?: string[]; skuCount?: number; requestedPackages?: number; actualPackages?: number | null; id: string; customerId?: string | null; discardNo: string; outboundNo?: string | null; inboundCodes?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; totalPackages?: number | null; totalFee?: number | string | null; currency?: string | null; status?: string | null; createdBy?: string | null; createdByName?: string | null; createdAt?: string | null; reviewedAt?: string | null }
export interface CargoDiscardItem { actualCartonMarks?: string[]; palletNo?: string | null; quantityUnit?: string | null; actualDiscardPackages?: number | null; pdaStatus?: string | null; cartonMarks?: string[]; id: string; orderId?: string | null; inboundCode?: string | null; skuCode?: string | null; productName?: string | null; availablePackagesSnapshot?: number | null; discardPackages?: number | null }
export interface CargoDiscardStatistics { orderCount: number; skuCount: number; requestedPackages: number; actualPackages: number | null; requestedPieces: number | null; actualPieces: number | null; palletCount: number | null }
export async function getCargoDiscardStatistics(query: CargoDiscardQuery) {
@@ -134,7 +134,8 @@ it('retains edited values and the original validation response when the action i
expect(state.form.remark).toBe('用户未保存的本轮备注')
expect(state.detail.recordVersion).toBe(0)
expect(wrapper.text()).toContain('预计到达时间须在发车之后')
expect(wrapper.text()).toContain('A0400')
expect(wrapper.text()).not.toContain('A0400')
expect(wrapper.text()).not.toContain('validation-request')
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
await state.retryError()
@@ -150,7 +151,7 @@ it('allows another click after rejection even when a later read would fail', asy
await state.submit(); await flushPromises()
expect(wrapper.emitted('rejected')).toEqual([[expect.objectContaining({ code: 'A0409', requestId: 'reject-request' })]])
expect(useDispatchCommandRecovery(() => identity.id).pending.value).toBeNull()
expect(wrapper.text()).toContain('A0409')
expect(wrapper.text()).not.toContain('A0409')
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
expect(state.form.remark).toBe('保留本次备注')
await state.submit()
@@ -10,7 +10,6 @@ import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import * as api from '../api/dispatches'
import { getTransport } from '../api/transportTasks'
@@ -254,7 +253,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
<div class="density-dialog-sections">
<el-alert v-if="cargoAction" :title="cargoEntryMessage" type="warning" :closable="false" show-icon />
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理,请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
<LoadErrorState v-if="error && !cargoAction" title="排车操作未完成" :message="error.message" :loading="loading || busy || uploadingEvidence" retry-text="刷新当前资料" @retry="retryError"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<LoadErrorState v-if="error && !cargoAction" title="排车操作未完成" :message="error.message" :loading="loading || busy || uploadingEvidence" retry-text="刷新当前资料" @retry="retryError"></LoadErrorState>
<el-alert v-if="detail && !allowed && !pending" :title="detail?.actions[currentAction]?.blockReason || '当前操作未获得允许,请核对权限与作业状态'" type="warning" :closable="false" show-icon />
<el-form v-if="detail && !cargoAction" class="density-form density-dialog-sections" label-position="left" label-width="130px" :disabled="frozen || loading || uploadingEvidence || !allowed">
<section v-if="currentOperation === 'ROUTE' && route" class="density-dialog-section" data-tone="source" aria-label="本车卸货顺序"><h3 class="density-dialog-section__title">卸货顺序</h3>
@@ -323,7 +322,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
<el-form-item v-if="currentAction === 'CANCEL' && currentOperation !== 'ROUTE'" label="取消原因" label-position="top" required>
<el-input v-model="form.reason" aria-label="取消原因" type="textarea" :rows="4" placeholder="请填写取消原因" />
</el-form-item>
<section v-else-if="currentOperation === 'ROUTE' || ['RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" class="density-dialog-section" :data-tone="currentAction === 'DISPATCH_REVIEW' ? undefined : 'work'" aria-label="操作原因与意见"><h3 class="density-dialog-section__title">{{ isReview ? '审核意见' : '操作说明' }}</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="isReview && form.decision === 'REJECTED' ? '驳回原因(必填)' : currentOperation === 'ROUTE' || currentAction === 'RETURN_TO_REVIEW' ? '原因(必填)' : '审核意见(选填)'" :error="isReview && form.decision === 'REJECTED' && !form.reason.trim() ? '请填写驳回原因' : ''"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" :placeholder="isReview ? '驳回时必须填写原因,审核通过时选填' : ''" /></el-form-item></td></tr></tbody></table></section>
<section v-else-if="currentOperation === 'ROUTE' || ['RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" class="density-dialog-section" :data-tone="isReview ? undefined : 'work'" aria-label="操作原因与意见"><h3 class="density-dialog-section__title">{{ isReview ? '审核意见' : '操作说明' }}</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="isReview && form.decision === 'REJECTED' ? '驳回原因(必填)' : currentOperation === 'ROUTE' || currentAction === 'RETURN_TO_REVIEW' ? '原因(必填)' : '审核意见(选填)'" :error="isReview && form.decision === 'REJECTED' && !form.reason.trim() ? '请填写驳回原因' : ''"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" :placeholder="isReview ? '驳回时必须填写原因,审核通过时选填' : ''" /></el-form-item></td></tr></tbody></table></section>
<section v-else-if="!['MARK_EXCEPTION', 'EDIT', 'SUBMIT_REVIEW'].includes(currentAction)" class="density-dialog-section" data-tone="work" aria-label="本次操作备注"><h3 class="density-dialog-section__title">本次备注</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="备注"><el-input v-model="form.remark" aria-label="本次操作备注" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section>
</el-form>
</div>
@@ -5,6 +5,7 @@ import TransportExecutionPanel from './TransportExecutionPanel.vue'
import { assignTransport, commandTransport, downloadTransportEvidence, getTransport, uploadTransport, type TransportDetail } from '../api/transportTasks'
import { getDriver } from '@/modules/overseas-master-data'
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
import { ApiResponseError } from '@/shared/api/http'
const can = vi.hoisted(() => vi.fn((_code: string) => true))
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn() }))
@@ -150,7 +151,7 @@ it('requires three categories and sends photos with both current versions for re
it('restores saved photos and retains uploads after a failed save', async () => {
const value = facts(); value.dispatchRecordVersion = 9
value.resources = { vehicle: { vehicleId: '1', evidenceFileIds: ['21'] }, driver: { driverId: '2', evidence: { photoFileIds: ['22'], fileIds: ['23'] } } } as TransportDetail['resources']
vi.mocked(getTransport).mockResolvedValue(value); vi.mocked(assignTransport).mockRejectedValue(new Error('保存失败'))
vi.mocked(getTransport).mockResolvedValue(value); vi.mocked(assignTransport).mockRejectedValue(new ApiResponseError('司机预计任务结束时间须晚于当前时间', 'A0400', 400, undefined, 'save-request', 'save-trace'))
const wrapper = mount(TransportExecutionPanel, { ...photoOptions, props: { dispatchNo: 'PC1', editResources: true } }); await flushPromises()
const cards = wrapper.findAllComponents(DispatchEvidenceFiles)
expect(cards.map(card => card.props('modelValue'))).toEqual([['21'], ['22'], ['23']])
@@ -158,6 +159,8 @@ it('restores saved photos and retains uploads after a failed save', async () =>
cards[0]!.vm.$emit('update:modelValue', ['24']); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '保存车辆司机')!.trigger('click'); await flushPromises()
expect(cards[0]!.props('modelValue')).toEqual(['24'])
expect(wrapper.text()).toContain('司机预计任务结束时间须晚于当前时间')
for (const diagnostic of ['A0400', 'save-request', 'save-trace']) expect(wrapper.text()).not.toContain(diagnostic)
expect(wrapper.emitted('saved')).toBeUndefined(); wrapper.unmount()
})
it('clears old photos when selecting another vehicle or driver and ignores late license responses', async () => {
@@ -7,8 +7,6 @@ import { onBeforeRouteLeave } from 'vue-router'
import { Truck, Package, RefreshCw, Trash2 } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { listVehicles, listDrivers, getDriver, pickupStatusLabels, type VehicleDetail, type DriverDetail, type PickupTask, type PickupEvidence } from '@/modules/overseas-master-data'
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
@@ -195,7 +193,7 @@ onBeforeUnmount(clearTruckPreviewUrls)
</script>
<template>
<section class="transport-execution admin-density admin-workbench" aria-label="运输任务执行">
<LoadErrorState v-if="error" title="操作未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-alert v-if="error" :title="error.message" type="error" :closable="false" show-icon />
<template v-if="detail && trip">
<header class="transport-toolbar">
@@ -43,6 +43,20 @@ function errorState(wrapper: Pick<VueWrapper, 'findAllComponents'>, title: strin
async function retry(state: VueWrapper) { await state.get('.load-error-state__actions button').trigger('click'); await flushPromises() }
describe('native cargo discard detail and approval', () => {
it('rejects a pending application with a required trimmed reason and customer scope', async () => {
const prompt = vi.spyOn(ElMessageBox, 'prompt').mockResolvedValueOnce({ value: ' 箱唛不符 ', action: 'confirm' })
const wrapper = await page()
await row(wrapper).findAll('button').find(item => item.text() === '驳回')!.trigger('click'); await flushPromises()
expect(prompt).toHaveBeenCalled()
expect(api.review).toHaveBeenCalledWith('CD-001', 'REJECT', '箱唛不符', '9007199254740994')
prompt.mockRestore()
})
it('formats applicant names and seconds and uses the same reviewed status as the client', async () => {
api.list.mockResolvedValueOnce({ records: [summary({ createdBy: '123456', createdByName: '客户姓名', createdAt: '2026-11-02T18:42:27.511', status: 'APPROVED' })], total: 1 })
const wrapper = await page()
expect(row(wrapper).text()).toContain('客户姓名'); expect(row(wrapper).text()).toContain('2026-11-02 18:42:27')
expect(row(wrapper).text()).not.toContain('.511'); expect(row(wrapper).text()).not.toContain('待 PDA')
})
it('binds fees to the discard application instead of its inventory or outbound order, and unmounts them on close', async () => {
const { dialog } = await open()
expect(dialog.getComponent({ name: 'BusinessDocumentFees' }).props()).toEqual({
@@ -19,11 +19,13 @@ import { BusinessDocumentFees } from '@/modules/overseas-business'
import { overseasWarehousePermissions as permission } from '../permissions'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
const access = useAccessPreviewStore(); const rows = ref<CargoDiscardSummary[]>([]); const customers = ref<PlatformTenantResource[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const detailOpen = ref(false); const detail = ref<CargoDiscardDetail | null>(null); const filters = ref<QueryModel>({ customerId: '', discardNo: '', inboundCode: '', status: '' })
/** 时间统一显示到秒。 */
function dateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 19) : '—' }
/** 数量事实缺失时明确提示,不用零代替未记录。 */
function quantity(value: number | null | undefined, unit: string) { return value == null ? '未记录 / 不适用' : `${value} ${unit}` }
const fields: QueryField[] = [{ key: 'customerId', type: 'select', label: '客户', placeholder: '请选择客户', options: [] }, { key: 'discardNo', type: 'input', label: '废弃单号', placeholder: '废弃单号', width: 220 }, { key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 180 }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '已审核', value: 'APPROVED' }, { label: '待客户确认', value: 'PDA_COMPLETED' }, { label: '已完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] }]
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 140, fixed: 'left', slotName: 'actions' }, { key: 'discardNo', label: '废弃单号', minWidth: 180 }, { key: 'status', label: '状态', width: 150, slotName: 'status', afterColumn: 'discardNo' }, { key: 'inboundCodes', label: '入仓码', minWidth: 180 }, { key: 'overseasWarehouseName', label: '仓库', minWidth: 140 }, { key: 'requestedPackages', label: '申请箱数', width: 90 }, { key: 'totalFee', label: '废弃费用', width: 120, formatter: (row: CargoDiscardSummary) => `${row.currency || 'USD'} ${row.totalFee ?? 0}` }, { key: 'createdBy', label: '申请人', width: 110 }, { key: 'createdAt', label: '创建时间', minWidth: 170 }]
const statusLabels: Record<string, string> = { PENDING_REVIEW: '待审核', APPROVED: '已审核,待 PDA', PDA_COMPLETED: 'PDA 已完成,待客户确认', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' }
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 140, fixed: 'left', slotName: 'actions' }, { key: 'discardNo', label: '废弃单号', minWidth: 180 }, { key: 'status', label: '状态', width: 150, slotName: 'status', afterColumn: 'discardNo' }, { key: 'inboundCodes', label: '入仓码', minWidth: 180 }, { key: 'overseasWarehouseName', label: '仓库', minWidth: 140 }, { key: 'requestedPackages', label: '申请箱数', width: 90 }, { key: 'totalFee', label: '废弃费用', width: 120, formatter: (row: CargoDiscardSummary) => `${row.currency || 'USD'} ${row.totalFee ?? 0}` }, { key: 'createdByName', label: '申请人', width: 110, formatter: (row: CargoDiscardSummary) => row.createdByName || '—' }, { key: 'createdAt', label: '创建时间', minWidth: 170, formatter: (row: CargoDiscardSummary) => dateTime(row.createdAt) }]
const statusLabels: Record<string, string> = { PENDING_REVIEW: '待审核', APPROVED: '已审核', PDA_COMPLETED: '待客户确认', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' }
const customerError = ref<ApiErrorDetails | null>(null); const listError = ref<ApiErrorDetails | null>(null)
const detailError = ref<ApiErrorDetails | null>(null); const reviewError = ref<ApiErrorDetails | null>(null)
const detailLoading = ref(false); const reviewing = ref(''); const detailTarget = ref<CargoDiscardSummary | null>(null)
@@ -78,10 +80,23 @@ async function audit(row: CargoDiscardSummary) {
catch (error) { if (error !== 'cancel' && error !== 'close') reviewError.value = getApiErrorDetails(error, '审核失败') }
finally { reviewing.value = '' }
}
/** 驳回须填写原因,成功后刷新当前列表和详情。 */
async function reject(row: CargoDiscardSummary) {
if (reviewing.value || row.status !== 'PENDING_REVIEW' || !access.can(permission.discardApprove)) return
reviewing.value = row.discardNo; reviewError.value = null
try {
const { value } = await ElMessageBox.prompt('请填写驳回原因', '驳回废弃单', { inputType: 'textarea', inputValidator: (value: string) => !!value?.trim() || '请填写驳回原因', confirmButtonText: '确认驳回', cancelButtonText: '取消', customClass: 'admin-density-confirm admin-workbench is-dialog' })
if (!access.can(permission.discardApprove)) return
await reviewCargoDiscard(row.discardNo, 'REJECT', value.trim(), row.customerId || undefined)
ElMessage.success('已驳回'); await load()
if (detailOpen.value && detailTarget.value?.discardNo === row.discardNo) await readDetail()
} catch (error) { if (error !== 'cancel' && error !== 'close') reviewError.value = getApiErrorDetails(error, '驳回失败') }
finally { reviewing.value = '' }
}
onMounted(() => { void loadCustomers(); void load() })
</script>
<template>
<AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="废弃单列表"><section><LoadErrorState v-if="customerError" title="客户列表加载失败" :message="customerError.message" @retry="loadCustomers"><template #details><ApiErrorMeta :details="customerError" /></template></LoadErrorState><LoadErrorState v-if="listError" title="废弃单列表加载失败" :message="listError.message" @retry="load()"><template #details><ApiErrorMeta :details="listError" /></template></LoadErrorState><LoadErrorState v-if="reviewError" title="审核失败" :message="reviewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="reviewError" /></template></LoadErrorState><div class="workbench-table is-page-surface"><DataTable appearance="integrated" :data="rows" :columns="columns" :loading="loading" :total="total" v-model:page="page" v-model:page-size="pageSize" preference-key="overseas-cargo-discards" row-key="id" empty-text="暂无废弃单" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-start><h1>废弃单</h1></template><template #filters><QueryToolbar ariaLabel="废弃单筛选" v-model="filters" :fields="fields" :loading="loading" preference-key="overseas-cargo-discards-query" @search="page = 1; load($event)" @reset="page = 1; load($event)" /></template><template #actions="{ row }"><el-button class="density-action" size="small" :disabled="!!reviewing" @click="openDetail(row as CargoDiscardSummary)">详情</el-button><el-button class="density-action" v-if="access.can(permission.discardApprove) && (row as CargoDiscardSummary).status === 'PENDING_REVIEW'" size="small" type="primary" :disabled="!!reviewing" @click="audit(row as CargoDiscardSummary)">审核并推送 PDA</el-button></template><template #status="{ row }"><StatusBadge :status="statusLabels[String((row as CargoDiscardSummary).status)] || (row as CargoDiscardSummary).status || '未记录'" /></template></DataTable></div></section></ListPageLayout></AdminPage>
<AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="废弃单列表"><section><LoadErrorState v-if="customerError" title="客户列表加载失败" :message="customerError.message" @retry="loadCustomers"><template #details><ApiErrorMeta :details="customerError" /></template></LoadErrorState><LoadErrorState v-if="listError" title="废弃单列表加载失败" :message="listError.message" @retry="load()"><template #details><ApiErrorMeta :details="listError" /></template></LoadErrorState><LoadErrorState v-if="reviewError" title="审核失败" :message="reviewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="reviewError" /></template></LoadErrorState><div class="workbench-table is-page-surface"><DataTable appearance="integrated" :data="rows" :columns="columns" :loading="loading" :total="total" v-model:page="page" v-model:page-size="pageSize" preference-key="overseas-cargo-discards" row-key="id" empty-text="暂无废弃单" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-start><h1>废弃单</h1></template><template #filters><QueryToolbar ariaLabel="废弃单筛选" v-model="filters" :fields="fields" :loading="loading" preference-key="overseas-cargo-discards-query" @search="page = 1; load($event)" @reset="page = 1; load($event)" /></template><template #actions="{ row }"><el-button class="density-action" size="small" :disabled="!!reviewing" @click="openDetail(row as CargoDiscardSummary)">详情</el-button><el-button class="density-action" v-if="access.can(permission.discardApprove) && (row as CargoDiscardSummary).status === 'PENDING_REVIEW'" size="small" type="primary" :disabled="!!reviewing" @click="audit(row as CargoDiscardSummary)">审核并推送 PDA</el-button><el-button v-if="access.can(permission.discardApprove) && (row as CargoDiscardSummary).status === 'PENDING_REVIEW'" class="density-action" size="small" type="danger" :disabled="!!reviewing" @click="reject(row as CargoDiscardSummary)">驳回</el-button></template><template #status="{ row }"><StatusBadge :status="statusLabels[String((row as CargoDiscardSummary).status)] || (row as CargoDiscardSummary).status || '未记录'" /></template></DataTable></div></section></ListPageLayout></AdminPage>
<AppDialog v-model="detailOpen" class="admin-density-dialog admin-workbench is-dialog" :title="'废弃单详情 · ' + (detailTarget?.discardNo || '')" :confirm-loading="detailLoading" :show-confirm="false" cancel-text="关闭" width="min(920px, calc(100vw - 28px))" fullscreenable>
<p v-if="detailLoading" role="status">正在读取废弃单…</p>
<LoadErrorState v-else-if="detailError" title="废弃单详情加载失败" :message="detailError.message" @retry="readDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
@@ -111,6 +126,7 @@ onMounted(() => { void loadCustomers(); void load() })
<LoadErrorState v-else-if="photoErrors[photo.fileId]" title="废弃照片加载失败" :message="photoErrors[photo.fileId]?.message" @retry="loadPhoto(photo.fileId)"><template #details><ApiErrorMeta :details="photoErrors[photo.fileId]!" /></template></LoadErrorState>
<el-button v-else @click="loadPhoto(photo.fileId)">加载照片</el-button><small>托盘:{{ photo.palletNo }} · {{ photo.submittedAt }}</small>
</div></div><p v-else>暂无已提交的废弃现场照片</p>
<div v-if="detail.summary.status === 'PENDING_REVIEW' && access.can(permission.discardApprove)"><el-button type="danger" :disabled="!!reviewing" @click="reject(detail.summary)">驳回</el-button></div>
<h3>状态流转</h3><table v-if="detail.histories.length" class="density-table" aria-label="废弃状态流转"><tbody><tr v-for="(item, index) in detail.histories" :key="index"><td>{{ item.createdAt || '—' }}</td><td>{{ statusLabels[item.toStatus || ''] || item.toStatus || '未记录' }}</td><td>{{ item.remark || '—' }}</td></tr></tbody></table><p v-else>暂无状态流转记录</p>
</template>
</AppDialog>
@@ -202,29 +202,28 @@ it('shows five working phases and uses server stage counts instead of current pa
const tabs = wrapper.get('.data-table__toolbar-start .workbench-list-tabs[aria-label="排车执行阶段"]').findAll('button')
expect(wrapper.find('.data-table__filters [aria-label="装车前审核分区"]').exists()).toBe(true)
expect(tabs.map(tab => tab.text())).toEqual(['装车前 3', '装车中 1', '装车后 1', '派送中 1', '派送完成 1'])
expect(listDispatches).toHaveBeenCalledWith(expect.objectContaining({ stage: 'BEFORE_LOADING', status: 'ACTIVE', beforeLoadingSection: 'VEHICLE_INFO' }))
expect(listDispatches).toHaveBeenCalledWith(expect.objectContaining({ stage: 'BEFORE_LOADING', status: 'ALL' }))
await tabs[3]!.trigger('click'); await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ stage: 'DELIVERING' })); expect(wrapper.find('[aria-label="装车前审核分区"]').exists()).toBe(false); wrapper.unmount()
})
it.each([0, 1, 2])('preserves section %s and its query after clearing the active dispatch filter', async index => {
it.each([0, 1, 2])('queries all documents after clearing scope from section %s', async index => {
const wrapper = mount(DispatchesView, options); await flushPromises()
const sectionValues = ['VEHICLE_INFO', 'DISPATCH_REVIEW', 'LOADING_REVIEW']
const sectionSelector = '[aria-label="装车前审核分区"]'
await wrapper.get(sectionSelector).findAll('button')[index]!.trigger('click'); await flushPromises()
const toolbar = wrapper.getComponent(QueryToolbar)
await toolbar.findAll('button').find(button => button.text() === '高级筛选')!.trigger('click')
toolbar.findAllComponents(ElSelect).find(select => select.props('placeholder') === '单据范围')!.vm.$emit('update:modelValue', '')
await flushPromises()
expect(wrapper.get(sectionSelector).findAll('button').map(button => button.text())).toEqual(['车辆信息', '调度审核', '理货员审核'])
expect(wrapper.get(sectionSelector).findAll('button')[index]!.attributes('aria-selected')).toBe('true')
await toolbar.findAll('button').find(button => button.text() === '查询')!.trigger('click'); await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'ACTIVE', beforeLoadingSection: sectionValues[index] }))
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'ALL' }))
const nextIndex = (index + 1) % 3
await wrapper.get(sectionSelector).findAll('button')[nextIndex]!.trigger('click'); await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'ACTIVE', beforeLoadingSection: sectionValues[nextIndex] }))
await toolbar.findAll('button').find(button => button.text() === '重置')!.trigger('click'); await flushPromises()
expect(wrapper.get(sectionSelector).findAll('button')).toHaveLength(3)
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'ACTIVE', beforeLoadingSection: sectionValues[nextIndex] }))
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'ALL' }))
await wrapper.get('[aria-label="排车执行阶段"]').findAll('button')[1]!.trigger('click'); await flushPromises()
expect(wrapper.find(sectionSelector).exists()).toBe(false)
expect(vi.mocked(listDispatches).mock.calls.at(-1)?.[0]).not.toHaveProperty('beforeLoadingSection')
@@ -279,7 +278,7 @@ it('shows trip progress independently of master vehicle status and leaves cancel
vi.mocked(listDispatches).mockResolvedValueOnce(page(records))
const wrapper = mount(DispatchesView, options); await flushPromises()
const toolbar = wrapper.getComponent(QueryToolbar)
expect(toolbar.props('fields').map(field => field.key)).toEqual(['dispatchNo', 'vehiclePlate', 'vehicleStatus'])
expect(toolbar.props('fields').map(field => field.key)).toEqual(['dispatchNo', 'vehiclePlate', 'vehicleStatus', 'status'])
expect(toolbar.props('fields').find(field => field.key === 'vehicleStatus')?.options?.map(option => option.label)).toEqual(['待发车', '配送中', '已送达'])
expect(wrapper.getComponent(DataTable).props('columns').some(column => column.key === 'vehicleStatus' && column.label === '车辆状态')).toBe(true)
expect(wrapper.findAllComponents(ElTag).map(tag => tag.text())).toEqual(['待发车', '待发车', '配送中', '已送达'])
@@ -287,23 +286,21 @@ it('shows trip progress independently of master vehicle status and leaves cancel
expect(wrapper.text()).not.toContain('维修中')
wrapper.unmount()
})
it.each(['CANCELLED', 'ALL'])('retains advanced %s lookup and resets its hidden value', async status => {
it.each(['CANCELLED', 'ALL'])('keeps %s lookup visible and resets to all documents', async status => {
const wrapper = mount(DispatchesView, options); await flushPromises()
const toolbar = wrapper.getComponent(QueryToolbar)
toolbar.vm.$emit('search', { status: 'ACTIVE', vehicleStatus: 'WAIT_DEPARTURE' }); await flushPromises()
await toolbar.findAll('button').find(button => button.text() === '高级筛选')!.trigger('click')
const select = toolbar.findAllComponents(ElSelect).find(item => item.props('placeholder') === '单据范围')!
select.vm.$emit('update:modelValue', status); select.vm.$emit('change', status); await flushPromises()
await toolbar.findAll('button').find(button => button.text() === '查询')!.trigger('click'); await flushPromises()
expect(toolbar.props('modelValue').vehicleStatus).toBe('')
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status }))
expect(vi.mocked(listDispatches).mock.calls.at(-1)?.[0]).not.toHaveProperty('beforeLoadingSection')
await toolbar.findAll('button').find(button => button.text().startsWith('收起筛选'))!.trigger('click')
expect(toolbar.props('fields').some(field => field.key === 'status')).toBe(false)
expect(toolbar.text()).toContain(status === 'CANCELLED' ? '高级筛选 · 已取消' : '高级筛选 · 全部单据')
expect(toolbar.props('fields').some(field => field.key === 'status')).toBe(true)
expect(toolbar.text()).not.toContain('高级筛选')
await toolbar.findAll('button').find(button => button.text() === '重置')!.trigger('click'); await flushPromises()
expect(toolbar.props('modelValue')).toMatchObject({ status: 'ACTIVE', vehicleStatus: '' })
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, status: 'ACTIVE', beforeLoadingSection: 'VEHICLE_INFO' }))
expect(toolbar.props('modelValue')).toMatchObject({ status: '', vehicleStatus: '' })
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, status: 'ALL' }))
wrapper.unmount()
})
it('returns to active dispatches when selecting vehicle progress from cancelled lookup', async () => {
@@ -330,7 +327,7 @@ it.each(['dispatchReview', 'loadingReview'] as const)('shows %s rejection in the
row.reviews[reviewKey] = { ...row.reviews[reviewKey], status: 'REJECTED', reason: '车辆证照需补齐' }
vi.mocked(listDispatches).mockResolvedValue(page([row]))
const wrapper = mount(DispatchesView, options); await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ beforeLoadingSection: 'VEHICLE_INFO' }))
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'ALL' }))
expect(wrapper.text()).toContain(`${reviewKey === 'dispatchReview' ? '调度审核' : '理货员审核'}退回:车辆证照需补齐`)
wrapper.unmount()
})
@@ -424,8 +421,8 @@ it.each(['saved', 'rejected'] as const)('refreshes list actions after workflow %
expect(row.props('actions')).not.toContainEqual(expect.objectContaining({ key: 'EDIT' }))
if (event === 'rejected') {
expect(wrapper.text()).toContain('本轮审核已经提交')
expect(wrapper.text()).toContain('A0409')
expect(wrapper.text()).toContain('list-reject-request')
expect(wrapper.text()).not.toContain('A0409')
expect(wrapper.text()).not.toContain('list-reject-request')
}
expect(wrapper.text()).not.toContain('结果尚待核对')
wrapper.unmount()
@@ -481,14 +478,14 @@ it('opens the exception form from the dispatching row and hides it without permi
denied.unmount()
})
it.each([['DISPATCH_REVIEW',1,'DISPATCH_REJECTED'],['LOADING_REVIEW',2,'LOADING_REJECTED']] as const)('returns a rejected %s to vehicle information',async(action,index,reviewState)=>{
it.each([['DISPATCH_REVIEW',1,'DISPATCH_REJECTED','DELIVERY'],['LOADING_REVIEW',2,'LOADING_REJECTED','DELIVERY'],['DISPATCH_REVIEW',1,'DISPATCH_REJECTED','LOCAL_TRUCK'],['LOADING_REVIEW',2,'LOADING_REJECTED','LOCAL_TRUCK']] as const)('returns a rejected %s to vehicle information (%s)',async(action,index,reviewState,taskType)=>{
const allowed={allowed:true,blockCode:null,blockReason:null}
vi.mocked(listDispatches).mockResolvedValue(page([listRow({actions:{[action]:allowed}})]))
vi.mocked(listDispatches).mockResolvedValue(page([listRow({taskType,actions:{[action]:allowed}})]))
const wrapper=mount(DispatchesView,options);await flushPromises()
await wrapper.get('[aria-label="装车前审核分区"]').findAll('button')[index]!.trigger('click');await flushPromises()
const row=wrapper.findAllComponents(RowActions).find(entry=>entry.props('moreLabel')==='更多操作 · PC/1')!
row.vm.$emit('action',action);await flushPromises()
vi.mocked(getDispatch).mockResolvedValueOnce(detail({reviewState,stage:'BEFORE_LOADING'}))
vi.mocked(getDispatch).mockResolvedValueOnce(detail({taskType,reviewState,stage:'BEFORE_LOADING'}))
wrapper.getComponent({name:'DispatchWorkflowDialog'}).vm.$emit('saved');await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({page:1,stage:'BEFORE_LOADING',beforeLoadingSection:'VEHICLE_INFO'}))
expect(wrapper.get('[aria-label="装车前审核分区"]').findAll('button')[0]!.attributes('aria-selected')).toBe('true')
@@ -48,8 +48,7 @@ const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const stage = ref<DispatchStage>('BEFORE_LOADING'), section = ref<BeforeLoadingSection>('VEHICLE_INFO')
const model = ref<QueryModel>({ dispatchNo: String(route.query.dispatchNo || ''), vehiclePlate: '', vehicleStatus: '', status: 'ACTIVE' })
const showAdvancedFilters = ref(false)
const model = ref<QueryModel>({ dispatchNo: String(route.query.dispatchNo || ''), vehiclePlate: '', vehicleStatus: '', status: '' })
const vehicleStatuses = [
{ value: 'WAIT_DEPARTURE', label: '待发车', type: 'warning' },
{ value: 'DELIVERING', label: '配送中', type: 'primary' },
@@ -63,14 +62,14 @@ const operation = ref<'ROUTE' | undefined>()
const action = ref<DispatchAction>('EDIT'), actionNo = ref(''), actionOpen = ref(false), createOpen = ref(false), bolLoadingNo = ref('')
const routeOpen = ref(false), routeOrigin = ref<RouteLocation>(routeLocation('')), routeDestinations = ref<RouteLocation[]>([])
const sections: Array<{ value: BeforeLoadingSection; label: string }> = [{ value: 'VEHICLE_INFO', label: '车辆信息' }, { value: 'DISPATCH_REVIEW', label: '调度审核' }, { value: 'LOADING_REVIEW', label: '理货员审核' }]
const showSections = computed(() => stage.value === 'BEFORE_LOADING' && (model.value.status || 'ACTIVE') === 'ACTIVE')
const showSections = computed(() => stage.value === 'BEFORE_LOADING' && (!model.value.status || model.value.status === 'ACTIVE'))
const availableFields = computed<QueryField[]>(() => [
{ key: 'dispatchNo', label: '排车单号', type: 'input', width: 180, placeholder: '请输入排车单号' },
{ key: 'vehiclePlate', label: '车牌号', type: 'input', width: 150, placeholder: '请输入车牌号' },
{ key: 'vehicleStatus', label: '车辆状态', placeholder: '车辆状态', type: 'select', width: 140, options: [...vehicleStatuses], props: { 'aria-label': '车辆状态', onChange: (value: unknown) => { if (value) model.value.status = 'ACTIVE' } } },
{ key: 'status', label: '单据范围', placeholder: '单据范围', type: 'select', width: 140, options: [{ value: 'ACTIVE', label: '有效排车' }, { value: 'CANCELLED', label: '已取消' }, { value: 'ALL', label: '全部单据' }], props: { 'aria-label': '单据范围', onChange: (value: unknown) => { if (value && value !== 'ACTIVE') model.value.vehicleStatus = '' } } },
{ key: 'status', label: '单据范围', placeholder: '单据范围', type: 'select', width: 140, options: [{ value: 'ACTIVE', label: '有效排车' }, { value: 'CANCELLED', label: '已取消' }], props: { 'aria-label': '单据范围', onChange: (value: unknown) => { if (value && value !== 'ACTIVE') model.value.vehicleStatus = '' } } },
])
const fields = computed(() => availableFields.value.filter(field => showAdvancedFilters.value || field.key !== 'status'))
const fields = availableFields
const columns = computed<DataTableColumn[]>(() => [
{ key: 'actions', label: '操作', width: rows.value.some(row => row.taskType === 'PICKUP') ? 330 : rows.value.some(canDownloadBol) ? 290 : 220, fixed: 'left', slotName: 'actions' },
{ key: 'taskType', label: '排车类型', width: 115, slotName: 'taskType' },
@@ -95,17 +94,17 @@ async function load() {
const current = ++sequence
loading.value = true; loadError.value = null
try {
const result = await listDispatches({ page: page.value, size: pageSize.value, stage: stage.value, status: String(model.value.status || 'ACTIVE') as DispatchStatus | 'ALL', dispatchNo: String(model.value.dispatchNo || '').trim() || undefined, vehiclePlate: String(model.value.vehiclePlate || '').trim() || undefined,
...(showSections.value ? { beforeLoadingSection: section.value } : {}) })
const result = await listDispatches({ page: page.value, size: pageSize.value, stage: stage.value, status: String(model.value.status || 'ALL') as DispatchStatus | 'ALL', dispatchNo: String(model.value.dispatchNo || '').trim() || undefined, vehiclePlate: String(model.value.vehiclePlate || '').trim() || undefined,
...(showSections.value && model.value.status === 'ACTIVE' ? { beforeLoadingSection: section.value } : {}) })
if (current !== sequence) return
rows.value = result.records; total.value = result.total; statistics.value = result.statistics
} catch (cause) { if (current === sequence) { rows.value = []; total.value = 0; statistics.value = null; loadError.value = getApiErrorDetails(cause, '排车列表加载失败,请重试') } }
finally { if (current === sequence) loading.value = false }
}
function search(value: QueryModel) {
model.value = { ...value, status: value.status || 'ACTIVE' }
model.value = { ...value, status: value.status || '' }
const selected = vehicleStatuses.find(item => item.value === model.value.vehicleStatus)
if (model.value.status !== 'ACTIVE') model.value.vehicleStatus = ''
if (model.value.status !== 'ACTIVE') { model.value.vehicleStatus = ''; section.value = 'VEHICLE_INFO' }
else if (selected && vehicleStatusForStage(stage.value)?.value !== selected.value) {
// 车辆运输进度与阶段联动,查询仍由服务端按阶段分页,避免交叉条件恒为空。
stage.value = selected.value === 'WAIT_DEPARTURE' ? 'BEFORE_LOADING' : selected.value
@@ -113,13 +112,13 @@ function search(value: QueryModel) {
}
page.value = 1; void load()
}
function resetSearch() { search({ dispatchNo: '', vehiclePlate: '', vehicleStatus: '', status: 'ACTIVE' }) }
function resetSearch() { search({ dispatchNo: '', vehiclePlate: '', vehicleStatus: '', status: '' }) }
function switchStage(value: DispatchStage) {
stage.value = value
if (model.value.vehicleStatus) model.value.vehicleStatus = vehicleStatusForStage(value)?.value || ''
section.value = 'VEHICLE_INFO'; page.value = 1; void load()
}
function switchSection(value: BeforeLoadingSection) { section.value = value; page.value = 1; void load() }
function switchSection(value: BeforeLoadingSection) { model.value.status = 'ACTIVE'; section.value = value; page.value = 1; void load() }
function openDetail(row: DispatchListRow) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }) }
const rowActionOrder: DispatchAction[] = ['EDIT', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'MARK_EXCEPTION', 'CANCEL']
function canManageDocuments(row: DispatchListRow) { return (row.taskType || 'DELIVERY') === 'DELIVERY' && row.status === 'ACTIVE' && ['LOADING', 'AFTER_LOADING', 'DELIVERING'].includes(row.stage) }
@@ -229,11 +228,11 @@ onBeforeUnmount(() => { sequence++ })
<template>
<AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="海外仓排车列表">
<OperationErrorToast :details="mutationError" />
<LoadErrorState v-if="rejectionError" title="排车操作未完成" :message="rejectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="rejectionError" /></template></LoadErrorState>
<el-alert v-if="rejectionError" :title="rejectionError.message" type="error" :closable="false" show-icon />
<div class="workbench-table is-page-surface"><DataTable v-model:page="page" v-model:page-size="pageSize" appearance="integrated" :data="rows" :columns="columns" :loading="loading" :total="total" :preference-key="preferenceKey" row-key="dispatchNo" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><el-button v-if="appointmentReturnTarget(route.query)" link @click="router.push(appointmentReturnTarget(route.query)!)">返回预约工作</el-button><div class="density-status-tabs workbench-list-tabs" role="tablist" aria-label="排车执行阶段"><button v-for="item in dispatchStages" :key="item.value" role="tab" :aria-selected="stage === item.value" :class="{ 'is-active': stage === item.value }" @click="switchStage(item.value)">{{ item.label }} <span>{{ statistics?.stageCounts[item.value] ?? '—' }}</span></button></div></template>
<template #toolbar-end><el-button v-if="access.can(dispatchActionPermissions.CREATE)" @click="createTransport('PICKUP')">安排到仓取货</el-button><el-button v-if="access.can(dispatchActionPermissions.CREATE)" @click="createTransport('LOCAL_TRUCK')">安排本土卡车</el-button><el-button v-if="access.can(dispatchActionPermissions.CREATE) || access.can(dispatchActionPermissions.ADD_CARGO)" type="primary" @click="createOpen = true">智能排车</el-button></template>
<template #filters><div class="integrated-query-group"><div v-if="showSections" class="density-status-tabs" role="tablist" aria-label="装车前审核分区"><button v-for="item in sections" :key="item.value" role="tab" :aria-selected="section === item.value" :class="{ 'is-active': section === item.value }" @click="switchSection(item.value)">{{ item.label }}</button></div><QueryToolbar id="dispatch-query-filters" v-model="model" :fields="fields" :available-fields="availableFields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="resetSearch"><template #actions><el-button :aria-expanded="showAdvancedFilters" aria-controls="dispatch-query-filters" @click="showAdvancedFilters = !showAdvancedFilters">{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}{{ model.status === 'CANCELLED' ? ' · 已取消' : model.status === 'ALL' ? ' · 全部单据' : '' }}</el-button></template></QueryToolbar></div></template>
<template #filters><div class="integrated-query-group"><div v-if="showSections" class="density-status-tabs" role="tablist" aria-label="装车前审核分区"><button v-for="item in sections" :key="item.value" role="tab" :aria-selected="section === item.value" :class="{ 'is-active': section === item.value }" @click="switchSection(item.value)">{{ item.label }}</button></div><QueryToolbar id="dispatch-query-filters" v-model="model" :fields="fields" :available-fields="availableFields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="resetSearch"></QueryToolbar></div></template>
<template v-if="loadError" #state><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as DispatchListRow)" :max-visible="row.taskType === 'PICKUP' || canDownloadBol(row as DispatchListRow) ? 3 : 2" :more-label="`更多操作 · ${row.dispatchNo}`" @action="handleAction($event, row as DispatchListRow)" /><p v-if="!row.actions">操作资格未返回,请刷新核对</p></template>
<template #taskType="{ row }">{{ transportLabels[(row.taskType || 'DELIVERY') as keyof typeof transportLabels] }}</template>
@@ -145,6 +145,33 @@ describe('LastMileOrderDetailView domestic detail alignment', () => {
})
})
it('allows selecting and submitting manual storage rent', async () => {
const { dialog } = await openFeeEditor()
const select = dialog.findAllComponents({ name: 'ElSelect' })[0]!
expect(select.findAllComponents({ name: 'ElOption' }).some(option => option.props('value') === 'FEE_TYPE_077')).toBe(true)
select.vm.$emit('update:modelValue', 'FEE_TYPE_077')
dialog.findAllComponents({ name: 'ElInputNumber' })[0]!.vm.$emit('update:modelValue', 80)
await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(createLastMileOrderFee).toHaveBeenCalledWith('internal-order-id', expect.objectContaining({ feeItemCode: 'FEE_TYPE_077', amount: 80 }))
})
it('creates a return order fee without charging quantity or unit inputs', async () => {
vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'internal-order-id', customerId: 'customer-1', orderNo: 'RT-1', orderType: 'RETURN_RELABEL', currentStatus: 'INBOUND_COMPLETED' }, cargo: [], fees: [], statusHistory: [], operationHistory: [] })
const { dialog } = await openFeeEditor()
expect(dialog.text()).not.toContain('计费数量')
expect(dialog.text()).not.toContain('计费单位')
dialog.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', 'FEE_TYPE_071')
dialog.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 80)
await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(createLastMileOrderFee).toHaveBeenCalledOnce()
const payload = vi.mocked(createLastMileOrderFee).mock.calls[0]![1]
expect(payload.amount).toBe(80)
expect(payload).not.toHaveProperty('chargeableQuantity')
expect(payload).not.toHaveProperty('quantityUnit')
expect(payload).not.toHaveProperty('unitPrice')
})
it('shows business references without changing the transfer workflow or requiring business-list permission', async () => {
vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'internal-order-id', customerId: 'A', orderNo: 'WT-1', sourceOrderNo: 'WT-1', orderType: 'WAREHOUSE_TRANSFER', businessSource: { businessType: 'LOCAL_LOOSEBOARD', label: '本土散板 · 拼车需求池', businessOrderNo: 'LB-1', externalBookingNo: 'FB-1' } }, cargo: [], fees: [], statusHistory: [], operationHistory: [] })
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises()
@@ -284,13 +311,13 @@ describe('LastMileOrderDetailView domestic detail alignment', () => {
await dialog.findAll('button').find(button => button.text() === '保存费用')!.trigger('click'); await flushPromises()
expect(createLastMileOrderFee).not.toHaveBeenCalled()
})
it('uses the live catalog, excludes automatic rent and only loads suppliers for payables', async () => {
it('uses the live catalog including rent and only loads suppliers for payables', async () => {
const { dialog } = await openFeeEditor()
expect(listOverseasFeeItems).toHaveBeenCalledWith('RECEIVABLE')
expect(listSuppliers).not.toHaveBeenCalled()
const select = dialog.findAllComponents({ name: 'ElSelect' })[0]!
expect(select.props('allowCreate')).toBe(false)
expect(select.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).toEqual(['FEE_TYPE_071'])
expect(select.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).toEqual(['FEE_TYPE_071', 'FEE_TYPE_077'])
await fillFee(dialog, 'PAYABLE')
expect(listOverseasFeeItems).toHaveBeenLastCalledWith('PAYABLE')
expect(listSuppliers).toHaveBeenCalledOnce()
@@ -272,7 +272,7 @@ async function loadFeeItems() {
feeForm.itemCode = ''; feeForm.itemName = ''
try {
const items = await listOverseasFeeItems(direction)
if (request === feeItemsRequest) feeItemOptions.value = items.filter(item => item.feeCode !== 'FEE_TYPE_077')
if (request === feeItemsRequest) feeItemOptions.value = items
} catch (error) {
if (request === feeItemsRequest) feeItemsError.value = getApiErrorDetails(error, '费用项目加载失败').message
} finally { if (request === feeItemsRequest) feeItemsLoading.value = false }
@@ -324,7 +324,7 @@ async function saveFee() {
if (feeForm.direction === 'PAYABLE' && !supplier) { ElMessage.warning('应付费用必须选择供应商'); return }
saving.value = true; mutationError.value = null
try {
await createLastMileOrderFee(orderId.value, { feeItemCode: selectedItem.feeCode, feeItemName: selectedItem.feeTypeName, feeRemark: feeForm.feeRemark.trim() || undefined, direction: feeForm.direction, currency: feeForm.currency, amount: feeForm.amount, businessDate: feeForm.businessDate, supplierId: supplier ? String(supplier.id) : undefined, supplierName: supplier?.name, supplierCategory: supplier?.category, settlementMode: feeForm.direction === 'PAYABLE' ? 'ON_ACCOUNT' : (feeForm.settlementMode || undefined), chargeableQuantity: feeForm.quantity, quantityUnit: feeForm.quantity == null ? undefined : feeForm.quantityUnit.trim(), unitPrice: feeForm.quantity ? feeForm.amount / feeForm.quantity : undefined })
await createLastMileOrderFee(orderId.value, { feeItemCode: selectedItem.feeCode, feeItemName: selectedItem.feeTypeName, feeRemark: feeForm.feeRemark.trim() || undefined, direction: feeForm.direction, currency: feeForm.currency, amount: feeForm.amount, businessDate: feeForm.businessDate, supplierId: supplier ? String(supplier.id) : undefined, supplierName: supplier?.name, supplierCategory: supplier?.category, settlementMode: feeForm.direction === 'PAYABLE' ? 'ON_ACCOUNT' : (feeForm.settlementMode || undefined), ...(!isReturnOrder.value ? { chargeableQuantity: feeForm.quantity, quantityUnit: feeForm.quantity == null ? undefined : feeForm.quantityUnit.trim(), unitPrice: feeForm.quantity ? feeForm.amount / feeForm.quantity : undefined } : {}) })
feeDialogOpen.value = false; ElMessage.success('费用已提交审核'); await load()
await Promise.all([feePanel.value?.refresh(), payableFeePanel.value?.refresh()]); activeTab.value = 'fees'
} catch (error) { mutationError.value = getApiErrorDetails(error, '费用新增失败,请重试') }
@@ -443,7 +443,7 @@ onMounted(load)
<tr v-if="feeForm.direction === 'PAYABLE'"><td colspan="2"><el-form-item label="供应商" required><el-select v-model="feeForm.supplierId" filterable :loading="suppliersLoading" :disabled="suppliersLoading || !!suppliersError" placeholder="请选择供应商"><el-option v-for="item in supplierOptions" :key="String(item.id)" :label="item.name" :value="String(item.id)" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="币种" required><el-select v-model="feeForm.currency" placeholder="请选择币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item></td><td><el-form-item label="金额" required><el-input-number v-model="feeForm.amount" :min="0" :precision="2" :step="10" controls-position="right" /></el-form-item></td></tr>
<tr v-if="feeForm.direction === 'RECEIVABLE'"><td colspan="2"><el-form-item label="结算方式"><el-select v-model="feeForm.settlementMode" clearable placeholder="按业务默认规则"><el-option label="推送结算(账期)" value="BILL" /><el-option label="余额扣费" value="WALLET" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="计费数量"><el-input-number v-model="feeForm.quantity" :min="0.0001" :precision="4" controls-position="right" /></el-form-item></td><td><el-form-item label="计费单位"><el-input v-model="feeForm.quantityUnit" :disabled="saving || feeForm.quantity == null" placeholder="如 箱、托、天、m³" /></el-form-item></td></tr>
<tr v-if="!isReturnOrder"><td><el-form-item label="计费数量"><el-input-number v-model="feeForm.quantity" :min="0.0001" :precision="4" controls-position="right" /></el-form-item></td><td><el-form-item label="计费单位"><el-input v-model="feeForm.quantityUnit" :disabled="saving || feeForm.quantity == null" placeholder="如 箱、托、天、m³" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="费用说明" :required="duplicateFees.length > 0"><el-input v-model="feeForm.feeRemark" type="textarea" :rows="2" maxlength="500" show-word-limit placeholder="补收或重复作业时,请说明原因" /></el-form-item></td></tr>
<tr v-if="duplicateFees.length"><td colspan="2" role="status">此单已有相同费用,确认本次为新增作业后可继续添加。<div v-for="row in duplicateFees" :key="row.sourceFeeId">{{ sameProjectFeeSummary(row) }}</div></td></tr>
<tr v-if="existingFeesError"><td colspan="2">{{ existingFeesError }} <el-button link type="primary" @click="loadExistingFees">重新加载</el-button></td></tr>
@@ -1436,6 +1436,24 @@ describe('LastMileOrdersView pending order and container conversion', () => {
await dialog.findAll('button').find(item => item.text() === '保存')!.trigger('click'); await flushPromises()
expect(updateLastMileOrderHold).toHaveBeenCalledWith(row.id, { held: !detained, chargeable: false, reason: '原因', expectedVersion: 0 }, expect.stringContaining('last-mile-hold-'))
})
it.each(['RELABEL_APPLICATION', 'WAREHOUSE_TRANSFER', 'LOCAL_LOOSEBOARD', 'RETURN_RELABEL', 'CARGO_DISCARD'])('does not offer manual transport nodes for %s', async orderType => {
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ id: 'business-tail', customerId, orderNo: 'BUSINESS-1', orderType, currentStatus: 'PENDING_ASSIGNMENT' }], page: 1, size: 10, total: 1, pages: 1 })
const wrapper = await mountView()
expect(wrapper.get('.el-table__body-wrapper .row-actions').text()).not.toContain('修改节点')
await (wrapper.vm as unknown as { openManualNodes(row: object): Promise<void> }).openManualNodes(selectedRow({ orderTypeCode: orderType }))
expect(getLastMileOrderManualNodes).not.toHaveBeenCalled()
})
it('provides the same return receipt statuses as the list labels and submits their exact codes', async () => {
const wrapper = await mountView()
const toolbar = wrapper.findComponent(QueryToolbar)
const status = (toolbar.props('fields') as Array<{ key: string; options?: Array<{ label: string; value: string }> }>).find(field => field.key === 'status')!
for (const [value, label] of [['PENDING_REVIEW', '待审核'], ['PENDING_RECEIPT', '待签收入库'], ['PARTIALLY_RECEIVED', '部分入库'], ['INBOUND_COMPLETED', '全部入库']]) {
expect(status.options).toContainEqual({ value, label })
toolbar.vm.$emit('search', { ...toolbar.props('modelValue'), status: value })
await flushPromises()
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ currentStatus: value }))
}
})
it('edits only customs nodes while preserving hidden node facts and zero version', async () => {
vi.mocked(getLastMileOrderManualNodes).mockResolvedValue({ orderId: '2092873463568596994', manualOverrideEnabled: true, nodes: [{ nodeCode: 'ACTUAL_DEPARTURE', nodeAt: '2026-10-19T08:00:00' }], version: 0 })
const wrapper = await mountView()
@@ -220,12 +220,16 @@ const allFields: QueryField[] = [
{ key: 'containerNo', type: 'input', label: '集装箱号', width: 180, placeholder: '请输入集装箱号' },
{ key: 'transferWarehouseCode', type: 'input', label: '转仓代码', width: 170, placeholder: '请输入转仓代码' },
{ key: 'status', type: 'select', label: '当前状态', width: 160, placeholder: '请选择当前状态', options: [
{ label: '待审核', value: 'PENDING_REVIEW' },
{ label: '待签收入库', value: 'PENDING_RECEIPT' },
{ label: '部分入库', value: 'PARTIALLY_RECEIVED' },
{ label: '已驳回', value: 'REJECTED' },
{ label: '待分配', value: 'PENDING_ASSIGNMENT' },
{ label: '待换标', value: 'WAIT_RELABEL' },
{ label: '待排车', value: 'READY_FOR_DISPATCH' },
{ label: '待出库', value: 'WAIT_OUTBOUND' },
{ label: '待入库', value: 'PENDING_INBOUND' },
{ label: '入库完成', value: 'INBOUND_COMPLETED' },
{ label: '全部入库', value: 'INBOUND_COMPLETED' },
{ label: '待派送', value: 'PENDING_DELIVERY' },
{ label: '派送中', value: 'DELIVERING' },
{ label: '派送完成', value: 'DELIVERED' },
@@ -763,8 +767,14 @@ async function submitHold() {
} catch (error) { actionError.value = getApiErrorDetails(error, '暂扣状态保存失败,请重试') }
finally { actionLoading.value = false }
}
const manualNodeExcludedOrderTypes = new Set(['RELABEL_APPLICATION', 'WAREHOUSE_TRANSFER', 'LOCAL_LOOSEBOARD', 'RETURN_RELABEL', 'CARGO_DISCARD'])
/** 独立仓内业务不提供头程运输节点修改入口。 */
function canEditManualNodes(row: LastMileOrderListItem) {
return !manualNodeExcludedOrderTypes.has(row.orderTypeCode)
}
async function openManualNodes(source: object) {
const row = source as LastMileOrderListItem
if (!canEditManualNodes(row)) return
actionTarget.value = row
manualNodesOpen.value = true
manualNodesLoading.value = true
@@ -1078,7 +1088,7 @@ function lastMileRowActions(row: LastMileOrderListItem): RowActionItem[] {
{ key: 'review', label: '审核', priority: 450, tone: 'warning', visible: access.can(permission.warehouseTransferApprove) && row.orderTypeCode === 'WAREHOUSE_TRANSFER' && row.currentStatusCode === 'PENDING_REVIEW' },
{ key: 'inbound-plan', label: '入库', priority: 400, visible: access.can(permission.inboundOrderCreate) && canGenerateInboundPlan(row) && !inboundPlanCreated },
{ key: 'hold', label: row.detained ? '解除暂扣' : '暂扣订单', priority: 300, tone: 'warning', visible: access.can(permission.lastMileOrderTransfer) && (row.detained || row.currentStatusCode === 'INBOUND_COMPLETED') },
{ key: 'manual-nodes', label: '修改节点', priority: 200, visible: access.can(permission.lastMileOrderTransfer) },
{ key: 'manual-nodes', label: '修改节点', priority: 200, visible: access.can(permission.lastMileOrderTransfer) && canEditManualNodes(row) },
{ key: 'confirm-relabel', label: '换标完成确认', priority: 150, visible: access.can(permission.relabelOrderStatus) && Boolean(row.relabelOrderNo) && canConfirmRelabel(row) },
{ key: 'rework-relabel', label: '换标退回重做', priority: 100, tone: 'danger', divided: true, visible: access.can(permission.relabelOrderStatus) && canReworkRelabel(row) },
]