merge: integrate TEST changes with fleet photo updates

This commit is contained in:
2026-10-26 17:21:14 +08:00
79 changed files with 3489 additions and 129 deletions
@@ -14,6 +14,9 @@ const api = vi.hoisted(() => ({ customers: vi.fn(), list: vi.fn(), detail: vi.fn
const can = vi.hoisted(() => vi.fn((_code: string) => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: api.customers }))
vi.mock('@/modules/overseas-business', () => ({ BusinessDocumentFees: {
name: 'BusinessDocumentFees', props: ['businessType', 'businessId', 'businessNo', 'customerId'], template: '<div>本单费用操作</div>',
} }))
vi.mock('../api/cargoDiscards', () => ({ listCargoDiscards: api.list, getCargoDiscard: api.detail, reviewCargoDiscard: api.review, downloadCargoDiscardPhoto: api.photo, getCargoDiscardStatistics: api.statistics }))
enableAutoUnmount(afterEach)
@@ -40,6 +43,14 @@ 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('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({
businessType: 'DISCARD_ORDER', businessId: '9007199254740993', businessNo: 'CD-001', customerId: '9007199254740994',
})
await dialog.get('.app-dialog__footer .el-button').trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'BusinessDocumentFees' }).exists()).toBe(false)
})
it('preserves all readonly facts, full item values and history through fullscreen', async () => {
const { wrapper, dialog } = await open()
expect(api.statistics).not.toHaveBeenCalled()
@@ -134,6 +145,7 @@ describe('native cargo discard detail and approval', () => {
expect(api.photo).toHaveBeenLastCalledWith('CD-002', '9007199254740996', 'CUSTOMER-2')
resolve(new Blob(['old'], { type: 'image/png' })); await flushPromises()
expect(URL.createObjectURL).toHaveBeenCalledOnce(); expect(dialog.props('title')).toBe('废弃单详情 · CD-002')
expect(dialog.getComponent({ name: 'BusinessDocumentFees' }).props()).toMatchObject({ businessNo: 'CD-002', customerId: 'CUSTOMER-2' })
})
it('ignores a detail response after unmounting before it can start photo downloads', async () => {
let resolve!: (value: CargoDiscardDetail) => void
@@ -15,6 +15,7 @@ import LineClampText from '@/shared/components/LineClampText.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { downloadCargoDiscardPhoto, listCargoDiscards, getCargoDiscard, reviewCargoDiscard, type CargoDiscardDetail, type CargoDiscardSummary } from '../api/cargoDiscards'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
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: '' })
@@ -90,6 +91,10 @@ onMounted(() => { void loadCustomers(); void load() })
<tr data-tone="source"><th>仓库</th><td>{{ detail.summary.overseasWarehouseName || '—' }}</td><th>入仓码</th><td>{{ detail.summary.inboundCodes || '—' }}</td></tr>
<tr data-tone="work"><th>状态</th><td>{{ statusLabels[detail.summary.status || ''] || detail.summary.status || '未记录' }}</td><th>备注</th><td>{{ detail.remark || '—' }}</td></tr>
</tbody></table>
<section v-if="detailOpen" aria-label="废弃单费用明细">
<h3>费用明细</h3>
<BusinessDocumentFees :key="detail.summary.id" business-type="DISCARD_ORDER" :business-id="detail.summary.id" :business-no="detail.summary.discardNo" :customer-id="detail.summary.customerId || undefined" />
</section>
<h3>废弃明细</h3><el-table :data="detail.items" border>
<el-table-column label="入仓码"><template #default="{ row }"><LineClampText :content="row.inboundCode || ''" /></template></el-table-column>
<el-table-column label="SKU"><template #default="{ row }"><LineClampText :content="row.skuCode || ''" /></template></el-table-column>
@@ -7,6 +7,9 @@ import DeliveryOrderDetailView from './DeliveryOrderDetailView.vue'
const routeState = vi.hoisted(() => ({ params: { deliveryNo: 'DEL/1' }, query: { customerId: '100' } as Record<string, string> }))
vi.mock('vue-router', () => ({ useRoute: () => routeState, useRouter: () => ({ push: vi.fn() }) }))
vi.mock('../api/deliveryOrders', () => ({ getDeliveryOrder: vi.fn() }))
vi.mock('@/modules/overseas-business', () => ({ BusinessDocumentFees: {
name: 'BusinessDocumentFees', props: ['businessType', 'businessNo', 'customerId', 'businessId'], template: '<section>本单费用</section>',
} }))
describe('DeliveryOrderDetailView', () => {
beforeEach(() => {
@@ -34,6 +37,10 @@ describe('DeliveryOrderDetailView', () => {
expect(wrapper.text()).toContain('洛杉矶仓')
expect(wrapper.text()).toContain('ACY2')
await wrapper.find('.el-tabs__item:nth-child(3)').trigger('click')
expect(wrapper.findComponent({ name: 'BusinessDocumentFees' }).props()).toEqual({
businessType: 'DELIVERY_ORDER', businessNo: 'DEL/1', customerId: '100', businessId: '1',
})
expect(wrapper.text()).toContain('履约费用记录')
expect(wrapper.text()).toContain('私人仓尾程派送费')
expect(wrapper.text()).toContain('派送费用')
expect(wrapper.text()).toContain('作废待财务确认')
@@ -48,6 +55,7 @@ describe('DeliveryOrderDetailView', () => {
await flushPromises()
expect(getDeliveryOrder).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('缺少派送单客户范围')
expect(wrapper.findComponent({ name: 'BusinessDocumentFees' }).exists()).toBe(false)
wrapper.unmount()
})
it('shows frozen original coverage separately from new fees', async () => {
@@ -13,6 +13,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { formatOverseasDateTime } from '../lastMilePresentation'
import { getDeliveryOrder, type DeliveryOrderDetail, type DeliveryFeeRow } from '../api/deliveryOrders'
import { coveredServiceLabels } from '../feeCoverageTypes'
import { BusinessDocumentFees } from '@/modules/overseas-business'
const route = useRoute()
const router = useRouter()
@@ -102,13 +103,15 @@ watch([deliveryNo, customerId], () => { void load() }, { immediate: true })
</section>
</el-tab-pane>
<el-tab-pane name="fees"><template #label><span class="density-tab-icon" aria-hidden="true">💰</span><span>{{ `费用明细 (${detail?.fees.length || 0})` }}</span></template>
<el-tab-pane name="fees"><template #label><span class="density-tab-icon" aria-hidden="true">💰</span><span>费用明细</span></template>
<section class="tab-content" aria-label="派送单费用明细">
<BusinessDocumentFees v-if="detail" business-type="DELIVERY_ORDER" :business-no="detail.order.deliveryNo" :customer-id="detail.order.customerId" :business-id="detail.order.id" />
<template v-if="detail?.feeCoverage?.references.length">
<p>本次已确认的原费用覆盖({{ formatOverseasDateTime(detail.feeCoverage.confirmedAt) }}):原费用金额供核对,不计作本次新增。</p>
<p v-for="reference in detail.feeCoverage.references" :key="`${reference.source.financeFeeId}-${reference.services.join('-')}`">{{ detail.feeCoverage.cargo.find(cargo => cargo.sourceInboundId === reference.source.sourceInboundId)?.inboundCode || reference.source.inboundCode || '—' }} · {{ reference.source.feeItemName || '—' }} · {{ reference.source.currency }} {{ reference.source.amount }} · 版本 {{ reference.source.sourceVersion }} · 已含{{ reference.services.map(service => coveredServiceLabels[service]).join('、') }}</p>
</template>
<el-table :data="detail?.fees || []" border stripe empty-text="暂无费用明细">
<h2 v-if="detail?.fees.length" class="fulfillment-fees-heading">履约费用记录</h2>
<el-table v-if="detail?.fees.length" :data="detail.fees" border stripe empty-text="暂无履约费用">
<el-table-column type="index" label="序号" width="80" /><el-table-column label="应收/应付" width="120"><template #default="scope">{{ scope.row.feeDirection === 'PAYABLE' ? '应付' : scope.row.feeDirection === 'RECEIVABLE' ? '应收' : show(scope.row.feeDirection) }}</template></el-table-column>
<el-table-column label="费用类型" min-width="180"><template #default="scope">{{ feeLabels[scope.row.feeType] || scope.row.feeType }}</template></el-table-column><el-table-column prop="currency" label="币种" width="100" />
<el-table-column prop="amount" label="金额" min-width="140" align="right" /><el-table-column label="费用状态" min-width="165"><template #default="scope">{{ feeStatus(scope.row as DeliveryFeeRow) }}</template></el-table-column><el-table-column prop="remark" label="备注" min-width="240" />
@@ -142,6 +145,7 @@ watch([deliveryNo, customerId], () => { void load() }, { immediate: true })
.title-line h1 { margin: 0; font-size: var(--admin-font-section); line-height: 28px; font-weight: 500; }
.delivery-detail { min-height: 0; overflow: hidden; border: 0; border-radius: 0; }
.tab-content { width: 100%; padding: 0; box-sizing: border-box; }
.tab-content .fulfillment-fees-heading { margin-top: var(--admin-space-5); }
.info-item--wide { grid-column: 1 / -1; }
</style>
@@ -0,0 +1,43 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getInboundPlan } from '../api/inboundPlans'
import InboundOrderDetailView from './InboundOrderDetailView.vue'
const route = vi.hoisted(() => ({ params: { orderId: 'PLAN-1' }, query: { customerId: '9007199254741001' } as Record<string, string> }))
vi.mock('vue-router', () => ({ useRoute: () => route, useRouter: () => ({ push: vi.fn() }) }))
vi.mock('../api/inboundPlans', () => ({ getInboundPlan: vi.fn(), moveInboundPallet: vi.fn() }))
vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null }))
vi.mock('@/modules/overseas-business', () => ({ BusinessDocumentFees: {
name: 'BusinessDocumentFees', props: ['businessType', 'businessNo', 'customerId', 'businessId'], template: '<section>本单费用</section>',
} }))
describe('InboundOrderDetailView fees', () => {
beforeEach(() => {
route.query = { customerId: '9007199254741001' }
vi.mocked(getInboundPlan).mockReset().mockResolvedValue({
id: '9007199254741002', planNo: 'PLAN-1', customerId: '9007199254741001', sourceOrderId: '9007199254741003',
lineCount: 0, palletCount: 0, totalGrossWeightKg: 0, totalNetWeightKg: 0, totalVolumeCbm: 0, lines: [], pallets: [], allocations: [],
})
})
it('uses the loaded inbound plan identity without substituting its source order', async () => {
const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: true, AppDialog: true } } })
await flushPromises()
expect(getInboundPlan).toHaveBeenCalledWith('PLAN-1', '9007199254741001')
expect(wrapper.findComponent({ name: 'BusinessDocumentFees' }).props()).toEqual({
businessType: 'OVERSEAS_INBOUND', businessNo: 'PLAN-1', customerId: '9007199254741001', businessId: '9007199254741002',
})
expect(wrapper.text()).not.toContain('当前入库计划详情接口未提供应收费用')
wrapper.unmount()
})
it('does not expose fee operations when the customer scope is absent', async () => {
route.query = {}
const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: true, AppDialog: true } } })
await flushPromises()
expect(getInboundPlan).not.toHaveBeenCalled()
expect(wrapper.findComponent({ name: 'BusinessDocumentFees' }).exists()).toBe(false)
wrapper.unmount()
})
})
@@ -3,6 +3,7 @@ import '@/styles/adminWorkbench.css'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import '@/styles/adminDensity.css'
import { appointmentReturnTarget } from '@/modules/appointment-calendar'
import { BusinessDocumentFees } from '@/modules/overseas-business'
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
@@ -45,7 +46,6 @@ const summaryGroups = [
{ title: '仓储信息', fields: ['海外仓', 'FBA仓库', '总箱数', '应拣货数', '实际拣货数', '货物毛重(kg)', '货物净重(kg)', '计费体积(m³)', '总托盘数'] },
]
const appointmentFields = ['预约状态', '预约送仓时间', '预计装车时间', '排车状态', '创建时间', '更新时间', '备注']
const feeColumns: DataTableColumn[] = [{ key: 'index', label: '序号', width: 70, align: 'center' }, { key: 'name', label: '费用项目', minWidth: 180 }, { key: 'currency', label: '币种', width: 100 }, { key: 'amount', label: '金额', width: 140, align: 'right' }, { key: 'remark', label: '备注', minWidth: 200 }]
const palletColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 150 },
@@ -101,7 +101,7 @@ onMounted(load)
<template v-else><section v-loading="loading" class="detail-section" aria-label="入库单基本信息"><div class="detail-groups"><section v-for="group in summaryGroups" :key="group.title" class="detail-group" :aria-label="group.title"><h2>{{ group.title }}</h2><dl class="detail-fields density-fields"><div v-for="label in group.fields" :key="label"><dt>{{ label }}</dt><dd>{{ fieldValues[label] }}</dd></div></dl></section></div></section>
<section class="detail-section pallet-section" aria-labelledby="inbound-pallet-heading"><div class="section-heading"><div><h2 id="inbound-pallet-heading">入库单托盘明细<InlineHelp title="托盘明细说明"><p>PDA 合托后,参与合托的入库单会分别展示同一托盘及本单对应箱数;A0 为系统共享溢出位。</p></InlineHelp></h2></div><InboundShortageConfirmation v-if="detail" :detail="detail" @refresh="load" /><span class="section-count">{{ palletRows.length }} 条</span></div><div class="detail-table-wrap"><div class="workbench-table"><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘明细"><template #actions="{ row }"><el-button class="density-action" size="small" :disabled="moveSaving || moveLocationsLoading || !(row as typeof palletRows[number]).palletNo || (row as typeof palletRows[number]).palletNo === '—' || (row as typeof palletRows[number]).palletVersion == null" @click="openMove(row as typeof palletRows[number])">移库</el-button></template></DataTable></div></div></section>
<section class="detail-section appointment-section" aria-labelledby="inbound-appointment-heading"><div class="compact-section-heading"><h2 id="inbound-appointment-heading">预约与排车</h2></div><dl class="detail-fields appointment-fields density-fields"><div v-for="label in appointmentFields" :key="label" :class="{ 'is-wide': label === '备注' }"><dt>{{ label }}</dt><dd>{{ fieldValues[label] }}</dd></div></dl></section>
<section class="detail-section fee-section" aria-labelledby="inbound-fees-heading"><div class="compact-section-heading"><h2 id="inbound-fees-heading">应收费用明细</h2><span>费用结果以服务端为准</span></div><div class="detail-table-wrap"><div class="workbench-table"><DataTable :data="[]" :columns="feeColumns" column-settings-mode="hide" empty-text="当前入库计划详情接口未提供应收费用" /></div></div><footer class="fee-total"><span>应收费用合计(USD)</span><strong>—</strong></footer></section></template>
<section v-if="detail" class="detail-section fee-section" aria-label="入库单费用"><BusinessDocumentFees business-type="OVERSEAS_INBOUND" :business-no="detail.planNo" :customer-id="detail.customerId" :business-id="detail.id" /></section></template>
</OverseasDetailShell><AppDialog v-model="moveOpen" class="admin-density-dialog admin-workbench is-dialog" :title="`托盘移库 · ${moveRow?.palletNo || '—'}`" width="min(560px, calc(100vw - 28px))" fullscreenable confirm-text="提交移库" :confirm-loading="moveSaving" :confirm-disabled="moveLocationsLoading || !!moveLocationError || !targetLocationCode || !moveReason.trim()" @confirm="submitMove"><div class="density-dialog-sections">
<section class="density-dialog-section" data-tone="source" aria-label="移库来源"><h3 class="density-dialog-section__title">来源信息</h3><table class="density-table"><tbody><tr><th scope="row">入库单号</th><td>{{ referenceValue }}</td></tr><tr><th scope="row">托盘号</th><td>{{ moveRow?.palletNo || '—' }}</td></tr></tbody></table></section>
<LoadErrorState v-if="moveLocationError" compact title="可用库位加载失败" :message="moveLocationError.message" :loading="moveLocationsLoading" @retry="loadMoveLocations"><template #details><ApiErrorMeta :details="moveLocationError" /></template></LoadErrorState>
@@ -118,7 +118,7 @@ onMounted(load)
.compact-section-heading { display:flex; min-height:42px; align-items:center; justify-content:space-between; gap:var(--admin-space-5); padding:var(--admin-space-2) var(--admin-space-5); border-bottom:1px solid var(--workbench-border); }
.compact-section-heading h2 { margin:0; color:var(--workbench-text); font-size:var(--admin-font-section); }
.compact-section-heading span { color:var(--workbench-muted); font-size:var(--admin-font-caption); }
.fee-section { overflow:hidden; }.fee-total { display:flex; min-height:44px; align-items:center; justify-content:flex-end; gap:40px; margin:0 var(--admin-space-5) var(--admin-space-4); padding:var(--admin-space-3) var(--admin-space-5); border:1px solid var(--workbench-border); border-top:0; }.fee-total strong { min-width:120px; font-size:var(--admin-font-section); }
.fee-section { overflow:hidden; padding:var(--admin-space-5); }
.pallet-section { overflow: hidden; }.section-heading { display:flex; min-height:50px; align-items:center; justify-content:space-between; gap:var(--admin-space-5); padding:var(--admin-space-3) var(--admin-space-5); border-bottom:1px solid var(--workbench-border); }.pallet-section h2 { margin: 0 0 var(--admin-space-1); color: var(--workbench-text); font-size: var(--admin-font-section); }.pallet-note { margin: 0; color: var(--workbench-muted); font-size: var(--admin-font-caption); }.section-count { flex:0 0 auto; color:var(--workbench-muted); font-size:var(--admin-font-caption); font-variant-numeric:tabular-nums; }
.appointment-section,.fee-section { border-top:1px solid var(--workbench-border); }
.appointment-section { padding:0 var(--admin-space-5) var(--admin-space-4); }
@@ -133,6 +133,5 @@ onMounted(load)
.detail-fields { --admin-field-label-width: 108px; grid-template-columns: repeat(5, minmax(0, 1fr)); }
:deep(.density-detail-header) { border-bottom: 1px solid var(--workbench-border); }
.detail-table-wrap { padding:var(--admin-space-3) var(--admin-space-5) var(--admin-space-4); }
.fee-section .detail-table-wrap { padding-bottom:0; }
</style>
@@ -7,11 +7,14 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { ApiResponseError } from '@/shared/api/http'
import { listSuppliers, type Supplier } from '@/modules/suppliers'
import { listOverseasFeeItems, type OverseasFee } from '@/modules/overseas-business'
import { getPlatformWarehouse } from '@/modules/warehouses'
import { getWarehouseTransfer } from '../api/warehouseTransfers'
const can = vi.hoisted(() => vi.fn(() => false))
const push = vi.hoisted(() => vi.fn())
const getDocumentFees = vi.hoisted(() => vi.fn(async (): Promise<OverseasFee[]> => []))
vi.mock('@/modules/overseas-business', async () => ({ BusinessDocumentFees: { name: 'BusinessDocumentFees', props: { businessType: String, businessId: String, businessNo: String, customerId: String, relatedDocuments: Array, direction: String, canCreate: Boolean, hideWhenEmpty: Boolean, heading: String }, template: '<div><slot name="toolbar-actions" /></div>', methods: { refresh() {}, getFees: getDocumentFees } }, listOverseasFeeItems: vi.fn(), sameProjectFees: (await import('../../overseas-business/overseasFeePresentation')).sameProjectFees, sameProjectFeeSummary: (await import('../../overseas-business/overseasFeePresentation')).sameProjectFeeSummary, feeStatus: (await import('../../overseas-business/overseasFeePresentation')).feeStatus, overseasBusinessPermissions: (await import('../../overseas-business/permissions')).overseasBusinessPermissions }))
vi.mock('@/modules/warehouses', () => ({ getPlatformWarehouse: vi.fn() }))
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'internal-order-id' } }), useRouter: () => ({ push }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
@@ -30,14 +33,12 @@ async function openFeeEditor() {
}
async function fillFee(dialog: VueWrapper, direction: 'RECEIVABLE' | 'PAYABLE') {
const selects = dialog.findAllComponents({ name: 'ElSelect' })
selects[0]!.vm.$emit('update:modelValue', 'STORAGE_FEE'); selects[0]!.vm.$emit('change', 'STORAGE_FEE')
selects[1]!.vm.$emit('update:modelValue', direction); await flushPromises()
selects[0]!.vm.$emit('update:modelValue', 'FEE_TYPE_071')
if (direction === 'PAYABLE') dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '请选择供应商')!.vm.$emit('update:modelValue', feeSupplier.id)
dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '请选择币种')!.vm.$emit('update:modelValue', direction === 'PAYABLE' ? 'CNY' : 'USD')
const numbers = dialog.findAllComponents({ name: 'ElInputNumber' })
numbers[0]!.vm.$emit('update:modelValue', 80); numbers[1]!.vm.$emit('update:modelValue', 2)
const dates = dialog.findAllComponents({ name: 'ElDatePicker' })
dates[0]!.vm.$emit('update:modelValue', '2026-10-20'); dates[1]!.vm.$emit('update:modelValue', '2026-10-01'); dates[2]!.vm.$emit('update:modelValue', '2026-10-20')
await flushPromises()
await dialog.get('input[placeholder="如 箱、托、天、m³"]').setValue('托')
}
@@ -130,9 +131,11 @@ describe('LastMileOrderDetailView domestic detail alignment', () => {
})
beforeEach(() => {
getDocumentFees.mockReset().mockResolvedValue([])
can.mockReset().mockReturnValue(false)
vi.mocked(listOverseasFeeItems).mockReset().mockResolvedValue([{ feeCode: 'FEE_TYPE_071', feeTypeName: '贴标费', defaultPrice: 1 }, { feeCode: 'FEE_TYPE_077', feeTypeName: '仓租', defaultPrice: 1 }])
vi.mocked(listSuppliers).mockReset().mockResolvedValue({ records: [feeSupplier], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(createLastMileOrderFee).mockReset().mockResolvedValue({ id: 'fee-test', sourceFeeId: 'source-fee-test', sourceVersion: 1, customerId: 'customer-1', feeItemCode: 'STORAGE_FEE', feeItemName: '仓租', direction: 'RECEIVABLE', currency: 'CNY', amount: 80, originalCurrency: 'USD', originalAmount: 80, exchangeRate: 1, exchangeRateMonth: '2026-10', reviewStatus: 'PENDING', publicationStatus: 'PENDING', sourceStatus: 'AVAILABLE', businessDate: '2026-10-20', createdAt: '', updatedAt: '' })
vi.mocked(createLastMileOrderFee).mockReset().mockResolvedValue({ id: 'fee-test', sourceFeeId: 'source-fee-test', sourceVersion: 1, customerId: 'customer-1', feeItemCode: 'FEE_TYPE_071', feeItemName: '贴标费', direction: 'RECEIVABLE', currency: 'CNY', amount: 80, originalCurrency: 'USD', originalAmount: 80, exchangeRate: 1, exchangeRateMonth: '2026-10', reviewStatus: 'PENDING', publicationStatus: 'PENDING', sourceStatus: 'AVAILABLE', businessDate: '2026-10-20', createdAt: '', updatedAt: '' })
push.mockReset()
vi.mocked(getWarehouseTransfer).mockReset()
vi.mocked(getPlatformWarehouse).mockReset()
@@ -252,14 +255,19 @@ describe('LastMileOrderDetailView domestic detail alignment', () => {
const { dialog } = await openFeeEditor()
expect(dialog.props('title')).toContain('FL-WH-001')
expect(dialog.props('width')).toContain('720px')
expect(dialog.text()).not.toContain('业务日期')
expect(dialog.text()).not.toContain('计费开始日期')
expect(dialog.text()).not.toContain('计费结束日期')
expect(dialog.findComponent({ name: 'ElDatePicker' }).exists()).toBe(false)
expect(dialog.get('[aria-label="尾程费用登记"]').attributes('data-tone')).toBeUndefined()
await fillFee(dialog, direction)
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
await dialog.findAll('button').find(button => button.text() === '保存费用')!.trigger('click'); await flushPromises()
const payable = direction === 'PAYABLE'
expect(createLastMileOrderFee).toHaveBeenCalledWith('internal-order-id', {
feeItemCode: 'STORAGE_FEE', feeItemName: '仓租', direction, currency: payable ? 'CNY' : 'USD', amount: 80, businessDate: '2026-10-20',
feeItemCode: 'FEE_TYPE_071', feeItemName: '贴标费', direction, currency: payable ? 'CNY' : 'USD', amount: 80, businessDate: new Date().toISOString().slice(0, 10),
supplierId: payable ? feeSupplier.id : undefined, supplierName: payable ? feeSupplier.name : undefined, supplierCategory: payable ? feeSupplier.category : undefined,
settlementMode: payable ? 'ON_ACCOUNT' : undefined, chargeableQuantity: 2, quantityUnit: '托', unitPrice: 40, periodStart: '2026-10-01', periodEnd: '2026-10-20',
settlementMode: payable ? 'ON_ACCOUNT' : undefined, chargeableQuantity: 2, quantityUnit: '托', unitPrice: 40,
})
})
it('keeps fee required-field validation before a request', async () => {
@@ -267,6 +275,98 @@ 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 () => {
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'])
await fillFee(dialog, 'PAYABLE')
expect(listOverseasFeeItems).toHaveBeenLastCalledWith('PAYABLE')
expect(listSuppliers).toHaveBeenCalledOnce()
})
it('blocks saving when the catalog fails and supports retry', async () => {
vi.mocked(listOverseasFeeItems).mockRejectedValueOnce(new Error('目录暂不可用'))
const { dialog } = await openFeeEditor()
expect(dialog.props('confirmDisabled')).toBe(true)
expect(dialog.text()).toContain('费用项目加载失败')
await dialog.findAll('button').find(button => button.text() === '重新加载')!.trigger('click'); await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(false)
expect(createLastMileOrderFee).not.toHaveBeenCalled()
})
it('keeps supplier failures in the editor, blocks payables and reloads the shared supplier list', async () => {
vi.mocked(listSuppliers).mockRejectedValueOnce(new ApiResponseError('仓储数据库暂不可用', 'S0503', 503, null, 'supplier-request'))
const { dialog } = await openFeeEditor()
await fillFee(dialog, 'PAYABLE')
expect(listSuppliers).toHaveBeenCalledWith({ page: 1, size: 200, status: 'ACTIVE' })
expect(dialog.text()).toContain('供应商加载失败')
expect(dialog.props('confirmDisabled')).toBe(true)
await dialog.findAll('button').find(button => button.text() === '保存费用')!.trigger('click'); await flushPromises()
expect(createLastMileOrderFee).not.toHaveBeenCalled()
await dialog.findAll('button').find(button => button.text() === '重试加载供应商')!.trigger('click'); await flushPromises()
expect(dialog.text()).not.toContain('供应商加载失败')
expect(dialog.props('confirmDisabled')).toBe(false)
const supplier = dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '请选择供应商')!
expect(supplier.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).toEqual([feeSupplier.id])
})
it('ignores a supplier response after switching back to customer receivables', async () => {
let reject!: (reason: unknown) => void
vi.mocked(listSuppliers).mockReturnValueOnce(new Promise((_, no) => { reject = no }))
const { dialog } = await openFeeEditor()
const direction = dialog.findAllComponents({ name: 'ElSelect' })[1]!
direction.vm.$emit('update:modelValue', 'PAYABLE'); await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(true)
direction.vm.$emit('update:modelValue', 'RECEIVABLE'); await flushPromises()
reject(new Error('旧请求失败')); await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(false)
expect(dialog.text()).not.toContain('供应商加载失败')
})
it('places fees and related source charges inside the exact order without a duplicated receivable table', async () => {
const result = await getLastMileOrder('internal-order-id')
vi.mocked(getLastMileOrder).mockResolvedValue({ ...result, order: { ...result.order, orderType: 'RETURN_RELABEL', sourceOrderNo: 'TH-01' } })
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises()
await wrapper.get('#tab-fees').trigger('click'); await flushPromises()
const panels = wrapper.findAllComponents({ name: 'BusinessDocumentFees' })
expect(panels).toHaveLength(2)
for (const [index, panel] of panels.entries()) expect(panel.props()).toMatchObject({
businessType: 'LASTLEG_ORDER', businessId: result.order.id, businessNo: result.order.orderNo, customerId: result.order.customerId,
direction: index === 0 ? 'RECEIVABLE' : 'PAYABLE',
relatedDocuments: [{ businessType: 'RETURN_ORDER', businessNo: 'TH-01', customerId: result.order.customerId }],
})
expect(panels[0]!.props()).toMatchObject({ heading: '应收明细', hideWhenEmpty: true, canCreate: false })
expect(panels[1]!.props()).toMatchObject({ heading: '应付明细', hideWhenEmpty: true, canCreate: false })
expect(wrapper.findAll('#pane-fees .el-table').length).toBe(0)
})
it('uses one create button and selects payable inside the shared editor', async () => {
can.mockReturnValue(true)
const wrapper = mount(LastMileOrderDetailView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
await wrapper.get('#tab-fees').trigger('click'); await flushPromises()
const createButtons = wrapper.findAll('#pane-fees button').filter(button => button.text().trim() === '新增费用')
expect(createButtons).toHaveLength(1)
await createButtons[0]!.trigger('click'); await flushPromises()
const dialog = wrapper.findComponent(AppDialog)
expect(dialog.findAllComponents({ name: 'ElSelect' })[1]!.props('modelValue')).toBe('RECEIVABLE')
await fillFee(dialog, 'PAYABLE')
expect(listOverseasFeeItems).toHaveBeenCalledWith('PAYABLE')
expect(listSuppliers).toHaveBeenCalledWith({ page: 1, size: 200, status: 'ACTIVE' })
await dialog.findAll('button').find(button => button.text() === '保存费用')!.trigger('click'); await flushPromises()
expect(createLastMileOrderFee).toHaveBeenCalledWith('internal-order-id', expect.objectContaining({
direction: 'PAYABLE', supplierId: feeSupplier.id, settlementMode: 'ON_ACCOUNT',
}))
})
it('shows existing amount and approval before allowing another fee with a reason', async () => {
getDocumentFees.mockResolvedValue([{ sourceFeeId: 'old-fee', feeItemCode: 'FEE_TYPE_071', direction: 'RECEIVABLE', sourceStatus: 'ACTIVE', reviewStatus: 'APPROVED', originalCurrency: 'USD', originalAmount: 80 } as OverseasFee])
const { dialog } = await openFeeEditor()
await fillFee(dialog, 'RECEIVABLE')
expect(dialog.text()).toContain('USD 80.00 · 已通过')
await dialog.findAll('button').find(button => button.text() === '保存费用')!.trigger('click'); await flushPromises()
expect(createLastMileOrderFee).not.toHaveBeenCalled()
await dialog.get('textarea[placeholder="补收或重复作业时,请说明原因"]').setValue('第二次贴标作业')
await dialog.findAll('button').find(button => button.text() === '保存费用')!.trigger('click'); await flushPromises()
expect(createLastMileOrderFee).toHaveBeenCalledWith('internal-order-id', expect.objectContaining({ feeRemark: '第二次贴标作业' }))
})
it('locks fee controls and preserves failed values and diagnostic context', async () => {
let reject!: (reason: unknown) => void
vi.mocked(createLastMileOrderFee).mockReturnValue(new Promise((_, no) => { reject = no }))
@@ -8,6 +8,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getPlatformWarehouse } from '@/modules/warehouses'
import { listSuppliers, type Supplier } from '@/modules/suppliers'
import { BusinessDocumentFees, listOverseasFeeItems, sameProjectFees, sameProjectFeeSummary, type OverseasFee } from '@/modules/overseas-business'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
@@ -28,7 +29,6 @@ import TransferPickupProgressPanel from '../components/TransferPickupProgressPan
interface DetailField { label: string; value: string | number; wide?: boolean }
interface CargoRow { id: string; sku: string; productName: string; pallets: number; cartons: number; quantity: number; weight: number; volume: number }
interface DocumentRow { status?: string; quantity?: number; type: string; number: string; createdAt: string; quote: string }
interface FeeRow { id: string; item: string; currency: string; amount: string; status: string; description: string }
interface LogRow { id: string; occurredAt: string; node: string; operator: string; result: string; sortAt: string }
const route = useRoute()
@@ -56,9 +56,27 @@ const loadError = ref<ApiErrorDetails | null>(null)
const mutationError = ref<ApiErrorDetails | null>(null)
const activeTab = ref('basic')
const feeDialogOpen = ref(false)
const feePanel = ref<InstanceType<typeof BusinessDocumentFees>>()
const payableFeePanel = ref<InstanceType<typeof BusinessDocumentFees>>()
const relatedFeeDocuments = computed(() => {
const order = detail.value?.order
const sourceTypes: Record<string, string> = { WAREHOUSE_TRANSFER: 'WAREHOUSE_TRANSFER', RETURN_RELABEL: 'RETURN_ORDER', RELABEL_APPLICATION: 'RELABEL_ORDER', CARGO_DISCARD: 'DISCARD_ORDER', LOCAL_LOOSEBOARD: 'LOCAL_LOOSEBOARD' }
const businessType = sourceTypes[order?.orderType || '']
const businessNo = order?.orderType === 'RELABEL_APPLICATION' ? order.relabelOrderNo || order.sourceOrderNo : order?.sourceOrderNo
return order && businessNo && businessType ? [{ businessType, businessNo, customerId: order.customerId }] : []
})
const supplierOptions = ref<Supplier[]>([])
const feeForm = reactive({ itemCode: '', itemName: '', direction: 'RECEIVABLE' as 'RECEIVABLE' | 'PAYABLE', currency: 'USD' as 'CNY' | 'USD', amount: 0, businessDate: new Date().toISOString().slice(0, 10), supplierId: '', quantity: undefined as number | undefined, quantityUnit: '', periodStart: '', periodEnd: '' })
const feeItemOptions = [{ code: 'STORAGE_FEE', name: '仓租' }, { code: 'INBOUND_OPERATION', name: '入库操作费' }, { code: 'OUTBOUND_OPERATION', name: '出库操作费' }, { code: 'RELABEL_FEE', name: '换标费' }, { code: 'REBOX_FEE', name: '换箱费' }, { code: 'PALLET_FEE', name: '托盘费' }, { code: 'DELIVERY_FEE', name: '派送费' }, { code: 'TRANSFER_FEE', name: '转仓费' }, { code: 'EXCEPTION_FEE', name: '异常处理费' }, { code: 'OTHER', name: '其他' }]
const suppliersLoading = ref(false)
const suppliersError = ref<ApiErrorDetails | null>(null)
let supplierRequest = 0
const feeForm = reactive({ itemCode: '', itemName: '', feeRemark: '', settlementMode: '' as '' | 'BILL' | 'WALLET' | 'ON_ACCOUNT' | 'DIRECT_PAID', direction: 'RECEIVABLE' as 'RECEIVABLE' | 'PAYABLE', currency: 'USD' as 'CNY' | 'USD', amount: 0, businessDate: new Date().toISOString().slice(0, 10), supplierId: '', quantity: undefined as number | undefined, quantityUnit: '' })
const existingFees = ref<OverseasFee[]>([]), existingFeesLoading = ref(false), existingFeesError = ref('')
const duplicateFees = computed(() => sameProjectFees(existingFees.value, feeForm.itemCode, feeForm.direction))
let existingFeesRequest = 0
const feeItemOptions = ref<Awaited<ReturnType<typeof listOverseasFeeItems>>>([])
const feeItemsLoading = ref(false)
const feeItemsError = ref('')
let feeItemsRequest = 0
function text(value: unknown) { return String(value ?? '').trim() || '—' }
function containerNodeLabel(value: unknown) {
@@ -71,7 +89,6 @@ function containerNodeLabel(value: unknown) {
} as Record<string, string>)[code] || text(code)
}
function yesNo(value: boolean | null | undefined) { return value == null ? '—' : value ? '是' : '否' }
function feeStatusLabel(value: string) { return ({ PENDING_REVIEW: '待审核', APPROVED: '审核通过', REJECTED: '已驳回' } as Record<string, string>)[value] || value }
function isSingleLineBusinessField(field: DetailField) { return field.label === '入仓码' || field.label === '集装箱号' }
const referenceValue = computed(() => lastMileOrderReference(detail.value?.order.orderNo, '订单编码待补充'))
@@ -205,10 +222,6 @@ const cargo = computed<CargoRow[]>(() => isReturnOrder.value
id: String(item.id), sku: text(item.skuCode), productName: text(item.productNameZh), pallets: Number(item.palletCount || 0),
cartons: Number(item.packageCount || 0), quantity: Number(item.productQuantity || 0), weight: Number(item.grossWeightKg || 0), volume: Number(item.volumeCbm || 0),
})))
const fees = computed<FeeRow[]>(() => (detail.value?.fees || []).map((item) => ({
id: String(item.id), item: item.feeItemName, currency: item.originalCurrency, amount: `${item.originalCurrency} ${Number(item.originalAmount).toFixed(2)}`,
status: feeStatusLabel(item.reviewStatus === 'PENDING' ? 'PENDING_REVIEW' : item.reviewStatus), description: text(item.supplierName || (item.chargeableQuantity == null ? null : `${item.chargeableQuantity} ${item.quantityUnit || ''}`)),
})))
const logs = computed<LogRow[]>(() => {
const statusRows = (detail.value?.statusHistory || []).map((item) => ({
id: `status-${item.id}`, occurredAt: formatOverseasDateTime(item.createdAt), sortAt: item.createdAt || '', node: lastMileStatusLabel(item.toStatus), operator: '系统',
@@ -235,11 +248,6 @@ const documentColumns: DataTableColumn[] = [
{ key: 'type', prop: 'type', label: '单据类型', minWidth: 150 }, { key: 'number', prop: 'number', label: '单据编号', minWidth: 200 },
{ key: 'createdAt', prop: 'createdAt', label: '生成时间', minWidth: 170 }, { key: 'quote', prop: 'quote', label: '报价', minWidth: 150 },
]
const feeColumns: DataTableColumn[] = [
{ key: 'item', prop: 'item', label: '费用项目', minWidth: 180 }, { key: 'currency', prop: 'currency', label: '币种', width: 120 },
{ key: 'amount', prop: 'amount', label: '金额', minWidth: 160 }, { key: 'status', prop: 'status', label: '状态', width: 140 },
{ key: 'description', prop: 'description', label: '供应商/说明', minWidth: 260 },
]
const logColumns: DataTableColumn[] = [
{ key: 'occurredAt', prop: 'occurredAt', label: '时间', minWidth: 180 }, { key: 'node', prop: 'node', label: '操作节点', minWidth: 180 },
{ key: 'operator', prop: 'operator', label: '操作人', minWidth: 150 }, { key: 'result', prop: 'result', label: '处理结果', minWidth: 320 },
@@ -252,23 +260,73 @@ async function load() {
catch (error) { loadError.value = error instanceof Error && error.message === '当前环境未签发海外访问令牌' ? { message: error.message } : getApiErrorDetails(error, '尾程订单详情加载失败,请重试') }
finally { loading.value = false }
}
async function openFeeDialog() {
Object.assign(feeForm, { itemCode: '', itemName: '', direction: 'RECEIVABLE', currency: 'USD', amount: 0, businessDate: new Date().toISOString().slice(0, 10), supplierId: '', quantity: undefined, quantityUnit: '', periodStart: '', periodEnd: '' })
async function openFeeDialog(direction: 'RECEIVABLE' | 'PAYABLE' = 'RECEIVABLE') {
Object.assign(feeForm, { itemCode: '', itemName: '', feeRemark: '', settlementMode: '', direction, currency: 'USD', amount: 0, businessDate: new Date().toISOString().slice(0, 10), supplierId: '', quantity: undefined, quantityUnit: '' })
mutationError.value = null; feeDialogOpen.value = true
if (access.can('platform:supplier:list') && supplierOptions.value.length === 0) {
try { supplierOptions.value = (await listSuppliers({ page: 1, size: 200, status: 'ACTIVE' })).records.filter((item) => item.status === 'ACTIVE') }
catch (error) { mutationError.value = getApiErrorDetails(error, '供应商列表加载失败') }
}
await loadFeeItems()
}
async function loadFeeItems() {
const request = ++feeItemsRequest
const direction = feeForm.direction
feeItemsLoading.value = true; feeItemsError.value = ''; feeItemOptions.value = []
feeForm.itemCode = ''; feeForm.itemName = ''
try {
const items = await listOverseasFeeItems(direction)
if (request === feeItemsRequest) feeItemOptions.value = items.filter(item => item.feeCode !== 'FEE_TYPE_077')
} catch (error) {
if (request === feeItemsRequest) feeItemsError.value = getApiErrorDetails(error, '费用项目加载失败').message
} finally { if (request === feeItemsRequest) feeItemsLoading.value = false }
}
async function loadFeeSuppliers() {
const request = ++supplierRequest
supplierOptions.value = []; suppliersError.value = null; suppliersLoading.value = false
if (!feeDialogOpen.value || feeForm.direction !== 'PAYABLE') return
if (!access.can('platform:supplier:list')) { suppliersError.value = { message: '当前账号没有供应商查询权限' }; return }
suppliersLoading.value = true
try {
const result = await listSuppliers({ page: 1, size: 200, status: 'ACTIVE' })
if (request === supplierRequest) supplierOptions.value = result.records.filter(item => item.status === 'ACTIVE')
} catch (error) {
if (request === supplierRequest) suppliersError.value = getApiErrorDetails(error, '供应商列表加载失败')
} finally { if (request === supplierRequest) suppliersLoading.value = false }
}
watch(() => feeForm.direction, () => {
if (!feeDialogOpen.value) return
void loadFeeItems()
})
watch([feeDialogOpen, () => feeForm.direction], () => {
feeForm.supplierId = ''
void loadFeeSuppliers()
})
/** 从对应收付表读取完整单据费用,换标申请等关联单据一并核对,避免分页漏提醒。 */
async function loadExistingFees() {
const request = ++existingFeesRequest
existingFees.value = []; existingFeesError.value = ''; existingFeesLoading.value = false
if (!feeDialogOpen.value) return
existingFeesLoading.value = true
try {
const panel = feeForm.direction === 'PAYABLE' ? payableFeePanel.value : feePanel.value
if (!panel) throw new Error('单据费用尚未加载完成')
const rows = await panel.getFees()
if (request === existingFeesRequest) existingFees.value = rows
} catch (failure) { if (request === existingFeesRequest) existingFeesError.value = getApiErrorDetails(failure, '已有费用加载失败').message }
finally { if (request === existingFeesRequest) existingFeesLoading.value = false }
}
watch([feeDialogOpen, () => feeForm.direction, orderId], loadExistingFees)
async function saveFee() {
if (saving.value) return
if (!feeForm.itemCode || !feeForm.itemName.trim() || feeForm.amount <= 0 || !feeForm.businessDate) { ElMessage.warning('请填写费用项目、业务日期和大于 0 的金额'); return }
if (existingFeesLoading.value || existingFeesError.value) { ElMessage.warning(existingFeesError.value || '正在核对已有费用'); return }
if (duplicateFees.value.length && !feeForm.feeRemark.trim()) { ElMessage.warning('此单已有相同费用项目,请填写费用说明后继续添加'); return }
if (feeForm.direction === 'PAYABLE' && (suppliersLoading.value || suppliersError.value)) return
const selectedItem = feeItemOptions.value.find(item => item.feeCode === feeForm.itemCode)
if (feeItemsLoading.value || feeItemsError.value || !selectedItem || feeForm.amount <= 0) { ElMessage.warning('请选择费用项目,填写大于 0 的金额'); return }
const supplier = supplierOptions.value.find((item) => String(item.id) === feeForm.supplierId)
if (feeForm.direction === 'PAYABLE' && !supplier) { ElMessage.warning('应付费用必须选择供应商'); return }
saving.value = true; mutationError.value = null
try {
await createLastMileOrderFee(orderId.value, { feeItemCode: feeForm.itemCode, feeItemName: feeForm.itemName.trim(), 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: supplier ? 'ON_ACCOUNT' : undefined, chargeableQuantity: feeForm.quantity, quantityUnit: feeForm.quantity == null ? undefined : feeForm.quantityUnit.trim(), unitPrice: feeForm.quantity ? feeForm.amount / feeForm.quantity : undefined, periodStart: feeForm.periodStart || undefined, periodEnd: feeForm.periodEnd || undefined })
feeDialogOpen.value = false; ElMessage.success('费用已提交财务审核'); await load(); activeTab.value = 'fees'
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 })
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, '费用新增失败,请重试') }
finally { saving.value = false }
}
@@ -366,19 +424,29 @@ onMounted(load)
<section class="info-panel plan-panel" aria-label="计划信息"><dl class="detail-fields plan-fields density-fields"><div v-for="field in planFields" :key="field.label"><dt>{{ field.label }}</dt><dd :class="{ 'detail-field-value--business': isSingleLineBusinessField(field) }" :title="isSingleLineBusinessField(field) ? String(field.value) : undefined">{{ field.value }}</dd></div></dl></section>
<section class="table-panel related-panel"><div class="workbench-table"><DataTable :data="documents" :columns="documentColumns" column-settings-mode="hide" empty-text="暂无关联单据"><template #toolbar-start><h2>关联单据</h2></template></DataTable></div></section>
</el-tab-pane>
<el-tab-pane name="fees"><template #label><span class="density-tab-icon" aria-hidden="true">💰</span><span>费用明细</span></template><section class="table-panel"><div class="workbench-table"><DataTable :data="fees" :columns="feeColumns" column-settings-mode="hide" row-key="id" empty-text="暂无费用明细"><template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderTransfer)" type="primary" @click="openFeeDialog"><Plus :size="16" />新增费用</el-button></template></DataTable></div></section></el-tab-pane>
<el-tab-pane name="fees" lazy><template #label><span class="density-tab-icon" aria-hidden="true">💰</span><span>费用明细</span></template>
<template v-if="detail">
<div class="fee-create-toolbar"><el-button v-if="access.can(permission.lastMileOrderTransfer)" type="primary" @click="openFeeDialog()"><Plus :size="16" />新增费用</el-button></div>
<BusinessDocumentFees ref="feePanel" heading="应收明细" hide-when-empty direction="RECEIVABLE" :can-create="false" business-type="LASTLEG_ORDER" :business-id="detail.order.id" :business-no="detail.order.orderNo" :customer-id="detail.order.customerId" :related-documents="relatedFeeDocuments" @changed="load" />
<BusinessDocumentFees ref="payableFeePanel" heading="应付明细" hide-when-empty direction="PAYABLE" :can-create="false" business-type="LASTLEG_ORDER" :business-id="detail.order.id" :business-no="detail.order.orderNo" :customer-id="detail.order.customerId" :related-documents="relatedFeeDocuments" @changed="load" />
</template>
</el-tab-pane>
<el-tab-pane name="logs"><template #label><span class="density-tab-icon" aria-hidden="true">📝</span><span>操作记录</span></template><section class="table-panel"><div class="workbench-table"><DataTable :data="logs" :columns="logColumns" column-settings-mode="hide" row-key="id" empty-text="暂无操作记录"></DataTable></div></section></el-tab-pane>
</el-tabs>
</section>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="feeDialogOpen" :title="`新增费用 · ${referenceValue}`" width="min(720px, calc(100vw - 28px))" fullscreenable confirm-text="保存费用" :confirm-loading="saving" @confirm="saveFee">
<section class="density-dialog-section" data-tone="work" aria-label="尾程费用登记"><h3 class="density-dialog-section__title">费用登记</h3>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="feeDialogOpen" :title="`新增费用 · ${referenceValue}`" width="min(720px, calc(100vw - 28px))" fullscreenable confirm-text="保存费用" :confirm-loading="saving" :confirm-disabled="feeItemsLoading || !!feeItemsError || (feeForm.direction === 'PAYABLE' && (suppliersLoading || !!suppliersError))" @confirm="saveFee">
<section class="density-dialog-section" aria-label="尾程费用登记"><h3 class="density-dialog-section__title">费用登记</h3>
<el-alert v-if="feeItemsError" type="error" :closable="false" :title="feeItemsError"><el-button text type="primary" @click="loadFeeItems">重新加载</el-button></el-alert>
<el-alert v-if="feeForm.direction === 'PAYABLE' && suppliersError" type="error" :closable="false" :title="`供应商加载失败:${suppliersError.message}`"><el-button text type="primary" @click="loadFeeSuppliers">重试加载供应商</el-button><ApiErrorMeta :details="suppliersError" /></el-alert>
<el-form class="density-form" label-position="left" label-width="110px" :disabled="saving"><table class="density-table density-table--editable fee-editor-table"><tbody>
<tr><td><el-form-item label="费用项目" required><el-select v-model="feeForm.itemCode" filterable allow-create clearable default-first-option placeholder="请选择或输入费用项目" @change="feeForm.itemName = feeItemOptions.find(item => item.code === feeForm.itemCode)?.name || String(feeForm.itemCode).trim()"><el-option v-for="item in feeItemOptions" :key="item.code" :label="item.name" :value="item.code" /></el-select></el-form-item></td><td><el-form-item label="收付方向" required><el-select v-model="feeForm.direction"><el-option label="应收客户" value="RECEIVABLE" /><el-option label="应付供应商" value="PAYABLE" /></el-select></el-form-item></td></tr>
<tr v-if="feeForm.direction === 'PAYABLE'"><td colspan="2"><el-form-item label="供应商" required><el-select v-model="feeForm.supplierId" filterable 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-date-picker v-model="feeForm.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></td><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></tr>
<tr><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><td><el-form-item label="计费数量"><el-input-number v-model="feeForm.quantity" :min="0.0001" :precision="4" controls-position="right" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="计费单位"><el-input v-model="feeForm.quantityUnit" :disabled="saving || feeForm.quantity == null" placeholder="如 箱、托、天、m³" /></el-form-item></td></tr>
<tr><td><el-form-item label="计费开始日期"><el-date-picker v-model="feeForm.periodStart" type="date" value-format="YYYY-MM-DD" placeholder="仓租等周期费用填写" /></el-form-item></td><td><el-form-item label="计费结束日期"><el-date-picker v-model="feeForm.periodEnd" type="date" value-format="YYYY-MM-DD" placeholder="仓租等周期费用填写" /></el-form-item></td></tr>
<tr><td><el-form-item label="费用项目" required><el-select v-model="feeForm.itemCode" filterable clearable :loading="feeItemsLoading" :disabled="feeItemsLoading || !!feeItemsError" placeholder="请选择费用项目"><el-option v-for="item in feeItemOptions" :key="item.feeCode" :label="item.feeTypeName" :value="item.feeCode" /></el-select></el-form-item></td><td><el-form-item label="收付方向" required><el-select v-model="feeForm.direction"><el-option label="应收客户" value="RECEIVABLE" /><el-option label="应付供应商" value="PAYABLE" /></el-select></el-form-item></td></tr>
<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><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>
</tbody></table></el-form>
</section>
</AppDialog>
@@ -386,6 +454,7 @@ onMounted(load)
</template>
<style scoped>
.fee-create-toolbar { display: flex; justify-content: flex-end; margin-bottom: var(--admin-space-3); }
.source-heading { margin: 0; padding: var(--admin-space-3); font-size: var(--admin-font-section); font-weight: 600; }
.return-carton-title { display:flex; align-items:center; flex-wrap:wrap; gap:8px 16px; padding:4px 0; line-height:1.5; }
.return-carton-measurements { color:var(--cc-text-secondary); font-weight:400; }
@@ -209,6 +209,33 @@ async function chooseContainer(wrapper: Awaited<ReturnType<typeof mountView>>) {
}
describe('LastMileOrdersView pending order and container conversion', () => {
it('opens the same inbound-code detail from all and before departure despite different order IDs', async () => {
const wrapper = await mountView()
const vm = wrapper.vm as unknown as { openDetail(row: object): void }
const row = { ...selectedRow(), orderTypeCode: 'FIRST_LEG', containerNode: '—' }
routerPush.mockClear()
vm.openDetail(row)
expect(routerPush).toHaveBeenLastCalledWith({ name: 'unified-order-detail-by-inbound-code', params: { inboundCode: row.inboundCode }, query: { tenantId: customerId, from: 'last-mile-orders' } })
await wrapper.findAll('[role="tab"]').find(tab => tab.text() === '开船前')!.trigger('click')
await flushPromises()
vm.openDetail({ ...row, id: '9007199254740999', orderNo: '—' })
expect(routerPush).toHaveBeenLastCalledWith({ name: 'unified-order-detail-by-inbound-code', params: { inboundCode: row.inboundCode }, query: { tenantId: customerId, from: 'last-mile-orders' } })
vm.openDetail({ ...row, containerNode: 'ACTUAL_DEPARTURE' })
expect(routerPush).toHaveBeenLastCalledWith({ name: 'overseas-last-mile-order-detail', params: { orderId: row.id } })
})
it('only uses the domestic ID fallback for source rows without an inbound code', async () => {
const wrapper = await mountView()
const vm = wrapper.vm as unknown as { openDetail(row: object): void }
const row = { ...selectedRow(), orderTypeCode: 'FIRST_LEG', containerNode: '—', inboundCode: '—', orderNo: '—' }
routerPush.mockClear()
vm.openDetail(row)
expect(routerPush).toHaveBeenCalledWith({ name: 'unified-order-detail', params: { orderId: row.id }, query: { tenantId: customerId, from: 'last-mile-orders' } })
routerPush.mockClear()
vm.openDetail({ ...row, orderNo: 'WC-1' })
expect(routerPush).not.toHaveBeenCalled()
})
it('keeps return row statistics across pages without rendering the long summary blocks', async () => {
const totals = { orderCount: 3, skuTypes: 3, expectedQuantity: 19, receivedQuantity: 14, remainingQuantity: 5, receivedBoxCount: 3, receivedParcelCount: 2, receivedPalletCount: 1 }
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [], page: 1, size: 10, total: 30, pages: 3 })
@@ -1119,10 +1119,19 @@ async function submitReworkRelabel() {
} catch (error) { ElMessage.error(getApiErrorDetails(error, '重新换标失败').message) }
finally { reworkSaving.value = false }
}
/** 开船前统一按入仓码联查头程详情,保留返回尾程列表的来源。 */
function openDetail(source: object) {
const row = source as LastMileOrderListItem
if (isBeforeDeparture(row)) {
void router.push({ name: 'unified-order-detail', params: { orderId: row.id }, query: { tenantId: row.customerId } })
const query = { tenantId: row.customerId, from: 'last-mile-orders' }
if (row.inboundCode && row.inboundCode !== '—') {
void router.push({ name: 'unified-order-detail-by-inbound-code', params: { inboundCode: row.inboundCode }, query })
} else if (!row.orderNo || row.orderNo === '—') {
// 未生成尾程单的聚合行 ID 才是头程订单 ID。
void router.push({ name: 'unified-order-detail', params: { orderId: row.id }, query })
} else {
ElMessage.warning('该订单缺少入仓码,暂时无法定位头程详情')
}
return
}
void router.push({ name: 'overseas-last-mile-order-detail', params: { orderId: row.id } })
@@ -166,7 +166,7 @@ onMounted(initialize)
</script>
<template>
<AdminPage class="admin-density admin-workbench"><OperationErrorToast :details="mutationError" />
<AdminPage spacing="compact" class="pallet-page admin-density admin-workbench"><OperationErrorToast :details="mutationError" />
<section class="summary-grid" aria-label="海外托盘数量概览">
<article class="workbench-metric workbench-tone is-compact" data-tone="blue">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Layers aria-hidden="true" /></span><h2 class="workbench-metric-label">托盘总数</h2></div>
@@ -220,6 +220,7 @@ onMounted(initialize)
</template>
<style scoped>
.pallet-page.admin-page.is-spacing-compact { gap: var(--admin-space-3); }
.pallet-editor-table { table-layout: fixed; }
.pallet-editor-table td:not([colspan]) { width: 50%; }
@@ -1,10 +1,41 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { getRelabelOrder, getRelabelQuantities } from '../api/relabelOrders'
import RelabelOrderDetailView from './RelabelOrderDetailView.vue'
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'RELABEL-1' } }), useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => false }) }))
vi.mock('../api/relabelOrders', async importOriginal => ({
...await importOriginal<typeof import('../api/relabelOrders')>(), getRelabelOrder: vi.fn(), getRelabelQuantities: vi.fn(),
}))
vi.mock('@/modules/overseas-business', () => ({ BusinessDocumentFees: {
name: 'BusinessDocumentFees', props: ['businessType', 'businessNo', 'customerId', 'businessId'], template: '<section>本单费用</section>',
} }))
const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/RelabelOrderDetailView.vue'), 'utf8')
describe('RelabelOrderDetailView', () => {
it('resolves fees by the relabel identity without reusing the original customer order', async () => {
vi.mocked(getRelabelOrder).mockResolvedValue({
application: { id: '9007199254741004', relabelNo: 'RELABEL-1', lastlegOrderId: '9007199254741005', customerOrderNo: 'RETURN-1', chargeAmount: 25, chargeCurrency: 'CNY', walletStatus: 'NOT_REQUIRED' },
items: [], history: [],
})
vi.mocked(getRelabelQuantities).mockRejectedValue(new Error('数量暂不可用'))
const wrapper = mount(RelabelOrderDetailView, { global: { plugins: [ElementPlus], stubs: { AppDialog: true, RelabelDocumentsPanel: true } } })
await flushPromises()
expect(wrapper.findComponent({ name: 'BusinessDocumentFees' }).props()).toEqual({
businessType: 'RELABEL_ORDER', businessNo: 'RELABEL-1', customerId: undefined, businessId: '9007199254741004',
})
expect(wrapper.text()).toContain('原换标费')
expect(wrapper.text()).toContain('25.00 CNY')
expect(wrapper.text()).toContain('无需冻结或扣款')
expect(wrapper.text()).not.toContain('NOT_REQUIRED')
wrapper.unmount()
})
it('renders carton relabel mappings as a row list instead of summary form fields', () => {
expect(source).toContain('<template #content-after>')
expect(source).toContain('<h2>箱唛换标信息</h2>')
@@ -17,6 +17,7 @@ import OverseasReadOnlyDetail from '../components/OverseasReadOnlyDetail.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import RelabelDocumentsPanel from '../components/RelabelDocumentsPanel.vue'
import { BusinessDocumentFees } from '@/modules/overseas-business'
import { relabelSkuCodes } from '../utils/relabelSkuDisplay'
const route = useRoute()
@@ -83,7 +84,7 @@ const groups = [
{ title: '单据信息', fields: ['换标单号', '关联订单', '状态', '资金状态', '创建时间', '更新时间'] },
{ title: '商品信息', fields: ['商品SKU', '中文品名', '箱数', '贴标件数', 'SKU种数', '当前关联托盘', '本轮目标托盘', '原托盘快照', '预算总价'] },
{ title: '作业要求', fields: ['贴标要求', '上传文件', '备注'] },
{ title: '费用明细', fields: ['换标费总计', '资金异常说明'] },
{ title: '原换标收费', fields: ['原换标费', '资金异常说明'] },
]
function text(value: unknown) { return String(value ?? '').trim() || '—' }
function status(value: unknown) {
@@ -98,7 +99,7 @@ function status(value: unknown) {
CANCELLED: '已作废',
} as Record<string, string>)[String(value || '')] || text(value)
}
function walletStatus(value: unknown) { return ({ FREEZE_PENDING: '冻结中', FROZEN: '已冻结', SETTLE_PENDING: '结算中', SETTLED: '已结算', RELEASE_PENDING: '释放中', RELEASED: '已释放', FREEZE_FAILED: '冻结失败', SETTLE_FAILED: '结算失败', RELEASE_FAILED: '释放失败' } as Record<string,string>)[String(value || '')] || text(value) }
function walletStatus(value: unknown) { return ({ NOT_REQUIRED: '无需冻结或扣款', FREEZE_PENDING: '冻结中', FROZEN: '已冻结', SETTLE_PENDING: '结算中', SETTLED: '已结算', RELEASE_PENDING: '释放中', RELEASED: '已释放', FREEZE_FAILED: '冻结失败', SETTLE_FAILED: '结算失败', RELEASE_FAILED: '释放失败' } as Record<string,string>)[String(value || '')] || text(value) }
const referenceValue = computed(() => text(detail.value?.application.relabelNo || '换标单号待补充'))
const fieldValues = computed<Record<string, string | number>>(() => {
const value = detail.value
@@ -122,7 +123,7 @@ const fieldValues = computed<Record<string, string | number>>(() => {
贴标要求: items.map((item) => text(item.labelRequirement)).join(';') || '—',
上传文件: items.map((item) => text(item.relabelFileName)).filter((item) => item !== '—').join('、') || '—',
备注: text(value?.remark),
换标费总计: value?.application.chargeAmount == null ? '—' : `${Number(value.application.chargeAmount).toFixed(2)} ${value.application.chargeCurrency || 'USD'}`,
原换标费: value?.application.chargeAmount == null ? '—' : `${Number(value.application.chargeAmount).toFixed(2)} ${value.application.chargeCurrency || 'USD'}`,
资金异常说明: text(value?.application.walletErrorMessage),
}
})
@@ -182,6 +183,9 @@ async function previewCompletionPhoto(fileId: string) {
<OverseasReadOnlyDetail workbench class="admin-density" dense title="换标单详情" reference-label="换标单号" :reference-value="referenceValue" back-label="返回换标单" domain="换标单详情" :groups="groups" :field-values="fieldValues" :pending="false" :status-text="detail ? status(detail.application.status) : '加载中'" :loading="loading" :show-content="!loadError" @back="back">
<template #state><LoadErrorState v-if="loadError" title="换标单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<template #content-after>
<section v-if="detail" class="relabel-document-panel" aria-label="换标单费用">
<BusinessDocumentFees business-type="RELABEL_ORDER" :business-no="detail.application.relabelNo" :business-id="detail.application.id" />
</section>
<section v-if="access.can(permission.relabelOrderStatus) && canVoid" class="relabel-document-panel relabel-review-actions"><el-button type="danger" :loading="voiding" :disabled="confirming || returning" @click="voidOrder">作废</el-button></section>
<section v-if="access.can(permission.relabelOrderStatus) && ['CUSTOMER_CONFIRM_PENDING', 'PROCESSING'].includes(detail?.application.status || '') && detail?.completionEvidence?.photoFileIds?.length" class="relabel-document-panel relabel-review-actions"><el-button v-if="detail?.application.status === 'CUSTOMER_CONFIRM_PENDING'" type="primary" :loading="confirming" :disabled="returning || voiding" @click="confirmCompletion"><CheckCircle2 :size="15" />确认换标完成</el-button><el-button type="warning" :loading="returning" :disabled="confirming || voiding" @click="returnForRework"><Undo2 :size="15" />退回重新贴标</el-button></section>
<LoadErrorState v-if="uploadError" title="换标PDF操作失败" :message="uploadError.message" :show-retry="false"><template #details><ApiErrorMeta :details="uploadError" /></template></LoadErrorState>